Compare commits
14 Commits
c08f0a5d4a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a266e7fb3a | |||
| 85273f163c | |||
| 27dc6b6fb9 | |||
| cb86dec266 | |||
| 427129de19 | |||
| b85446865e | |||
| 846cd79541 | |||
| 21c5e15d90 | |||
| 98a79118ee | |||
| 1f37408776 | |||
| c481f931fd | |||
| c345c005cf | |||
| 3c708d4f77 | |||
| 56534c8bda |
@@ -1,6 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: web
|
build:
|
||||||
|
context: web
|
||||||
|
args:
|
||||||
|
- PB_EMAIL=${PB_EMAIL}
|
||||||
|
- PB_PW=${PB_PW}
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "4321:4321"
|
- "4321:4321"
|
||||||
@@ -15,3 +19,5 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
recipie:
|
||||||
18
web/Dockerfile
Normal file
18
web/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm i
|
||||||
|
COPY . .
|
||||||
|
ENV PUBLIC_PB_URL="http://pb:8090"
|
||||||
|
ENV PUBLIC_URL=http://localhost:4321
|
||||||
|
|
||||||
|
ARG PB_EMAIL
|
||||||
|
ARG PB_PW
|
||||||
|
ENV PB_EMAIL=$PB_EMAIL
|
||||||
|
ENV PB_PW=$PB_PW
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 4321
|
||||||
|
# CMD ["npm", "run", "dev", "--", "--host"]
|
||||||
|
CMD [ "npm", "run", "preview", "--", "--host" ]
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
---
|
|
||||||
import "../styles/global.css"
|
|
||||||
---
|
|
||||||
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta name="generator" content={Astro.generator} />
|
|
||||||
<title>Astro</title>
|
|
||||||
@@ -4,10 +4,10 @@ const links = [
|
|||||||
txt: "new",
|
txt: "new",
|
||||||
lnk: "/recipe/new"
|
lnk: "/recipe/new"
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
txt: "add",
|
// txt: "add",
|
||||||
lnk: "/recipe/import"
|
// lnk: "/recipe/import"
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
txt: "tags",
|
txt: "tags",
|
||||||
lnk: "/tags"
|
lnk: "/tags"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const tableView = true
|
|||||||
ingredients.map((ing, index) => (
|
ingredients.map((ing, index) => (
|
||||||
<>
|
<>
|
||||||
<tr class="border-t border-white/10 cursor-pointer hover:bg-white/10 transition-opacity ingredient-row" data-index={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.value.value}</td>
|
<td class="px-4 py-2">{ing.quantity ? ing.quantity.value.value : ''}</td>
|
||||||
<td class="px-4 py-2">{ing.unit || ''}</td>
|
<td class="px-4 py-2">{ing.unit || ''}</td>
|
||||||
<td class="px-4 py-2">{ing.name}{ing.preparation ? ` (${ing.preparation})` : ''}</td>
|
<td class="px-4 py-2">{ing.name}{ing.preparation ? ` (${ing.preparation})` : ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const { ingredients, class: className } = Astro.props;
|
|||||||
|
|
||||||
<div class={className}>
|
<div class={className}>
|
||||||
{ingredients.map(i => (
|
{ingredients.map(i => (
|
||||||
<ul class="text-sm">
|
<p class="text-sm">・{i.quantity ? i.quantity.value.value : ''} {i.unit || ""} {i.name} {i.preparation ? `(${i.preparation})` : ''}</p>
|
||||||
<li class="">{i.quantity.value.value} {i.unit || ""} {i.name} {i.preparation ? `(${i.preparation})` : ''}</li class=""> </ul>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -15,7 +15,7 @@ const { id, title, description, tags, image } = Astro.props
|
|||||||
<p id="recipe-name" class="text-[14pt] text-white opacity-90 font-bold line-clamp-1" >{title}</p>
|
<p id="recipe-name" class="text-[14pt] text-white opacity-90 font-bold line-clamp-1" >{title}</p>
|
||||||
<!-- <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="line-clamp-1">
|
||||||
<TagRow tags={tags}/>
|
<TagRow tags={tags}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
---
|
---
|
||||||
import BaseHead from '@component/BaseHead'
|
|
||||||
import Header from '@component/Header'
|
import Header from '@component/Header'
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
|
|
||||||
|
const {
|
||||||
|
title
|
||||||
|
} = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang=en>
|
<html lang=en>
|
||||||
<head>
|
<head>
|
||||||
<BaseHead title="Recipie" />
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>{title ? title : "Recipie"}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="main" class="flex-1">
|
<main id="main" class="flex-1">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const records = await pb.collection('recipes').getFullList()
|
|||||||
const recipes = records.map(r => new Recipe(r.cooklang))
|
const recipes = records.map(r => new Recipe(r.cooklang))
|
||||||
const ids = records.map(r => r.id)
|
const ids = records.map(r => r.id)
|
||||||
const images = await Promise.all(
|
const images = await Promise.all(
|
||||||
records.map(r => pb.files.getURL(r, r.images[0]).substring(21)) // get first image from each recipe as a cover image
|
records.map(r => '/api' + pb.files.getURL(r, r.images[0]).split('api')[1] ) // get first image from each recipe as a cover image
|
||||||
)
|
)
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
104
web/src/pages/recipe/[id]/edit.astro
Normal file
104
web/src/pages/recipe/[id]/edit.astro
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
import Base from "@layout/Base";
|
||||||
|
import { authPB } from "@data/pb";
|
||||||
|
|
||||||
|
const pb = await authPB()
|
||||||
|
const { id } = Astro.params
|
||||||
|
|
||||||
|
const rec = await pb.collection('recipes').getOne(id as string)
|
||||||
|
|
||||||
|
const templateHeaders = rec.cooklang
|
||||||
|
const recipeId = id as string
|
||||||
|
---
|
||||||
|
|
||||||
|
<script lang="ts" define:vars={{ recipeId }}>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelector('#btn-save').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const cooklangTextarea = document.querySelector('#cooklang')
|
||||||
|
const cooklangContent = cooklangTextarea.value;
|
||||||
|
|
||||||
|
// Get the file input
|
||||||
|
const photoInput = document.querySelector('#photo')
|
||||||
|
const photoFile = photoInput?.files?.[0];
|
||||||
|
|
||||||
|
// Create FormData for the upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('cooklang', cooklangContent);
|
||||||
|
|
||||||
|
if (photoFile) {
|
||||||
|
formData.append('images', photoFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the existing recipe using PATCH
|
||||||
|
const response = await fetch(`/api/collections/recipes/records/${recipeId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
// Redirect to the recipe page
|
||||||
|
window.location.href = `/recipe/${result.id}`;
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error('Update failed:', error);
|
||||||
|
alert('Failed to update recipe: ' + error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating recipe:', error);
|
||||||
|
alert('Error updating recipe: ' + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Base title="Edit Recipe">
|
||||||
|
<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">Edit Recipe</p>
|
||||||
|
<button
|
||||||
|
id="btn-save"
|
||||||
|
class="px-3 py-2 bg-white/10 hover:bg-white/20 active:bg-white/30 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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-4 md:mt-16 md:flex-2/3 w-full flex-col md:ml-3">
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
|
||||||
|
<div class="bg-[#2a2b2c] rounded-lg mb-2 p-2">
|
||||||
|
<textarea
|
||||||
|
id="cooklang"
|
||||||
|
rows="13"
|
||||||
|
class="block w-full rounded-lg px-3 py-1 font-mono resize-none"
|
||||||
|
oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'">{templateHeaders}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm italic text-white/40">Recipie uses <a class="underline" href="https://cooklang.org">Cooklang.</a> Visit the documentation to learn more.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -16,16 +16,16 @@ const re = await pb.collection('recipes').getOne(id as string)
|
|||||||
let recipeData = new Recipe(re.cooklang)
|
let recipeData = new Recipe(re.cooklang)
|
||||||
|
|
||||||
const images = await Promise.all(
|
const images = await Promise.all(
|
||||||
re.images.map(r => pb.files.getURL(re, r).substring(21))
|
re.images.map(r => '/api' + pb.files.getURL(re, r).split('api')[1])
|
||||||
)
|
)
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base>
|
<Base title=`${recipeData.metadata.title}: Recipie` >
|
||||||
<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" images={images} />
|
<ImageCarousel class="w-full" images={images} />
|
||||||
<p class="text-[28pt] font-bold leading-11 mt-2">{recipeData.metadata.title ?? "Untitled Recipe"}</p>
|
<p class="text-[28pt] font-bold leading-11 mt-2">{recipeData.metadata.title ?? "Untitled Recipe"}</p>
|
||||||
|
<a href=`${id}/edit`>edit</a>
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<InfoView
|
<InfoView
|
||||||
prepTime={recipeData.metadata?.["prep time"]}
|
prepTime={recipeData.metadata?.["prep time"]}
|
||||||
108
web/src/pages/recipe/new.astro
Normal file
108
web/src/pages/recipe/new.astro
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
import Base from "@layout/Base";
|
||||||
|
import { authPB } from "@data/pb";
|
||||||
|
|
||||||
|
const pb = await authPB()
|
||||||
|
|
||||||
|
const templateHeaders = `
|
||||||
|
---
|
||||||
|
title:
|
||||||
|
description:
|
||||||
|
cook time:
|
||||||
|
prep time:
|
||||||
|
servings:
|
||||||
|
tags:
|
||||||
|
-
|
||||||
|
---
|
||||||
|
`
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelector('#btn-save')!.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const cooklangTextarea = document.querySelector('#cooklang') as HTMLTextAreaElement;
|
||||||
|
const cooklangContent = cooklangTextarea?.value;
|
||||||
|
|
||||||
|
// Get the file input
|
||||||
|
const photoInput = document.querySelector('#photo') as HTMLInputElement;
|
||||||
|
const photoFile = photoInput?.files?.[0];
|
||||||
|
|
||||||
|
// Create FormData for the upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('cooklang', cooklangContent);
|
||||||
|
|
||||||
|
if (photoFile) {
|
||||||
|
formData.append('images', photoFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to PocketBase via /api
|
||||||
|
const response = await fetch('/api/collections/recipes/records', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
// Redirect to the recipe page
|
||||||
|
window.location.href = `/recipe/${result.id}`;
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
alert('Failed to save recipe: ' + error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving recipe:', error);
|
||||||
|
alert('Error saving recipe: ' + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Base title="New Recipe">
|
||||||
|
<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-3 py-2 bg-white/10 hover:bg-white/20 active:bg-white/30 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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-4 md:mt-16 md:flex-2/3 w-full flex-col md:ml-3">
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
|
||||||
|
<div class="bg-[#2a2b2c] rounded-lg mb-2 p-2">
|
||||||
|
<textarea
|
||||||
|
id="cooklang"
|
||||||
|
rows="13"
|
||||||
|
class="block w-full rounded-lg px-3 py-1 font-mono resize-none"
|
||||||
|
oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'">{templateHeaders}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm italic text-white/40">Recipie uses <a class="underline" href="https://cooklang.org">Cooklang.</a> Visit the documentation to learn more.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
78
web/src/pages/search.astro
Normal file
78
web/src/pages/search.astro
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
import { Recipe } from "@tmlmt/cooklang-parser"
|
||||||
|
import { authPB } from "@data/pb";
|
||||||
|
import RecipeCard from "@component/index/card"
|
||||||
|
import Base from "@layout/Base";
|
||||||
|
|
||||||
|
const pb = await authPB()
|
||||||
|
|
||||||
|
// Get search query from URL
|
||||||
|
const url = new URL(Astro.request.url);
|
||||||
|
const query = url.searchParams.get('q') || '';
|
||||||
|
|
||||||
|
let recipes: Recipe[] = [];
|
||||||
|
let ids: string[] = [];
|
||||||
|
let images: string[] = [];
|
||||||
|
let totalResults = 0;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
// Search in title, description, and cooklang content
|
||||||
|
const result = await pb.collection('recipes').getList(1, 50, {
|
||||||
|
filter: `cooklang ~ "${query}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
totalResults = result.totalItems;
|
||||||
|
recipes = result.items.map((r: any) => new Recipe(r.cooklang));
|
||||||
|
ids = result.items.map((r: any) => r.id);
|
||||||
|
images = await Promise.all(
|
||||||
|
result.items.map((r: any) => '/api' + pb.files.getURL(r, r.images[0]).split('api')[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title=`Search${ query != "" ? ": " + query : "" }`>
|
||||||
|
<div class="mb-6">
|
||||||
|
<!-- <h1 class="text-3xl font-bold mb-4">Search Recipes</h1> -->
|
||||||
|
|
||||||
|
<form method="get" class="my-6">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
value={query}
|
||||||
|
placeholder="Search recipes..."
|
||||||
|
class="flex-1 px-4 py-2 rounded-lg bg-[#2a2b2c] text-white border border-gray-600 focus:outline-none focus:border-gray-500"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-2 bg-gray-200/20 hover:bg-gray-200/30 rounded-lg font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{query && (
|
||||||
|
<p class="text-gray-400">
|
||||||
|
{totalResults} result{totalResults !== 1 ? 's' : ''} for "{query}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{query && recipes.length > 0 && (
|
||||||
|
<div class="grid gap-3 grid-cols-1 py-3 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
|
||||||
|
{
|
||||||
|
recipes.map((r, i) => (
|
||||||
|
<RecipeCard
|
||||||
|
id={ids[i]}
|
||||||
|
title={r.metadata.title ?? "Untitled Recipe"}
|
||||||
|
description={r.metadata.description ?? "No Description"}
|
||||||
|
tags={r.metadata.tags ?? []}
|
||||||
|
image={images[i]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Base>
|
||||||
39
web/src/pages/tags/[tag].astro
Normal file
39
web/src/pages/tags/[tag].astro
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
import Base from "@layout/Base";
|
||||||
|
import { Recipe } from "@tmlmt/cooklang-parser";
|
||||||
|
import { authPB } from "@data/pb";
|
||||||
|
import Card from "@component/index/card";
|
||||||
|
|
||||||
|
const { tag } = Astro.params
|
||||||
|
const pb = await authPB()
|
||||||
|
|
||||||
|
const records = await pb.collection('recipes').getFullList({
|
||||||
|
filter: `cooklang~"- ${tag}"` // what a hack lmao but it works
|
||||||
|
})
|
||||||
|
|
||||||
|
const recipes = records.map(r => new Recipe(r.cooklang))
|
||||||
|
const ids = records.map(r => r.id)
|
||||||
|
const images = await Promise.all(
|
||||||
|
records.map(r => '/api' + pb.files.getURL(r, r.images[0]).split('api')[1]) // get first image from each recipe as a cover image
|
||||||
|
)
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base>
|
||||||
|
<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" >{tag}</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, i) => (
|
||||||
|
<Card
|
||||||
|
id={ids[i]}
|
||||||
|
title={r.metadata.title ?? "Untitled Recipe"}
|
||||||
|
description={r.metadata.description ?? "No Description"}
|
||||||
|
tags={r.metadata.tags ?? []}
|
||||||
|
image={images[i]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
46
web/src/pages/tags/index.astro
Normal file
46
web/src/pages/tags/index.astro
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
import { authPB } from "@data/pb";
|
||||||
|
import { Recipe } from "@tmlmt/cooklang-parser";
|
||||||
|
import Base from "@layout/Base";
|
||||||
|
|
||||||
|
const pb = await authPB()
|
||||||
|
|
||||||
|
// Get all recipes
|
||||||
|
const records = await pb.collection('recipes').getFullList()
|
||||||
|
|
||||||
|
const recipes = records.map(r => new Recipe(r.cooklang))
|
||||||
|
|
||||||
|
// Extract all tags and count occurrences
|
||||||
|
const tagCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
recipes.forEach(recipe => {
|
||||||
|
const recipeTags = recipe.metadata?.tags;
|
||||||
|
if (Array.isArray(recipeTags)) {
|
||||||
|
recipeTags.forEach(tag => {
|
||||||
|
if (tag && typeof tag === 'string') {
|
||||||
|
const trimmedTag = tag.trim();
|
||||||
|
tagCounts.set(trimmedTag, (tagCounts.get(trimmedTag) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and sort by name
|
||||||
|
const tags = Array.from(tagCounts.keys()).sort();
|
||||||
|
const countsPerTag = tags.map(tag => tagCounts.get(tag) || 0);
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base>
|
||||||
|
<p class="title pb-2">
|
||||||
|
{tags.length} Tags
|
||||||
|
</p>
|
||||||
|
{
|
||||||
|
(tags ?? []).map((t, i) => (
|
||||||
|
// <p>{t.name} -> {countsPerTag[i]} {countsPerTag[i] == 1 ? "Recipe" : "Recipes" } </p>
|
||||||
|
<a class="hover:underline" href={`/tags/${t}`}>
|
||||||
|
{t} ({countsPerTag[i]})
|
||||||
|
</a><br/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Base>
|
||||||
Reference in New Issue
Block a user