Compare commits

..

10 Commits

Author SHA1 Message Date
e9965c38ff [PIE-29] New recipe form submission (#15)
Basic form submission of recipe. Not everything is handled yet: images, tags, and step-associated-ingredients are not handled, as they do not have an interface in the recipe form yet.
Co-authored-by: June <self@breadone.net>
Co-committed-by: June <self@breadone.net>
2025-08-22 16:43:40 +12:00
b1d95ea785 [PIE-30] Complete fields in /recipe/new (#14)
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-18 17:48:14 +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
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
db6df2053a [PIE-11] Add option to change from table to list view (!9)
Option interface not implemented, will be in a future settings page. But anyway idk i like the table view more
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-13 19:46:34 +12:00
c12a4ab1aa [PIE-17] Design refinements and improvements (!8)
Add ingredients to each step in detailview, add tags, and some refactoring and smaller improvements

Reviewed-on: #8
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-13 19:24:09 +12:00
3c0e9dc0dd [PIE-14] More robust API proxy (!7)
Finally! Using an Astro API Route to handle all the queries which works like a dream. Also created a custom function to get the relative path for image files, fixing any external access issues.
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-13 17:54:45 +12:00
b8de3e82e9 [PIE-8] Add recipe detail view And Other Major Changes (!6)
- Adds /recipe/:id page
- (Almost!) fully designed and functional recipe view
- Has steps, images, ingredients, peripheral info (work/wait time, rating, description, servings)
- New colour scheme! Much more monotone but in a nice way (imo)
- New font, not condensed
- Several more smaller design changes
Co-authored-by: june <self@breadone.net>
Co-committed-by: june <self@breadone.net>
2025-08-13 16:54:05 +12:00
25 changed files with 786 additions and 128 deletions

View File

@@ -9,5 +9,5 @@ ENV PUBLIC_URL=http://localhost:4321
RUN npm run build
EXPOSE 4321
CMD ["npm", "run", "dev", "--", "--host"]
# CMD [ "node", "dist/sever/entry.mjs"]
# CMD ["npm", "run", "dev", "--", "--host"]
CMD [ "npm", "run", "preview", "--", "--host" ]

View File

@@ -0,0 +1,44 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_842702175")
// add field
collection.fields.addAt(9, new Field({
"hidden": false,
"id": "number1485952547",
"max": null,
"min": null,
"name": "worktime",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
// add field
collection.fields.addAt(10, new Field({
"hidden": false,
"id": "number2198822773",
"max": null,
"min": null,
"name": "waittime",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_842702175")
// remove field
collection.fields.removeById("number1485952547")
// remove field
collection.fields.removeById("number2198822773")
return app.save(collection)
})

View File

@@ -7,8 +7,6 @@ import tailwindcss from '@tailwindcss/vite';
import { loadEnv } from "vite";
const { PUBLIC_PB_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), "");
// https://astro.build/config
export default defineConfig({
output: 'server',
@@ -20,15 +18,7 @@ export default defineConfig({
vite: {
plugins: [tailwindcss()],
server: {
proxy: {
// The idea is to proxy the Pocketbase connection to the current domain so the user doesn't have to open two ports
// Currently works in dev (npm run dev -- --host) with the correct PUBLIC_URL var set but not through docker
'/api': {
target: PUBLIC_PB_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
}
cors: false
}
}
});

View File

@@ -2,7 +2,6 @@ services:
web:
build: .
env_file: .env
network_mode: host
ports:
- "4321:4321"

View File

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

View File

@@ -1,29 +1,12 @@
---
import client from "../../data/pocketbase"
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
}
}))
: []
const { tags } = Astro.props
---
<div class="">
{
tags.map(tag => (
(tags ?? []).map(tag => (
<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"
>
{tag.name}

View File

@@ -1,17 +1,7 @@
---
import client from "@/data/pocketbase";
const { class: className, recipe } = Astro.props
async function getLink(img: string) {
const record = await client.collection("images").getOne(img)
const link = await client.files.getURL(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))
)
const links = await client.getRecipeImages(recipe as string)
---
@@ -49,10 +39,10 @@ const links = await Promise.all(
<!-- Hidden element to pass server data to client -->
<div class="hidden" id="carousel-data">{JSON.stringify(links)}</div>
<div class="relative flex items-center w-full md:w-80 h-60">
<button id="dec-button" class="absolute left-2" onclick="dec()">PREV</button>
<img id="carousel-img" class="w-full h-full object-cover" src={links[0]} />
<div class="relative flex items-center w-full h-60">
<button id="dec-button" class="absolute left-2" onclick="dec()">&lt;</button>
<img id="carousel-img" class="rounded-lg w-full h-full object-cover" src={links[0]} />
<!-- <div class="w-70 h-60 bg-green-600" /> -->
<button id="inc-button" class="absolute right-2" onclick="inc()">NEXT</button>
<button id="inc-button" class="absolute right-2" onclick="inc()">&gt;</button>
</div>
</div>

View File

@@ -0,0 +1,40 @@
---
import TagRow from "../Card/TagRow.astro";
const { re } = Astro.props
function formatTime(seconds) {
if (seconds === 0) return null
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
let result = "";
if (h > 0) result += `${h}h`;
if (m > 0) result += `${m}m`;
if (result === "") result = "0m";
return result;
}
function formatTimeMin(minutes) {
if (minutes === 0) return null
const h = Math.floor(minutes / 60);
const m = minutes % 60;
let result = "";
if (h > 0) result += `${h}h`;
if (m > 0) result += `${m}m`;
if (result === "") result = "0m";
return result;
}
const workTime = formatTimeMin(re.worktime)
const waitTime = formatTimeMin(re.waittime)
---
<p class="text-white/60 text-sm mt-1 italic">{re.description}</p>
<div class="flex pt-1 items-center">
{re.servings !== 0 && (<p class="border-r pr-2">Serves: {re.servings}</p>)}
{workTime && (<p class="border-r px-2">{workTime} Work</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>)}
</div>
<TagRow tags={re.expand.tags}/>

View File

@@ -2,41 +2,67 @@
// const { ingredients } = Astro.props
const { class: className, ingredients } = Astro.props
const tableView = true
---
<!-- <div class=`bg-[#2a2b2c] rounded-lg grid grid-cols-3 text-center ${className}`>
<span class="font-bold">Quantity</span>
<span class="font-bold">Unit</span>
<span class="font-bold">Food</span>
{!tableView && (
<div class={`bg-[#2a2b2c] p-3 rounded-lg w-full text-left ${className}`}>
{
ingredients.map(ing => (
<span>{ing.quantity}</span>
<span>{ing.unit}</span>
<span>{ing.name}</span>
<div class="text-white/70">
<p>• {ing.quantity}&nbsp;{ing.unit}&nbsp;{ing.name}</p>
</div>
))
}
</div> -->
</div>
)}
<table class={`table-auto bg-[#2a2b2c] rounded-lg ${className} w-full`}>
<thead>
<tr>
<th class="text-center px-4 py-2">Quantity</th>
<th class="text-center px-4 py-2">Unit</th>
<th class="text-center px-4 py-2">Food</th>
</tr>
</thead>
<tbody>
{
ingredients.map(ing => (
<>
<tr class="border-t border-gray-600">
<td class="text-left px-4 py-2">{ing.quantity}</td>
<td class="text-left px-4 py-2">{ing.unit}</td>
<td class="text-left px-4 py-2">{ing.name}</td>
</tr>
</>
))
}
</tbody>
</table>
{tableView && (
<table class={`table-auto text-left bg-[#2a2b2c] rounded-lg ${className}`}>
<thead>
<tr>
<th class="px-4 py-2">Quantity</th>
<th class="px-4 py-2">Unit</th>
<th class="px-4 py-2">Ingredient</th>
</tr>
</thead>
<tbody>
{
ingredients.map((ing, index) => (
<>
<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>
</tr>
</>
))
}
</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

@@ -0,0 +1,14 @@
---
import client from "@/data/pocketbase";
const { ingredients, class: className } = Astro.props;
---
<div class={className}>
{ingredients.map(i => (
<div class="text-sm">
<p>• {i.quantity} {i.unit || " "} {i.name}</p>
</div>
))}
</div>

View File

@@ -0,0 +1,25 @@
---
import { record } from "astro:schema"
import client from "@/data/pocketbase"
import StepIngredientSideView from "./StepIngredientSideView.astro"
const { steps, class: className } = Astro.props
---
<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">
<p class="text-bold text-[10pt]">Step {s.index + 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">{s.instruction}</p>
{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">
<StepIngredientSideView class="text-left" ingredients={s.expand.ingredients} />
</div>
)}
</div>
</div>
)) }
</div>

View File

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

View File

@@ -1,5 +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(import.meta.env.PUBLIC_URL)
client.autoCancellation(false)
class APIClient {
pb: Pocketbase
constructor() {
this.pb = new Pocketbase("http://localhost:4321")
this.pb.autoCancellation(false)
}
async getRecipesPage(page: number, perPage: number = 30, options: RecordListOptions) {
return await this.pb.collection<Recipe>(Collection.RECIPES).getList(page, perPage, options)
}
async getAllRecipes() {
return await this.pb.collection<Recipe>(Collection.RECIPES).getFullList({ expand: 'ingredients,tags,steps,images,steps.ingredients' })
}
async getRecipe(id: string) {
return await this.pb.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.pb.collection(Collection.IMAGES).getOne(imgID)
const res = this.pb.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.pb.collection<Tag>(Collection.TAGS).getFullList()
}
async getTag(name: string) {
return await this.pb.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.pb.collection<Recipe>(Collection.RECIPES).getFullList({
filter: `tags ~ '${tag.id}'`,
expand: 'ingredients,tags,steps,images,steps.ingredients'
})
}
}
const client = new APIClient()
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'
}

View File

@@ -10,7 +10,7 @@ import Header from "@/components/Header";
<body>
<main id="main" class="flex-1">
<Header/>
<div class="px-3 md:px-5 pt-2">
<div class="px-3 mb-5 md:px-5 pt-2">
<slot />
</div>
</main>

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

@@ -0,0 +1,19 @@
import type { APIRoute } from "astro";
// THANK YOU https://stackoverflow.com/a/77297521 !!!!!!!
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 + requestUrl.search, proxyUrl);
};
export const ALL: APIRoute = async ({ request }) => {
const proxyUrl = getProxyUrl(request);
const response = await fetch(proxyUrl.href, request);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
};

View File

@@ -3,15 +3,15 @@ import PageLayout from "@/layouts/base"
import client from "@/data/pocketbase"
import OverviewCard from "@/components/Card/OverviewCard"
const recipies = await client.collection("recipes").getFullList()
const recipes = await client.getAllRecipes()
---
<PageLayout>
<!-- <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} />
))
}

View File

@@ -1,53 +1,33 @@
---
import client from "@/data/pocketbase";
import SiteLayout from "@/layouts/base";
import ImageCarousel from "@/components/Detail/ImageCarousel";
import IngredientTableView from "@/components/Detail/IngredientTableView";
import StepView from "@/components/Detail/StepView";
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 client.getRecipe(recipeid as string)
---
<SiteLayout>
<div class="flex flex-col md:flex-row">
<div class="flex flex-col ">
<ImageCarousel class="w-full" recipe={re} />
<p class=" md:hidden text-[28pt] font-bold leading-none mt-2">{re.name}</p>
<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={recipeid} />
<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 flex-col">
<p class="hidden md:block text-[28pt] font-bold pl-5">{re.name}</p>
<div class="md:ml-5 mt-2 md:mt-0">
<p class="text-[22pt] font-bold">Steps</p>
{ steps.map(s => (
<div class="bg-[#2a2b2c] rounded-lg mb-2 p-3 md:">
<p>Step {s.index + 1}</p>
<p>{s.instruction}</p>
</div>
)) }
</div>
<div class="flex mt-4 md:flex-2/3 w-full flex-col">
<!-- Steps -->
<StepView class="md:ml-3" steps={re.expand.steps ?? []} />
</div>
</div>

140
src/pages/recipe/new.astro Normal file
View File

@@ -0,0 +1,140 @@
---
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 sticky">
<div class="flex mb-2 items-center">
<p class="text-[28pt] mr-auto">New Recipe</p>
<button
id="btn-save"
class="px-1 w-15 h-9 bg-white/10 active:bg-white/20 transition-colors rounded-lg"
>
Save
</button>
</div>
<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>
<!-- Details -->
<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'"
/>
<textarea
id="rec-desc"
rows="3"
placeholder="Description"
class="text-sm italic leading-none mt-2 bg-white/10 rounded-lg resize-none overflow-hidden p-2"
oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'"
/>
<!-- if it works :3 -->
<!-- Smaller details -->
<div class="flex mt-2 h-9 space-x-2">
<input
id="rec-servings"
type="number"
class="bg-white/10 px-2 rounded-lg w-24 overflow-hidden"
placeholder="Servings"
/>
<input
id="rec-worktime"
type="text"
class="bg-white/10 px-2 rounded-lg w-24 overflow-hidden"
placeholder="Work Time"
/>
<input
id="rec-waittime"
type="text"
class="bg-white/10 px-2 rounded-lg w-24 overflow-hidden"
placeholder="Wait Time"
/>
<input
id="rec-rating"
type="number"
class="bg-white/10 px-2 rounded-lg w-23 overflow-hidden"
placeholder="Rating"
/>
</div>
<div class="flex flex-row align-middle items-center">
<p class="mt-4 text-[22pt] font-bold">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:mt-16 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>

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>

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

@@ -0,0 +1,227 @@
import client from "@/data/pocketbase"
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: {quantity: string, unit: string, name: string}[] = []
let steps: {
index: number,
instruction: string,
ingredients: string[], // IDs of ingredient fields
}[] = []
const formFields = () => {
return {
name: (document.querySelector("#rec-name") as HTMLTextAreaElement).value ?? "",
description: (document.querySelector("#rec-desc") as HTMLTextAreaElement).value ?? "",
servings: (document.querySelector("#rec-servings") as HTMLInputElement).value ?? "",
worktime: (document.querySelector("#rec-worktime") as HTMLInputElement).value ?? "",
waittime: (document.querySelector("#rec-waittime") as HTMLInputElement).value ?? "",
rating: (document.querySelector("#rec-rating") as HTMLInputElement).value ?? "",
ings: ingredients,
stepList: steps
}
}
// - INIT
document.addEventListener('DOMContentLoaded', function() {
ingredientFields.push(
document.querySelector('#ing-qty')!,
document.querySelector('#ing-unit')!,
document.querySelector('#ing-name')!
)
stepInput.addEventListener('input', showAddStepButton)
document.querySelector('#btn-save')?.addEventListener('click', addRecipe)
// 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
async function addRecipe() {
const comps = formFields()
console.log(comps.ings)
const ingredientIDs = await Promise.all(
comps.ings.map(async it => (await client.pb.collection('ingredients').create(it)).id) // get the id of the returned record
)
const stepIDs = await Promise.all(
comps.stepList.map(async it => (await client.pb.collection('steps').create(it)).id)
)
const recipe = await client.pb.collection('recipes').create({
name: comps.name,
description: comps.description,
servings: comps.servings,
worktime: comps.worktime,
waittime: comps.waittime,
rating: comps.rating,
ingredients: ingredientIDs,
steps: stepIDs
})
console.log(recipe)
}
function addIngredient() {
const ing = {
quantity: 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.quantity}</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,4 +7,8 @@ html {
/* @apply font-; */
@apply font-sans;
/* font-family: 'SF Pro Display', 'Segoe UI', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; */
}
.title {
@apply text-2xl;
}

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"]