Compare commits

..

4 Commits

Author SHA1 Message Date
691409318d Add description field 2025-08-17 11:40:23 +12:00
bf5d2f24c2 [PIE-16] Basic tag page implementation (#13)
Adds `/tags/[name]` and associated API routes and such. `/tags/` has an extremely barebones layout atm so it's not publicly visible

Reviewed-on: #13
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-16 23:17:31 +12:00
6145f14df0 [PIE-28] Switch to type-safe API (#12)
Custom API client to handle a lot of the more type safe queries

Reviewed-on: #12
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-16 13:17:10 +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
17 changed files with 210 additions and 76 deletions

View File

@@ -4,8 +4,7 @@ import TagRow from "./TagRow.astro"
const { recipe } = Astro.props; const { recipe } = Astro.props;
const headerImage = await client.collection("images").getOne(recipe.images[0]) const image = (await client.getRecipeImages(recipe.id))[0]
const image = await client.files.getRelativeURL(headerImage, headerImage.image)
--- ---
<div class="relative z-0 flex h-50"> <div class="relative z-0 flex h-50">
@@ -19,7 +18,7 @@ const image = await client.files.getRelativeURL(headerImage, headerImage.image)
<!-- <p id="recipe-desc" class="text-white text-[10pt]"> {recipe.description} </p> --> <!-- <p id="recipe-desc" class="text-white text-[10pt]"> {recipe.description} </p> -->
<div id="tag-row" class=""> <div id="tag-row" class="">
<TagRow tagIds={recipe.tags}/> <TagRow tags={recipe.expand.tags}/>
</div> </div>
</div> </div>

View File

@@ -1,29 +1,12 @@
--- ---
import client from "../../data/pocketbase" const { tags } = Astro.props
interface Props {
tagIds: string[]
}
const { tagIds } = Astro.props
const tags = tagIds && tagIds.length > 0
? await Promise.all(tagIds.map(async (tagId: string) => {
try {
const tagData = await client.collection("tags").getOne(tagId)
return { name: tagData.name, id: tagId }
} catch (error) {
return null
}
}))
: []
--- ---
<div class=""> <div class="">
{ {
tags.map(tag => ( (tags ?? []).map(tag => (
<a <a
href={`/tag/${tag.id}`} href={`/tags/${tag.name}`}
class="text-white bg-white/20 px-2 mr-2 mt-2 rounded-md inline-block hover:bg-white/30" class="text-white bg-white/20 px-2 mr-2 mt-2 rounded-md inline-block hover:bg-white/30"
> >
{tag.name} {tag.name}

View File

@@ -1,17 +1,7 @@
--- ---
import client from "@/data/pocketbase"; import client from "@/data/pocketbase";
const { class: className, recipe } = Astro.props const { class: className, recipe } = Astro.props
const links = await client.getRecipeImages(recipe as string)
async function getLink(img: string) {
const record = await client.collection("images").getOne(img)
const link = await client.files.getRelativeURL(record, record.image)
return link
}
// Use Promise.all to wait for all async operations to complete
const links = await Promise.all(
recipe.images.map((img: string) => getLink(img))
)
--- ---

View File

@@ -26,4 +26,4 @@ const waitTime = formatTime(re.waittime)
{waitTime && (<p class="border-r px-2">{waitTime} Wait</p>)} {waitTime && (<p class="border-r px-2">{waitTime} Wait</p>)}
{re.rating !== 0 && (<p class="pl-2">{re.rating}</p><p class="text-white/40 text-xs">/10</p>)} {re.rating !== 0 && (<p class="pl-2">{re.rating}</p><p class="text-white/40 text-xs">/10</p>)}
</div> </div>
<TagRow tagIds={re.tags}/> <TagRow tags={re.expand.tags}/>

View File

@@ -3,13 +3,10 @@ import client from "@/data/pocketbase";
const { ingredients, class: className } = Astro.props; const { ingredients, class: className } = Astro.props;
const ings = await Promise.all(
ingredients.map(async i => await client.collection("ingredients").getOne(i))
)
--- ---
<div class={className}> <div class={className}>
{ings.map(i => ( {ingredients.map(i => (
<div class="text-sm"> <div class="text-sm">
<p>• {i.quantity} {i.unit || " "} {i.name}</p> <p>• {i.quantity} {i.unit || " "} {i.name}</p>
</div> </div>

View File

@@ -16,7 +16,7 @@ const { steps, class: className } = Astro.props
<p class="w-full md:flex-2/3 pr-1 text-left">{s.instruction}</p> <p class="w-full md:flex-2/3 pr-1 text-left">{s.instruction}</p>
{s.ingredients && s.ingredients.length > 0 && ( {s.ingredients && s.ingredients.length > 0 && (
<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"> <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} /> <StepIngredientSideView class="text-left" ingredients={s.expand.ingredients} />
</div> </div>
)} )}
</div> </div>

View File

@@ -7,8 +7,9 @@
</a> </a>
<div class="ml-auto space-x-5"> <div class="ml-auto space-x-5">
<a class="hover:underline underline-offset-4 " href="/recipe/new">new</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" href="/recipe/import">add</a>
<a class="hover:underline underline-offset-4 " >search</a> <!-- <a class="hover:underline underline-offset-4" href="/tags">tags</a> -->
<a class="hover:underline underline-offset-4" >search</a>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,71 @@
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") class APIClient {
client.autoCancellation(false) client: Pocketbase
constructor() {
this.client = new Pocketbase("http://localhost:4321")
this.client.autoCancellation(false)
}
async getRecipesPage(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: 'ingredients,tags,steps,images,steps.ingredients' })
}
async getRecipe(id: string) {
return await this.client.collection<Recipe>(Collection.RECIPES).getOne(id, { expand: 'ingredients,tags,steps,images,steps.ingredients' })
}
// IMAGE
async getImageURL(imgID: string, relative: boolean = true) {
const record = await this.client.collection(Collection.IMAGES).getOne(imgID)
const res = this.client.files.getURL(record, record.image)
return relative ? res.substring(21) : res
}
async getRecipeImages(recipeID: string) {
const re = await this.getRecipe(recipeID)
const imgIDs = re.images ?? []
const urls = Promise.all(
imgIDs.map(img => this.getImageURL(img))
)
return urls
}
async getAllTags() {
return await this.client.collection<Tag>(Collection.TAGS).getFullList()
}
async getTag(name: string) {
return await this.client.collection<Tag>(Collection.TAGS).getList(1, 50, { filter: `name = '${name}'` })
}
async getRecipesOfTag(tagName: string) {
// get the tag id first
const tagResult = await this.getTag(tagName)
if (tagResult.items.length === 0) {
return []
}
const tag = tagResult.items[0]
return await this.client.collection<Recipe>(Collection.RECIPES).getFullList({
filter: `tags ~ '${tag.id}'`,
expand: 'ingredients,tags,steps,images,steps.ingredients'
})
}
// Return a relative url for file instead of full path including localhost, which breaks external access
client.files.getRelativeURL = (record: { [key: string]: any; }, filename: string, queryParams?: FileOptions | undefined) => {
const res = client.files.getURL(record, filename)
return res.substring(21)
} }
const client = new APIClient()
export default client; export default client;

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

@@ -0,0 +1,55 @@
// 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
filename: string
url?: 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'
}

9
src/pages/404.astro Normal file
View File

@@ -0,0 +1,9 @@
---
import Base from "@/layouts/base";
---
<Base>
<div class="flex items-center justify-center text-3xl font-bold">
🥧 404 🥧
</div>
</Base>

View File

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

View File

@@ -3,15 +3,15 @@ import PageLayout from "@/layouts/base"
import client from "@/data/pocketbase" import client from "@/data/pocketbase"
import OverviewCard from "@/components/Card/OverviewCard" import OverviewCard from "@/components/Card/OverviewCard"
const recipies = await client.collection("recipes").getFullList() const recipes = await client.getAllRecipes()
--- ---
<PageLayout> <PageLayout>
<!-- <p class="pb-2">What would you like today?</p> --> <!-- <p class="pb-2">What would you like today?</p> -->
<div class="grid md:gap-2 gap-3 grid-cols-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-8"> <div class="grid gap-3 grid-cols-1 py-3 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
{ {
recipies.map(r => ( recipes.map(r => (
<OverviewCard recipe={r} /> <OverviewCard recipe={r} />
)) ))
} }

View File

@@ -1,5 +1,6 @@
--- ---
import client from "@/data/pocketbase"; import client from "@/data/pocketbase";
import SiteLayout from "@/layouts/base"; import SiteLayout from "@/layouts/base";
import ImageCarousel from "@/components/Detail/ImageCarousel"; import ImageCarousel from "@/components/Detail/ImageCarousel";
import IngredientTableView from "@/components/Detail/IngredientTableView"; import IngredientTableView from "@/components/Detail/IngredientTableView";
@@ -8,41 +9,25 @@ import InfoView from "@/components/Detail/InfoView";
const { recipeid } = Astro.params; const { recipeid } = Astro.params;
const re = await client.collection("recipes").getOne(recipeid ?? "0"); const re = await client.getRecipe(recipeid as string)
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)
)
)
--- ---
<SiteLayout> <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 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="flex md:flex-1/3 flex-col mt-2 md:mt-4 sticky">
<ImageCarousel class="w-full" recipe={re} /> <ImageCarousel class="w-full" recipe={recipeid} />
<p class="text-[28pt] font-bold leading-11 mt-2">{re.name}</p> <p class="text-[28pt] font-bold leading-11 mt-2">{re.name}</p>
<!-- Details --> <!-- Details -->
<InfoView re={re} /> <InfoView re={re} />
<p class="text-[22pt] font-bold 'md:mt-4'">Ingredients</p> <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>
<div class="flex mt-4 md:flex-2/3 w-full flex-col"> <div class="flex mt-4 md:flex-2/3 w-full flex-col">
<!-- Steps --> <!-- Steps -->
<StepView class="md:ml-3" steps={steps} /> <StepView class="md:ml-3" steps={re.expand.steps ?? []} />
</div> </div>
</div> </div>

View File

@@ -34,6 +34,13 @@ async function submitRecipe() {
placeholder="Name" placeholder="Name"
class="text-[28pt] font-bold p-1 leading-none mt-2 bg-white/10 rounded-lg resize-none overflow-hidden" 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'" oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'"
/>
<textarea
id="rec-desc"
rows="4"
placeholder="Description"
class="text-sm italic 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 --> <!-- if it works :3 -->

View File

@@ -0,0 +1,22 @@
---
import SiteLayout from "@/layouts/base";
import client from "@/data/pocketbase";
import OverviewCard from "@/components/Card/OverviewCard";
const { name } = Astro.params
const recipes = await client.getRecipesOfTag(name) // todo redir to 404 if not found
---
<SiteLayout>
<p class="text-xl pb-2">
{recipes.length} { recipes.length == 1 ? "Recipe" : "Recipes" } with: <code class="bg-white/10 p-1 text-sm rounded-lg" >{name}</code>
</p>
<div class="grid md:gap-2 gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-8">
{
recipes.map(r => (
<OverviewCard recipe={r} />
))
}
</div>
</SiteLayout>

View File

@@ -0,0 +1,24 @@
---
import client from "@/data/pocketbase";
import SiteLayout from "@/layouts/base";
const tags = await client.getAllTags()
const countsPerTag = await Promise.all(
tags.map(async t => (await client.getRecipesOfTag(t.name)).length)
)
---
<SiteLayout>
<p class="title pb-2">
{tags.length} Tags
</p>
{
(tags ?? []).map((t, i) => (
// <p>{t.name} -&gt; {countsPerTag[i]} {countsPerTag[i] == 1 ? "Recipe" : "Recipes" } </p>
<a class="hover:underline" href={`/tags/${t.name}`}>
{t.name} ({countsPerTag[i]})
</a><br/>
))
}
</SiteLayout>

View File

@@ -8,3 +8,7 @@ html {
@apply font-sans; @apply font-sans;
/* font-family: 'SF Pro Display', 'Segoe UI', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; */ /* font-family: 'SF Pro Display', 'Segoe UI', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; */
} }
.title {
@apply text-2xl;
}