Compare commits

...

6 Commits

Author SHA1 Message Date
b514655b5c progressively updating to use api 2025-08-16 12:22:10 +12:00
b3f92210fa Proxy forwards Query parameters too 2025-08-16 11:01:56 +12:00
2e54690ee4 Starting basic API client 2025-08-15 19:01:47 +12:00
f7e24657f5 Initial schema creation 2025-08-15 18:02:13 +12:00
0b1334d508 [PIE-13] New recipe page (!11)
Add `/recipe/new` path with form to add new recipe
Mostly designed but not fully: steps and ingredients are inputtable with final styling but it cannot be submitted yet, and other crucial components like description, rating, etc are not yet implemented.
Many design changes too cos i couldnt help myself

More additions will certainly be required but this PR is huge so I will split it out into more
Reviewed-on: #11
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-15 16:12:02 +12:00
26cab10c14 [PIE-22] Add checklist for ingredients (!10)
Each row in the table can be clicked/tapped to be crossed off to complete
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-13 23:55:11 +12:00
11 changed files with 410 additions and 41 deletions

View File

@@ -19,7 +19,7 @@ const tableView = true
)}
{tableView && (
<table class={`table-auto text-left bg-[#2a2b2c] rounded-lg ${className} w-full`}>
<table class={`table-auto text-left bg-[#2a2b2c] rounded-lg ${className}`}>
<thead>
<tr>
<th class="px-4 py-2">Quantity</th>
@@ -29,9 +29,9 @@ const tableView = true
</thead>
<tbody>
{
ingredients.map(ing => (
ingredients.map((ing, index) => (
<>
<tr class="border-t border-gray-600">
<tr class="border-t border-white/10 cursor-pointer hover:bg-white/10 transition-opacity ingredient-row" data-index={index}>
<td class="px-4 py-2">{ing.quantity}</td>
<td class="px-4 py-2">{ing.unit}</td>
<td class="px-4 py-2">{ing.name}</td>
@@ -41,4 +41,28 @@ const tableView = true
}
</tbody>
</table>
)}
)}
<style>
.ingredient-row.completed {
/* background-color: rgba(0, 0, 0, 0.4) !important; */
opacity: 0.6;
}
.ingredient-row.completed td {
text-decoration: line-through;
color: rgba(255, 255, 255, 0.5);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const rows = document.querySelectorAll('.ingredient-row');
rows.forEach(row => {
row.addEventListener('click', function() {
this.classList.toggle('completed');
});
});
});
</script>

View File

@@ -10,7 +10,7 @@ const ings = await Promise.all(
<div class={className}>
{ings.map(i => (
<div>
<div class="text-sm">
<p>• {i.quantity} {i.unit || " "} {i.name}</p>
</div>
))}

View File

@@ -3,10 +3,10 @@ import { record } from "astro:schema"
import client from "@/data/pocketbase"
import StepIngredientSideView from "./StepIngredientSideView.astro"
const { steps } = Astro.props
const { steps, class: className } = Astro.props
---
<div class="md:ml-5 mt-2 md:mt-0">
<div class={className}>
<p class="text-[22pt] font-bold md:hidden">Steps</p>
{ steps.map(s => (
<div class="bg-[#2a2b2c] rounded-lg mb-2 p-3">
@@ -15,7 +15,7 @@ const { steps } = Astro.props
<div class="flex flex-col md:flex-row md:items-stretch">
<p class="w-full md:flex-2/3 pr-1 text-left">{s.instruction}</p>
{s.ingredients && s.ingredients.length > 0 && (
<div class="w-full md:w-auto md:flex-1/3 mt-2 md:mt-0 md:ml-2 md:pl-3 md:border-l text-white/70 border-white/70">
<div class="w-full md:w-auto md:flex-2/5 mt-2 md:mt-0 md:ml-2 md:pl-3 md:border-l text-white/70 border-white/70">
<StepIngredientSideView class="text-left" ingredients={s.ingredients} />
</div>
)}

View File

@@ -7,8 +7,8 @@
</a>
<div class="ml-auto space-x-5">
<a>new</a>
<a>tags</a>
<a>search</a>
<a class="hover:underline underline-offset-4 " href="/recipe/new">new</a>
<a class="hover:underline underline-offset-4 " >tags</a>
<a class="hover:underline underline-offset-4 " >search</a>
</div>
</div>

View File

@@ -1,4 +1,11 @@
import Pocketbase from "pocketbase"
import Pocketbase, { type RecordListOptions } from "pocketbase"
import {
type Recipe,
type Ingredient,
type Step,
type Tag,
Collection
} from './schema'
const client = new Pocketbase("http://localhost:4321")
client.autoCancellation(false)
@@ -9,4 +16,27 @@ client.files.getRelativeURL = (record: { [key: string]: any; }, filename: string
return res.substring(21)
}
export default client;
export default client;
class APIClient {
client: Pocketbase
constructor() {
this.client = new Pocketbase("http://localhost:4321")
this.client.autoCancellation(false)
}
async getRecipesPaginated(page: number, perPage: number = 30, options: RecordListOptions) {
return await this.client.collection<Recipe>(Collection.RECIPES).getList(page, perPage, options)
}
async getAllRecipes() {
return await this.client.collection<Recipe>(Collection.RECIPES).getFullList({ expand: "steps,ingredients,tags" })
}
async getRecipe(id: string) {
return await this.client.collection<Recipe>(Collection.RECIPES).getOne(id, { expand: 'ingredients,tags,steps' })
}
}
export const client2 = new APIClient()

53
src/data/schema.ts Normal file
View File

@@ -0,0 +1,53 @@
// Base PB type
export interface BaseRecord {
id: string,
created: string,
updated: string
}
export interface Ingredient extends BaseRecord {
quantity: string,
unit: string,
name: string
}
export interface Step extends BaseRecord {
index: number,
instruction: string,
ingredients?: Ingredient[]
}
export interface Tag extends BaseRecord {
name: string
}
// not sure Image is the best type cos it might be quite heavy to get all the fields every time but
// it is here in case it is (a good idea)
export interface Image extends BaseRecord {
id: string
}
export interface Recipe extends BaseRecord {
name: string,
description?: string,
servings?: number,
images?: string[], // image IDs
ingredients: string[]
steps: string[],
tags?: string[]
expand: {
images?: Image[], // image IDs,
ingredients: Ingredient[]
steps: Step[],
tags?: Tag[]
}
}
export const Collection = {
RECIPES: 'recipes',
STEPS: 'steps',
INGREDIENTS: 'ingredients',
TAGS: 'tags',
IMAGES: 'images'
}

View File

@@ -5,8 +5,7 @@ import type { APIRoute } from "astro";
const getProxyUrl = (request: Request) => {
const proxyUrl = new URL(import.meta.env.PUBLIC_PB_URL);
const requestUrl = new URL(request.url);
return new URL(requestUrl.pathname, proxyUrl);
return new URL(requestUrl.pathname + requestUrl.search, proxyUrl);
};
export const ALL: APIRoute = async ({ request }) => {

View File

@@ -1,5 +1,6 @@
---
import client from "@/data/pocketbase";
import client, { client2 } from "@/data/pocketbase";
import SiteLayout from "@/layouts/base";
import ImageCarousel from "@/components/Detail/ImageCarousel";
import IngredientTableView from "@/components/Detail/IngredientTableView";
@@ -8,43 +9,25 @@ import InfoView from "@/components/Detail/InfoView";
const { recipeid } = Astro.params;
const re = await client.collection("recipes").getOne(recipeid ?? "0");
const stepIds = re.steps
let steps = await Promise.all(
stepIds.map(async s =>
await client.collection("steps").getOne(s)
)
)
steps = steps.sort((a, b) => a.index - b.index);
const ingredients = await Promise.all(
re.ingredients.map(async s =>
await client.collection("ingredients").getOne(s)
)
)
const re = await client2.getRecipe(recipeid as string)
---
<SiteLayout>
<div class="flex flex-col md:flex-row">
<div class="flex flex-col mt-2 md:mt-4 sticky">
<div class="flex flex-col md:flex-row mx-auto justify-center w-full lg:max-w-3/4 xl:max-w-2/3 2xl:max-w-1/2">
<div class="flex md:flex-1/3 flex-col mt-2 md:mt-4 sticky">
<ImageCarousel class="w-full" recipe={re} />
<p class=" md:hidden text-[28pt] font-bold leading-none mt-2">{re.name}</p>
<p class="text-[28pt] font-bold leading-11 mt-2">{re.name}</p>
<!-- Details -->
<InfoView re={re} />
<p class="text-[22pt] font-bold 'md:mt-4'">Ingredients</p>
<IngredientTableView class:list={['md:w-80', 'px-4']} ingredients={ingredients} />
<IngredientTableView class:list={['md:w-80', 'px-4']} ingredients={re.expand.ingredients} />
</div>
<div class="flex w-full flex-col">
<p class="hidden md:block text-[28pt] font-bold pl-5">{re.name}</p>
<div class="flex mt-4 md:flex-2/3 w-full flex-col">
<!-- Steps -->
<StepView steps={steps} />
<StepView class="md:ml-3" steps={re.expand.steps} />
</div>
</div>

View File

@@ -0,0 +1,94 @@
---
import SiteLayout from "@/layouts/base";
const { recipeid } = Astro.params;
// Actually post the recipe with the stored variables
async function submitRecipe() {
}
---
<script src="@/script/newRecipe.ts"/>
<SiteLayout>
<div class="flex flex-col md:flex-row mx-auto justify-center w-full lg:max-w-3/4 xl:max-w-2/3 2xl:max-w-1/2">
<div class="flex md:flex-1/3 flex-col mt-2 md:mt-4 sticky">
<div class="relative">
<input
id="photo"
type="file"
accept="image/png,image/jpeg"
class="w-full bg-white/10 rounded-lg h-50
file:mr-4 file:py-2 file:px-4
file:rounded-lg file:border-0 file:hidden
before:content-['camera'] before:w-full before:h-full before:flex
before:items-center before:justify-center before:absolute
relative cursor-pointer
[&::-webkit-file-upload-button]:hidden"
>
</div>
<textarea
id="rec-name"
rows="1"
placeholder="Name"
class="text-[28pt] font-bold p-1 leading-none mt-2 bg-white/10 rounded-lg resize-none overflow-hidden"
oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'"
/>
<!-- if it works :3 -->
<!-- Details -->
<!-- <InfoView re={re} /> -->
<div class="flex flex-row align-middle items-center">
<p class="mt-4 text-[22pt] font-bold 'mt-4'">Ingredients</p>
<button disabled class="disabled:text-white/20 transition-colors ml-auto mt-5 text-white bg-white/10 rounded-lg px-3 py-1 " id="add-ingredient-btn" >Add</button>
</div>
<table class={`table-fixed text-left bg-[#2a2b2c] rounded-lg w-full`}>
<thead>
<tr>
<th class="px-4 py-2 w-18">Qty</th>
<th class="px-4 py-2 w-20">Unit</th>
<th class="px-4 py-2">Ingredient</th>
</tr>
</thead>
<tbody id="ingredient-table" class="w-full border-t px-4 py-2 border-white/10">
<tr id="ingredient-input" class="">
<td class="px-2 py-1">
<input id="ing-qty" class="w-full h-9 my-1 bg-white/10 rounded-lg px-2 py-2" type="text" placeholder="Qty">
</td>
<td class="px-2 py-1">
<input id="ing-unit" class="w-full h-9 bg-white/10 rounded-lg px-2 py-2" type="text" placeholder="Unit">
</td>
<td class="px-2 py-1">
<!-- <textarea id="ing-name" class="w-full h-11 bg-white/20 rounded-lg px-2 py-3 mt-1 resize-none leading-tight" placeholder="Ingredient" rows="1"/> -->
<input id="ing-name" class="w-full h-9 bg-white/10 rounded-lg px-2 py-2" type="text" placeholder="Ingredient">
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex mt-4 md:flex-2/3 w-full flex-col md:ml-3">
<!-- <p class="hidden md:block text-[28pt] font-bold pl-5">Helloi</p> -->
<!-- Steps -->
<p class="text-[22pt] font-bold md:hidden">Steps</p>
<div class="bg-[#2a2b2c] rounded-lg mb-2 p-2">
<!-- <p class="text-bold text-[10pt]">Step</p> -->
<textarea
id="new-instruction"
class="block bg-white/10 w-full h-full rounded-lg resize-none px-3 py-1"
placeholder="New Step"
oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'"
/>
</div>
<button disabled class="disabled:text-white/20 ml-auto mb-2 transition-colors text-white bg-white/10 rounded-lg px-3 py-1 " id="add-step-btn" >Add</button>
<ul id="step-list"></ul>
</div>
</div>
</SiteLayout>

185
src/script/newRecipe.ts Normal file
View File

@@ -0,0 +1,185 @@
let ingredientFields: HTMLInputElement[] = []
let ingredientTable: HTMLTableSectionElement = document.querySelector('#ingredient-table')!
let ingredientAddButton: HTMLButtonElement = document.querySelector('#add-ingredient-btn')!
let stepInput: HTMLTextAreaElement = document.querySelector('#new-instruction')!
let stepAddButton: HTMLButtonElement = document.querySelector('#add-step-btn')!
let stepList: HTMLUListElement = document.querySelector('#step-list')!
let currentStepIndex = 0
// - VARS
let ingredients: {qty: string, unit: string, name: string}[] = []
let steps: {
index: number,
instruction: string,
ingredients: string[], // IDs of ingredient fields
}[] = []
// - INIT
document.addEventListener('DOMContentLoaded', function() {
ingredientFields.push(
document.querySelector('#ing-qty')!,
document.querySelector('#ing-unit')!,
document.querySelector('#ing-name')!
)
stepInput.addEventListener('input', showAddStepButton)
// show plus button once the user types in the text fields
ingredientFields.forEach(f => {
f.addEventListener('input', showAddIngredientButton)
f.addEventListener('keydown', showAddIngredientButton)
})
// onclick for add button
document.querySelector('#add-ingredient-btn')?.addEventListener('click', addIngredient);
// Enter key navigation for ingredient fields
ingredientFields[0].addEventListener('keydown', e => {
if (e.key === 'Enter') {
ingredientFields[1].focus() // Move from qty to unit
}
})
ingredientFields[1].addEventListener('keydown', e => {
if (e.key === 'Enter') {
ingredientFields[2].focus() // Move from unit to name
}
})
// for pressing enter to add ingredient (on the last field)
ingredientFields[2].addEventListener('keydown', e => {if (e.key === 'Enter') addIngredient()} )
// Initial check for button state
showAddIngredientButton()
showAddStepButton()
// Steps
stepInput.addEventListener('keyup', e => { if (e.key === 'Enter' && e.shiftKey) addStep() } )
stepAddButton.addEventListener('click', addStep)
});
// - ADD
function addIngredient() {
const ing = {
qty: ingredientFields[0].value,
unit: ingredientFields[1].value,
name: ingredientFields[2].value
}
ingredients.push(ing)
const newRow = document.createElement('tr')
newRow.innerHTML = `
<td class="px-4 py-2 border-t border-white/10">${ing.qty}</td>
<td class="px-4 py-2 border-t border-white/10">${ing.unit}</td>
<td class="px-4 py-2 border-t border-white/10">${ing.name}</td>
`
// Add row to table and clear fields
ingredientTable.appendChild(newRow)
ingredientFields.forEach(f => f.value = '')
ingredientAddButton.disabled = true // Hide Add Ingredient button
// move cursor to Qty field again
ingredientFields[0].focus()
}
function addStep() {
const step = {
index: currentStepIndex++,
instruction: stepInput.value,
ingredients: []
}
steps.push(step)
renderSteps()
stepInput.value = ''
stepAddButton.disabled = true
stepInput.focus()
}
function renderSteps() {
// clear the step list
stepList.innerHTML = ''
// re-render all steps in their current order
steps.forEach((step, displayIndex) => {
const newStep = document.createElement('div')
// times like this i regret using astro
newStep.innerHTML = `
<div class="flex justify-between items-start ">
<div class="flex flex-col">
<p class="text-bold text-[10pt]">Step ${displayIndex + 1}</p>
<div class="flex flex-col md:flex-row md:items-stretch">
<p class="w-full md:flex-2/3 pr-1 text-left">${step.instruction}</p>
</div>
</div>
<div class="flex flex-col gap-1">
<button id="move-up-btn" class="px-2 py-1 bg-white/10 hover:bg-white/30 transition-colors rounded text-xs" data-step-index="${step.index}" ${displayIndex === 0 ? 'disabled' : ''}>↑</button>
<button id="move-down-btn" class="px-2 py-1 bg-white/10 hover:bg-white/30 transition-colors rounded text-xs" data-step-index="${step.index}" ${displayIndex === steps.length - 1 ? 'disabled' : ''}>↓</button>
</div>
</div>
`
newStep.id = `step-${step.index}`
newStep.className = "bg-[#2a2b2c] rounded-lg mb-2 p-3"
stepList.appendChild(newStep)
})
// event listeners to reorder buttons
document.querySelectorAll('#move-up-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const stepIndex = parseInt((e.target as HTMLButtonElement).dataset.stepIndex!)
moveStep(stepIndex, -1)
})
})
document.querySelectorAll('#move-down-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const stepIndex = parseInt((e.target as HTMLButtonElement).dataset.stepIndex!)
moveStep(stepIndex, 1)
})
})
}
// - UTILS
function showAddIngredientButton() {
const hasQty = ingredientFields[0].value.trim().length > 0
const hasName = ingredientFields[2].value.trim().length > 0
if (hasQty && hasName) {
ingredientAddButton.disabled = false
} else {
ingredientAddButton.disabled = true
}
}
function showAddStepButton() {
if (stepInput.value.trim().length > 0) {
stepAddButton.disabled = false
} else {
stepAddButton.disabled = true
}
}
// shift: the direction to move. should be +1 to move down or -1 to move up the list
function moveStep(stepIndex: number, shift: number) {
// Find the step in the array
const currentStepArrayIndex = steps.findIndex(step => step.index === stepIndex)
if (currentStepArrayIndex === -1) return // step not found
const newIndex = currentStepArrayIndex + shift
// check bounds
if (newIndex < 0 || newIndex >= steps.length) return
// swap the steps in the array
const temp = steps[currentStepArrayIndex]
steps[currentStepArrayIndex] = steps[newIndex]
steps[newIndex] = temp
// re-render the steps
renderSteps()
}

View File

@@ -7,6 +7,7 @@
"paths": {
"@/components/*": ["src/components/*.astro"],
"@/layouts/*": ["src/layouts/*.astro"],
"@/script/*": ["src/script/*"],
"@/utils": ["src/utils/index.ts"],
"@/data/*": ["src/data/*"],
"@/site-config": ["src/site.config.ts"]