Compare commits

...

10 Commits

10 changed files with 316 additions and 7 deletions

13
web/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
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
RUN npm run build
EXPOSE 4321
# CMD ["npm", "run", "dev", "--", "--host"]
CMD [ "npm", "run", "preview", "--", "--host" ]

View File

@@ -32,7 +32,7 @@ const tableView = true
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.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.name}{ing.preparation ? ` (${ing.preparation})` : ''}</td>
</tr>

View File

@@ -4,7 +4,6 @@ const { ingredients, class: className } = Astro.props;
<div class={className}>
{ingredients.map(i => (
<ul class="text-sm">
<li class="">{i.quantity.value.value} {i.unit || ""} {i.name} {i.preparation ? `(${i.preparation})` : ''}</li class=""> </ul>
<p class="text-sm">・{i.quantity ? i.quantity.value.value : ''} {i.unit || ""} {i.name} {i.preparation ? `(${i.preparation})` : ''}</p>
))}
</div>

View File

@@ -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-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}/>
</div>
</div>

View File

@@ -11,7 +11,7 @@ const records = await pb.collection('recipes').getFullList()
const recipes = records.map(r => new Recipe(r.cooklang))
const ids = records.map(r => r.id)
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
)
---

View 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>
<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>

View File

@@ -16,7 +16,7 @@ const re = await pb.collection('recipes').getOne(id as string)
let recipeData = new Recipe(re.cooklang)
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])
)
---
@@ -25,7 +25,7 @@ const images = await Promise.all(
<div class="flex md:flex-1/3 flex-col mt-2 md:mt-4 sticky">
<ImageCarousel class="w-full" images={images} />
<p class="text-[28pt] font-bold leading-11 mt-2">{recipeData.metadata.title ?? "Untitled Recipe"}</p>
<a href=`${id}/edit`>edit</a>
<!-- Details -->
<InfoView
prepTime={recipeData.metadata?.["prep time"]}

View 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>
<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>

View 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>

View 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} -&gt; {countsPerTag[i]} {countsPerTag[i] == 1 ? "Recipe" : "Recipes" } </p>
<a class="hover:underline" href={`/tags/${t}`}>
{t} ({countsPerTag[i]})
</a><br/>
))
}
</Base>