your personal website on atproto - mirror blento.app

Merge pull request #257 from hipstersmoothie/kich

Kich Cards

authored by

Florian and committed by
GitHub
394840cc 3157d42c

+1346 -1
+7 -1
src/lib/cards/index.ts
··· 53 53 import { MarginCardDefinition } from './social/MarginCard'; 54 54 import { SembleCollectionCardDefinition } from './social/SembleCollectionCard'; 55 55 import { GermDMCardDefinition } from './social/GermDMCard'; 56 + import { KichRecipeCardDefinition } from './social/KichRecipeCard'; 57 + import { KichRecipeCollectionCardDefinition } from './social/KichRecipeCollectionCard'; 58 + import { KichCookingLogCardDefinition } from './social/KichCookingLogCard'; 56 59 // import { Model3DCardDefinition } from './visual/Model3DCard'; 57 60 58 61 export const AllCardDefinitions = [ ··· 111 114 PlyrFMCollectionCardDefinition, 112 115 MarginCardDefinition, 113 116 SembleCollectionCardDefinition, 114 - GermDMCardDefinition 117 + GermDMCardDefinition, 118 + KichRecipeCardDefinition, 119 + KichRecipeCollectionCardDefinition, 120 + KichCookingLogCardDefinition 115 121 ] as const; 116 122 117 123 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+161
src/lib/cards/social/KichCookingLogCard/KichCookingLogCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { CardDefinitionsByType } from '../..'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 + import type { ContentComponentProps } from '../../types'; 6 + import type { KichCookingLogEntry } from '.'; 7 + 8 + let { item }: ContentComponentProps = $props(); 9 + 10 + const data = getAdditionalUserData(); 11 + const did = getDidContext(); 12 + const handle = getHandleContext(); 13 + 14 + let clientLogs = $state<KichCookingLogEntry[] | undefined>(undefined); 15 + let logs = $derived( 16 + clientLogs ?? (data[item.cardType] as KichCookingLogEntry[] | undefined) ?? [] 17 + ); 18 + let isLoading = $state(false); 19 + let isLoaded = $state(false); 20 + 21 + onMount(async () => { 22 + if (logs.length === 0) { 23 + isLoading = true; 24 + const loaded = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 25 + did, 26 + handle 27 + })) as KichCookingLogEntry[] | undefined; 28 + clientLogs = loaded ?? []; 29 + data[item.cardType] = clientLogs; 30 + isLoading = false; 31 + } 32 + isLoaded = true; 33 + }); 34 + 35 + function getRecipeUrl(entry: KichCookingLogEntry): string { 36 + if (!entry.recipeRkey) return entry.recipeUri; 37 + const actor = 38 + entry.recipeRepo && entry.recipeRepo.startsWith('did:') 39 + ? entry.recipeRepo === did 40 + ? handle 41 + : entry.recipeRepo 42 + : entry.recipeRepo || handle; 43 + return `https://kich.io/profile/${actor}/recipe/${entry.recipeRkey}`; 44 + } 45 + 46 + function getImageUrl(entry: KichCookingLogEntry): string | undefined { 47 + if (!entry.recipeRepo || !entry.recipe) return entry.recipe?.imageUrl; 48 + const first = entry.recipe.images?.[0]; 49 + if (!first?.ref?.$link) return entry.recipe.imageUrl; 50 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${entry.recipeRepo}/${first.ref.$link}@jpeg`; 51 + } 52 + 53 + function formatDate(dateString: string): string { 54 + const date = new Date(dateString); 55 + if (Number.isNaN(date.getTime())) return 'recently'; 56 + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 57 + } 58 + </script> 59 + 60 + <div class="h-full w-full p-4"> 61 + {#if logs.length > 0} 62 + <div class="logs-list"> 63 + {#each logs as entry, index (`log-${index}`)} 64 + <a 65 + href={getRecipeUrl(entry)} 66 + target="_blank" 67 + rel="noopener noreferrer" 68 + class="log-card relative block aspect-video shrink-0 overflow-hidden rounded-xl" 69 + > 70 + {#if getImageUrl(entry)} 71 + <img 72 + src={getImageUrl(entry)} 73 + alt={entry.recipe?.name || 'Recipe'} 74 + class="absolute inset-0 h-full w-full object-cover" 75 + /> 76 + {:else} 77 + <div 78 + class="from-base-300 to-base-200 dark:from-base-800 dark:to-base-900 absolute inset-0 bg-gradient-to-br" 79 + ></div> 80 + {/if} 81 + <div class="log-overlay pointer-events-none absolute inset-0"></div> 82 + <div class="absolute right-0 bottom-0 left-0 p-3"> 83 + <div class="mb-2 flex flex-wrap gap-1 text-[11px]"> 84 + <span class="rounded-full bg-black/30 px-2 py-0.5 text-white/95"> 85 + {formatDate(entry.createdAt)} 86 + </span> 87 + {#if entry.scaledServings} 88 + <span class="rounded-full bg-black/30 px-2 py-0.5 text-white/95"> 89 + {entry.scaledServings} servings 90 + </span> 91 + {/if} 92 + {#if entry.recipe?.servings !== undefined} 93 + <span class="rounded-full bg-black/30 px-2 py-0.5 text-white/95"> 94 + Servings {entry.recipe.servings} 95 + </span> 96 + {/if} 97 + </div> 98 + <h3 class="line-clamp-2 text-sm font-semibold text-white"> 99 + {entry.recipe?.name || 'Cooked recipe'} 100 + </h3> 101 + {#if entry.notes} 102 + <p class="mt-1 line-clamp-2 text-xs text-white/90"> 103 + {entry.notes} 104 + </p> 105 + {/if} 106 + </div> 107 + </a> 108 + {/each} 109 + </div> 110 + {:else if isLoaded && !isLoading} 111 + <div 112 + class="text-base-500 dark:text-base-400 flex h-full items-center justify-center text-center text-sm" 113 + > 114 + No cooking logs yet 115 + </div> 116 + {:else} 117 + <div 118 + class="text-base-500 dark:text-base-400 flex h-full items-center justify-center text-center text-sm" 119 + > 120 + Loading cooking logs... 121 + </div> 122 + {/if} 123 + </div> 124 + 125 + <style> 126 + .logs-list { 127 + display: flex; 128 + height: 100%; 129 + min-height: 0; 130 + flex-direction: column; 131 + gap: 0.75rem; 132 + overflow-y: auto; 133 + } 134 + 135 + .log-card { 136 + flex: 0 0 auto; 137 + } 138 + 139 + .log-overlay { 140 + background: linear-gradient( 141 + to top, 142 + rgba(0, 0, 0, 0.9), 143 + rgba(0, 0, 0, 0.16) 60%, 144 + rgba(0, 0, 0, 0) 145 + ); 146 + } 147 + 148 + @container card (aspect-ratio > 1/1) { 149 + .logs-list { 150 + flex-direction: row; 151 + overflow-x: auto; 152 + overflow-y: hidden; 153 + scroll-snap-type: x mandatory; 154 + } 155 + 156 + .log-card { 157 + width: min(24rem, 78%); 158 + scroll-snap-align: start; 159 + } 160 + } 161 + </style>
+144
src/lib/cards/social/KichCookingLogCard/index.ts
··· 1 + import { getRecord, listRecords, parseUri, resolveHandle } from '$lib/atproto'; 2 + import type { Did, Handle } from '@atcute/lexicons'; 3 + import type { CardDefinition } from '../../types'; 4 + import KichCookingLogCard from './KichCookingLogCard.svelte'; 5 + 6 + const KICH_COOKING_LOG_COLLECTION = 'io.kich.cookinglog'; 7 + const KICH_RECIPE_COLLECTION = 'io.kich.recipe.recipe'; 8 + 9 + type StrongRef = { 10 + uri: string; 11 + cid?: string; 12 + }; 13 + 14 + type KichBlob = { 15 + $type: 'blob'; 16 + ref: { 17 + $link: string; 18 + }; 19 + mimeType?: string; 20 + size?: number; 21 + }; 22 + 23 + type KichRecipeRecord = { 24 + name?: string; 25 + description?: string; 26 + imageUrl?: string; 27 + images?: KichBlob[]; 28 + servings?: number; 29 + }; 30 + 31 + export type KichCookingLogRecord = { 32 + subject: StrongRef; 33 + scaledServings?: unknown; 34 + notes?: string; 35 + createdAt: string; 36 + }; 37 + 38 + export type KichCookingLogEntry = { 39 + logUri: string; 40 + createdAt: string; 41 + notes?: string; 42 + scaledServings?: string; 43 + recipeUri: string; 44 + recipeRepo?: string; 45 + recipeRkey?: string; 46 + recipe?: KichRecipeRecord; 47 + }; 48 + 49 + export const KichCookingLogCardDefinition = { 50 + type: 'kichCookingLog', 51 + contentComponent: KichCookingLogCard, 52 + createNew: (card) => { 53 + card.cardType = 'kichCookingLog'; 54 + card.w = 4; 55 + card.h = 4; 56 + card.mobileW = 8; 57 + card.mobileH = 6; 58 + card.cardData.label = 'Cooking Log'; 59 + }, 60 + loadData: async (_items, { did }) => { 61 + const records = (await listRecords({ 62 + did, 63 + collection: KICH_COOKING_LOG_COLLECTION, 64 + limit: 50 65 + })) as Array<{ 66 + uri?: string; 67 + value?: unknown; 68 + }>; 69 + 70 + const logs: KichCookingLogEntry[] = []; 71 + const recipeCache = new Map<string, KichRecipeRecord>(); 72 + 73 + for (const record of records) { 74 + const value = record.value as KichCookingLogRecord | undefined; 75 + if (!value?.subject?.uri || !value.createdAt) continue; 76 + 77 + const parsedRecipe = parseUri(value.subject.uri); 78 + if ( 79 + !parsedRecipe || 80 + parsedRecipe.collection !== KICH_RECIPE_COLLECTION || 81 + !parsedRecipe.repo || 82 + !parsedRecipe.rkey 83 + ) { 84 + continue; 85 + } 86 + 87 + const recipe = await loadRecipe({ 88 + repo: parsedRecipe.repo, 89 + rkey: parsedRecipe.rkey, 90 + cache: recipeCache 91 + }); 92 + 93 + logs.push({ 94 + logUri: record.uri ?? '', 95 + createdAt: value.createdAt, 96 + notes: value.notes, 97 + scaledServings: 98 + value.scaledServings === undefined || value.scaledServings === null 99 + ? undefined 100 + : String(value.scaledServings), 101 + recipeUri: value.subject.uri, 102 + recipeRepo: parsedRecipe.repo, 103 + recipeRkey: parsedRecipe.rkey, 104 + recipe 105 + }); 106 + } 107 + 108 + return logs.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 109 + }, 110 + canAdd: ({ collections }) => collections.includes(KICH_COOKING_LOG_COLLECTION), 111 + name: 'Kich Cooking Log', 112 + canHaveLabel: true, 113 + keywords: ['kich', 'cooking', 'log', 'recipes', 'history'], 114 + groups: ['Social'], 115 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6h16.5M3.75 12h16.5M3.75 18h16.5" /></svg>` 116 + } as CardDefinition & { type: 'kichCookingLog' }; 117 + 118 + async function loadRecipe({ 119 + repo, 120 + rkey, 121 + cache 122 + }: { 123 + repo: string; 124 + rkey: string; 125 + cache: Map<string, KichRecipeRecord>; 126 + }): Promise<KichRecipeRecord | undefined> { 127 + const key = `${repo}:${rkey}`; 128 + if (cache.has(key)) return cache.get(key); 129 + 130 + const resolvedDid = repo.startsWith('did:') 131 + ? repo 132 + : await resolveHandle({ handle: repo as Handle }).catch(() => undefined); 133 + if (!resolvedDid) return undefined; 134 + 135 + const recipeRecord = await getRecord({ 136 + did: resolvedDid as Did, 137 + collection: KICH_RECIPE_COLLECTION, 138 + rkey 139 + }).catch(() => undefined); 140 + 141 + const recipe = recipeRecord?.value as KichRecipeRecord | undefined; 142 + if (recipe) cache.set(key, recipe); 143 + return recipe; 144 + }
+113
src/lib/cards/social/KichRecipeCard/CreateKichRecipeCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Subheading } from '@foxui/core'; 3 + import type { Did, Handle } from '@atcute/lexicons'; 4 + import { getRecord, parseUri, resolveHandle } from '$lib/atproto'; 5 + import Modal from '$lib/components/modal/Modal.svelte'; 6 + import type { CreationModalComponentProps } from '../../types'; 7 + 8 + const KICH_RECIPE_COLLECTION = 'io.kich.recipe.recipe'; 9 + 10 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 11 + 12 + let isValidating = $state(false); 13 + let errorMessage = $state(''); 14 + let recipeUri = $state(''); 15 + 16 + function parseKichRecipeInput( 17 + input: string 18 + ): 19 + | { type: 'at'; did: string; rkey: string } 20 + | { type: 'url'; handle: string; rkey: string } 21 + | null { 22 + const trimmed = input.trim(); 23 + 24 + // at://did:.../io.kich.recipe.recipe/{rkey} 25 + const parsedAt = parseUri(trimmed); 26 + if (parsedAt?.repo && parsedAt?.rkey && parsedAt.collection === KICH_RECIPE_COLLECTION) { 27 + return { type: 'at', did: parsedAt.repo, rkey: parsedAt.rkey }; 28 + } 29 + 30 + // https://kich.io/profile/{handle}/recipe/{rkey} 31 + const kichMatch = trimmed.match( 32 + /^https?:\/\/(?:www\.)?kich\.io\/profile\/([^/]+)\/recipe\/([^/?#]+)\/?$/i 33 + ); 34 + if (kichMatch) { 35 + return { type: 'url', handle: decodeURIComponent(kichMatch[1]), rkey: kichMatch[2] }; 36 + } 37 + 38 + return null; 39 + } 40 + 41 + async function validateAndCreate() { 42 + errorMessage = ''; 43 + isValidating = true; 44 + 45 + try { 46 + const parsed = parseKichRecipeInput(recipeUri); 47 + if (!parsed) { 48 + throw new Error('Invalid recipe input'); 49 + } 50 + 51 + const did = 52 + parsed.type === 'at' 53 + ? parsed.did 54 + : await resolveHandle({ handle: parsed.handle as Handle }).catch(() => undefined); 55 + 56 + if (!did) { 57 + throw new Error('Could not resolve handle'); 58 + } 59 + 60 + const record = await getRecord({ 61 + did: did as Did, 62 + collection: KICH_RECIPE_COLLECTION, 63 + rkey: parsed.rkey 64 + }); 65 + 66 + if (!record?.value) { 67 + throw new Error('Recipe not found'); 68 + } 69 + 70 + item.cardData.uri = `at://${did}/${KICH_RECIPE_COLLECTION}/${parsed.rkey}`; 71 + item.cardData.kichHandle = parsed.type === 'url' ? parsed.handle : did; 72 + item.cardData.href = `https://kich.io/profile/${item.cardData.kichHandle}/recipe/${parsed.rkey}`; 73 + return true; 74 + } catch { 75 + errorMessage = 76 + 'Enter an AT URI (at://...) or a Kich URL (https://kich.io/profile/{handle}/recipe/...).'; 77 + return false; 78 + } finally { 79 + isValidating = false; 80 + } 81 + } 82 + </script> 83 + 84 + <Modal open={true} closeButton={false}> 85 + <form 86 + onsubmit={async () => { 87 + if (await validateAndCreate()) oncreate(); 88 + }} 89 + class="flex flex-col gap-2" 90 + > 91 + <Subheading>Enter a Kich recipe URL or AT URI</Subheading> 92 + <Input 93 + bind:value={recipeUri} 94 + placeholder="https://kich.io/profile/hipstersmoothie.com/recipe/..." 95 + class="mt-4" 96 + /> 97 + 98 + {#if errorMessage} 99 + <Alert type="error" title="Failed to create recipe card"><span>{errorMessage}</span></Alert> 100 + {/if} 101 + 102 + <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 103 + Paste an AT URI or a <code>kich.io/profile/hipstersmoothie.com/recipe/...</code> URL. 104 + </p> 105 + 106 + <div class="mt-4 flex justify-end gap-2"> 107 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 108 + <Button type="submit" disabled={isValidating || !recipeUri.trim()} 109 + >{isValidating ? 'Creating...' : 'Create'}</Button 110 + > 111 + </div> 112 + </form> 113 + </Modal>
+303
src/lib/cards/social/KichRecipeCard/KichRecipeCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { parseUri } from '$lib/atproto'; 4 + import type { Did } from '@atcute/lexicons'; 5 + import { CardDefinitionsByType } from '../..'; 6 + import { getAdditionalUserData } from '$lib/website/context'; 7 + import type { ContentComponentProps } from '../../types'; 8 + import type { KichBlob, KichRecipeIngredient, KichRecipeRecord } from '.'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + const data = getAdditionalUserData(); 13 + let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); 14 + 15 + let fetchedRecipe = $state<KichRecipeRecord | undefined>(undefined); 16 + let isLoaded = $state(false); 17 + 18 + let recipe = $derived( 19 + fetchedRecipe || 20 + ((data[item.cardType] as Record<string, KichRecipeRecord> | undefined)?.[item.id] as 21 + | KichRecipeRecord 22 + | undefined) 23 + ); 24 + 25 + let title = $derived(recipe?.name || 'Recipe'); 26 + let description = $derived(recipe?.description || ''); 27 + 28 + let ingredientText = $derived.by(() => normalizeIngredients(recipe)); 29 + let ingredientCount = $derived(recipe?.ingredients?.length ?? 0); 30 + 31 + let instructionText = $derived.by(() => normalizeInstructions(recipe)); 32 + 33 + let imageUrl = $derived.by(() => { 34 + if (!parsedUri?.repo || !recipe) return undefined; 35 + 36 + const firstImage = recipe.images?.[0]; 37 + if (firstImage?.ref?.$link) { 38 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${firstImage.ref.$link}@jpeg`; 39 + } 40 + return recipe.imageUrl; 41 + }); 42 + 43 + let prepareUrl = $derived.by(() => { 44 + if (item.cardData?.href && typeof item.cardData.href === 'string') { 45 + return item.cardData.href as string; 46 + } 47 + if (!parsedUri?.rkey) return 'https://kich.io'; 48 + const handle = (item.cardData?.kichHandle as string | undefined) || parsedUri.repo; 49 + return `https://kich.io/profile/${handle}/recipe/${parsedUri.rkey}`; 50 + }); 51 + 52 + let metaItems = $derived.by(() => { 53 + const items: string[] = []; 54 + if (recipe?.servings !== undefined) { 55 + items.push(`${recipe.servings} serving${recipe.servings === 1 ? '' : 's'}`); 56 + } 57 + if (recipe?.cookTimeMinutes !== undefined) { 58 + items.push(`${recipe.cookTimeMinutes} min cook`); 59 + } 60 + if (ingredientCount > 0) { 61 + items.push(`${ingredientCount} ingredient${ingredientCount === 1 ? '' : 's'}`); 62 + } 63 + return items; 64 + }); 65 + 66 + onMount(async () => { 67 + if (!recipe && item.cardData?.uri && parsedUri?.repo) { 68 + const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 69 + did: parsedUri.repo as Did, 70 + handle: '' 71 + })) as Record<string, KichRecipeRecord> | undefined; 72 + 73 + if (loadedData?.[item.id]) { 74 + fetchedRecipe = loadedData[item.id]; 75 + if (!data[item.cardType]) { 76 + data[item.cardType] = {}; 77 + } 78 + (data[item.cardType] as Record<string, KichRecipeRecord>)[item.id] = fetchedRecipe; 79 + } 80 + } 81 + isLoaded = true; 82 + }); 83 + 84 + function normalizeIngredients(recipeData?: KichRecipeRecord): string[] { 85 + if (!recipeData?.ingredients?.length) return []; 86 + return recipeData.ingredients.map(formatIngredient).filter((value) => value.length > 0); 87 + } 88 + 89 + function formatIngredient(ingredient: KichRecipeIngredient): string { 90 + const amount = ingredient.heuristicAmount ?? ingredient.measuredAmount ?? ingredient.grams; 91 + const unit = 92 + ingredient.heuristicUnit ?? 93 + ingredient.measuredUnit ?? 94 + (ingredient.grams !== undefined ? 'g' : undefined); 95 + 96 + const quantity = amount !== undefined ? `${amount}` : ''; 97 + const quantityWithUnit = `${quantity}${unit ? ` ${unit}` : ''}`.trim(); 98 + const base = quantityWithUnit ? `${quantityWithUnit} ${ingredient.name}` : ingredient.name; 99 + return ingredient.notes ? `${base} (${ingredient.notes})` : base; 100 + } 101 + 102 + function normalizeInstructions(recipeData?: KichRecipeRecord): string[] { 103 + if (!recipeData?.instructions?.length) return []; 104 + return recipeData.instructions 105 + .map((step) => step.value?.trim() ?? '') 106 + .filter((value) => value.length > 0); 107 + } 108 + </script> 109 + 110 + <svelte:head> 111 + <link 112 + rel="stylesheet" 113 + href="https://fonts.googleapis.com/css2?family=Nunito:wght@900&display=swap" 114 + /> 115 + </svelte:head> 116 + 117 + <div class="flex h-full flex-col overflow-hidden" class:has-image={Boolean(imageUrl)}> 118 + {#if recipe} 119 + {#if imageUrl} 120 + <div class="recipe-image-wrap relative"> 121 + <img 122 + src={imageUrl} 123 + alt={title} 124 + class="recipe-image rounded-t-xl aspect-16/9 w-full object-cover" 125 + /> 126 + <div class="image-overlay pointer-events-none absolute inset-0"></div> 127 + <div class="compact-overlay pointer-events-none absolute right-0 bottom-0 left-0 p-4"> 128 + {#if metaItems.length > 0} 129 + <div class="mb-1 flex flex-wrap gap-2 text-xs"> 130 + {#each metaItems as meta, index (`compact-meta-${index}`)} 131 + <span class="rounded-full bg-black/30 px-2 py-1 text-white/95">{meta}</span> 132 + {/each} 133 + </div> 134 + {/if} 135 + <h3 class="line-clamp-2 text-xl font-semibold text-white">{title}</h3> 136 + {#if description} 137 + <p class="mt-1 line-clamp-2 text-sm text-white/90">{description}</p> 138 + {/if} 139 + </div> 140 + <a 141 + href={prepareUrl} 142 + target="_blank" 143 + rel="noopener noreferrer" 144 + class="recipe-image-hit-area absolute inset-0 z-20" 145 + aria-label="Open recipe on Kich" 146 + ></a> 147 + </div> 148 + {/if} 149 + 150 + <div class="recipe-details flex min-h-0 flex-1 flex-col gap-2 pt-5"> 151 + <div class="flex flex-col gap-2"> 152 + <div class="flex items-start justify-between px-5"> 153 + <h3 154 + class="kich-wordmark text-base-900 dark:text-base-50 line-clamp-2 text-2xl font-semibold" 155 + > 156 + {title} 157 + </h3> 158 + <a 159 + href={prepareUrl} 160 + target="_blank" 161 + rel="noopener noreferrer" 162 + class="kich-wordmark kich-prepare-btn bg-accent-500 hover:bg-accent-600 text-base-50 rounded-xl px-2 py-1 font-medium" 163 + > 164 + Prepare 165 + </a> 166 + </div> 167 + 168 + {#if metaItems.length > 0} 169 + <div class="text-base-600 dark:text-base-300 flex flex-wrap gap-2 px-5 text-xs"> 170 + {#each metaItems as meta, index (`meta-${index}`)} 171 + <span 172 + class="bg-base-200/70 dark:bg-base-800/70 accent:bg-base-50/20 rounded-full px-2 py-1" 173 + > 174 + {meta} 175 + </span> 176 + {/each} 177 + </div> 178 + {/if} 179 + </div> 180 + 181 + <div class="content-fade-wrap min-h-0 flex-1"> 182 + <div class="h-full min-h-0 overflow-y-auto px-5 pb-6"> 183 + {#if description} 184 + <p class="text-base-500 dark:text-base-300 mb-3 line-clamp-3"> 185 + {description} 186 + </p> 187 + {/if} 188 + 189 + {#if ingredientText.length > 0} 190 + <div class="mb-3"> 191 + <p 192 + class="text-base-700 dark:text-base-200 mb-1 text-xs font-semibold tracking-wide uppercase" 193 + > 194 + Ingredients 195 + </p> 196 + <ul class="text-base-600 dark:text-base-300 list-disc space-y-1 pl-4 text-sm"> 197 + {#each ingredientText.slice(0, 6) as ingredient, index (`ingredient-${index}`)} 198 + <li><span class="line-clamp-1">{ingredient}</span></li> 199 + {/each} 200 + </ul> 201 + </div> 202 + {/if} 203 + 204 + {#if instructionText.length > 0} 205 + <div> 206 + <p 207 + class="text-base-700 dark:text-base-200 mb-1 text-xs font-semibold tracking-wide uppercase" 208 + > 209 + Steps 210 + </p> 211 + <ol class="text-base-600 dark:text-base-300 list-decimal space-y-1 pl-4 text-sm"> 212 + {#each instructionText.slice(0, 3) as step, index (`step-${index}`)} 213 + <li><span class="line-clamp-2">{step}</span></li> 214 + {/each} 215 + </ol> 216 + </div> 217 + {/if} 218 + </div> 219 + <div 220 + class="to-base-100 dark:to-base-900 accent:to-accent-500 pointer-events-none absolute right-0 bottom-0 left-0 h-8 bg-gradient-to-b from-transparent" 221 + ></div> 222 + </div> 223 + </div> 224 + {:else if isLoaded} 225 + <div class="flex h-full items-center justify-center"> 226 + <p class="text-base-500 dark:text-base-400 text-center text-sm">Recipe not found</p> 227 + </div> 228 + {:else} 229 + <div class="flex h-full items-center justify-center"> 230 + <p class="text-base-500 dark:text-base-400 text-center text-sm">Loading recipe...</p> 231 + </div> 232 + {/if} 233 + </div> 234 + 235 + <style> 236 + .kich-wordmark { 237 + font-family: 'Nunito', sans-serif; 238 + font-weight: 900; 239 + } 240 + 241 + .content-fade-wrap { 242 + position: relative; 243 + } 244 + 245 + .image-overlay { 246 + display: none; 247 + background: linear-gradient( 248 + to top, 249 + rgba(0, 0, 0, 0.9), 250 + rgba(0, 0, 0, 0.16) 40%, 251 + rgba(0, 0, 0, 0) 252 + ); 253 + } 254 + 255 + .compact-overlay { 256 + display: none; 257 + } 258 + 259 + .recipe-image-hit-area { 260 + display: none; 261 + } 262 + 263 + @container card (aspect-ratio > 1/1) { 264 + .has-image .recipe-image-wrap { 265 + min-height: 0; 266 + flex: 1; 267 + overflow: hidden; 268 + } 269 + 270 + .has-image .recipe-image { 271 + height: 100%; 272 + aspect-ratio: auto; 273 + transform-origin: center center; 274 + transition: transform 250ms ease-in-out; 275 + } 276 + 277 + @media (hover: hover) { 278 + .has-image .recipe-image-wrap:hover .recipe-image { 279 + transform: scale(1.03); 280 + } 281 + } 282 + 283 + .has-image .recipe-details { 284 + display: none; 285 + } 286 + 287 + .has-image .image-overlay { 288 + display: block; 289 + } 290 + 291 + .has-image .compact-overlay { 292 + display: block; 293 + } 294 + 295 + .has-image .recipe-image-hit-area { 296 + display: block; 297 + } 298 + 299 + .has-image .kich-prepare-btn { 300 + display: none; 301 + } 302 + } 303 + </style>
+140
src/lib/cards/social/KichRecipeCard/index.ts
··· 1 + import { getRecord, parseUri, resolveHandle } from '$lib/atproto'; 2 + import type { Did, Handle } from '@atcute/lexicons'; 3 + import type { CardDefinition } from '../../types'; 4 + import CreateKichRecipeCardModal from './CreateKichRecipeCardModal.svelte'; 5 + import KichRecipeCard from './KichRecipeCard.svelte'; 6 + 7 + const KICH_RECIPE_COLLECTION = 'io.kich.recipe.recipe'; 8 + 9 + export type KichBlob = { 10 + $type: 'blob'; 11 + ref: { 12 + $link: string; 13 + }; 14 + mimeType?: string; 15 + size?: number; 16 + }; 17 + 18 + export type KichRecipeInstructionStep = { 19 + id: string; 20 + value: string; 21 + }; 22 + 23 + export type KichRecipeIngredient = { 24 + id: string; 25 + name: string; 26 + grams?: number; 27 + measuredAmount?: number; 28 + measuredUnit?: string; 29 + heuristicAmount?: number; 30 + heuristicUnit?: string; 31 + notes?: string; 32 + group?: string; 33 + isDetached?: boolean; 34 + isOptional?: boolean; 35 + }; 36 + 37 + export type KichRecipeTag = { 38 + id: string; 39 + name: string; 40 + }; 41 + 42 + export type KichRecipeRecord = { 43 + name?: string; 44 + description?: string; 45 + servings?: number; 46 + prepTimeMinutes?: number; 47 + cookTimeMinutes?: number; 48 + instructions?: KichRecipeInstructionStep[]; 49 + ingredients?: KichRecipeIngredient[]; 50 + imageUrl?: string; 51 + images?: KichBlob[]; 52 + source?: string; 53 + url?: string; 54 + isPrivate?: boolean; 55 + createdAt?: string; 56 + updatedAt?: string; 57 + tags?: KichRecipeTag[]; 58 + }; 59 + 60 + export const KichRecipeCardDefinition = { 61 + type: 'kichRecipe', 62 + contentComponent: KichRecipeCard, 63 + creationModalComponent: CreateKichRecipeCardModal, 64 + createNew: (card) => { 65 + card.cardType = 'kichRecipe'; 66 + card.w = 4; 67 + card.h = 5; 68 + card.mobileW = 8; 69 + card.mobileH = 6; 70 + }, 71 + loadData: async (items) => { 72 + const recipesById: Record<string, KichRecipeRecord> = {}; 73 + 74 + for (const item of items) { 75 + const uri = item.cardData?.uri; 76 + if (!uri || typeof uri !== 'string') continue; 77 + 78 + const parsed = parseUri(uri); 79 + if (!parsed || parsed.collection !== KICH_RECIPE_COLLECTION || !parsed.repo || !parsed.rkey) 80 + continue; 81 + 82 + try { 83 + const did = parsed.repo.startsWith('did:') 84 + ? parsed.repo 85 + : await resolveHandle({ handle: parsed.repo as Handle }).catch(() => undefined); 86 + if (!did) continue; 87 + 88 + const record = await getRecord({ 89 + did: did as Did, 90 + collection: KICH_RECIPE_COLLECTION, 91 + rkey: parsed.rkey 92 + }); 93 + 94 + if (record?.value) { 95 + recipesById[item.id] = record.value as KichRecipeRecord; 96 + } 97 + } catch { 98 + // Ignore individual recipe fetch failures to avoid blocking other cards. 99 + } 100 + } 101 + 102 + return recipesById; 103 + }, 104 + onUrlHandler: (url, item) => { 105 + const atUriMatch = url.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/?#]+)/); 106 + const kichUrlMatch = url.match( 107 + /^https?:\/\/(?:www\.)?kich\.io\/profile\/([^/]+)\/recipe\/([^/?#]+)\/?$/i 108 + ); 109 + 110 + let authority: string; 111 + let rkey: string; 112 + if (atUriMatch) { 113 + const [, did, collection, matchedRkey] = atUriMatch; 114 + if (collection !== KICH_RECIPE_COLLECTION) return null; 115 + authority = did; 116 + rkey = matchedRkey; 117 + } else if (kichUrlMatch) { 118 + authority = decodeURIComponent(kichUrlMatch[1]); 119 + rkey = kichUrlMatch[2]; 120 + } else { 121 + return null; 122 + } 123 + 124 + item.w = 4; 125 + item.h = 5; 126 + item.mobileW = 8; 127 + item.mobileH = 6; 128 + item.cardType = 'kichRecipe'; 129 + item.cardData.uri = `at://${authority}/${KICH_RECIPE_COLLECTION}/${rkey}`; 130 + item.cardData.kichHandle = authority; 131 + item.cardData.href = `https://kich.io/profile/${authority}/recipe/${rkey}`; 132 + return item; 133 + }, 134 + urlHandlerPriority: 5, 135 + name: 'Kich Recipe', 136 + canHaveLabel: true, 137 + keywords: ['kich', 'recipe', 'food', 'cooking'], 138 + groups: ['Social'], 139 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a5.25 5.25 0 0 0-5.25 5.25v.75a5.25 5.25 0 0 0 10.5 0V12A5.25 5.25 0 0 0 12 6.75Zm0 0V3m0 3.75c.966 0 1.75-.784 1.75-1.75S12.966 3.25 12 3.25s-1.75.784-1.75 1.75.784 1.75 1.75 1.75ZM4.5 12h2.25m10.5 0h2.25M6.697 17.303l1.591-1.591m7.424 0 1.591 1.591M8.288 8.288 6.697 6.697m10.606 0-1.591 1.591" /></svg>` 140 + } as CardDefinition & { type: 'kichRecipe' };
+118
src/lib/cards/social/KichRecipeCollectionCard/CreateKichRecipeCollectionCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Subheading } from '@foxui/core'; 3 + import type { Did, Handle } from '@atcute/lexicons'; 4 + import { getRecord, parseUri, resolveHandle } from '$lib/atproto'; 5 + import Modal from '$lib/components/modal/Modal.svelte'; 6 + import type { CreationModalComponentProps } from '../../types'; 7 + 8 + const KICH_RECIPE_COLLECTION_COLLECTION = 'io.kich.recipe.collection'; 9 + 10 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 11 + 12 + let isValidating = $state(false); 13 + let errorMessage = $state(''); 14 + let collectionUri = $state(''); 15 + 16 + function parseKichCollectionInput( 17 + input: string 18 + ): 19 + | { type: 'at'; did: string; rkey: string } 20 + | { type: 'url'; handle: string; rkey: string } 21 + | null { 22 + const trimmed = input.trim(); 23 + 24 + // at://did:.../io.kich.recipe.collection/{rkey} 25 + const parsedAt = parseUri(trimmed); 26 + if ( 27 + parsedAt?.repo && 28 + parsedAt?.rkey && 29 + parsedAt.collection === KICH_RECIPE_COLLECTION_COLLECTION 30 + ) { 31 + return { type: 'at', did: parsedAt.repo, rkey: parsedAt.rkey }; 32 + } 33 + 34 + // https://kich.io/profile/{handle}/collection/{rkey} 35 + const kichMatch = trimmed.match( 36 + /^https?:\/\/(?:www\.)?kich\.io\/profile\/([^/]+)\/collection\/([^/?#]+)\/?$/i 37 + ); 38 + if (kichMatch) { 39 + return { type: 'url', handle: decodeURIComponent(kichMatch[1]), rkey: kichMatch[2] }; 40 + } 41 + 42 + return null; 43 + } 44 + 45 + async function validateAndCreate() { 46 + errorMessage = ''; 47 + isValidating = true; 48 + 49 + try { 50 + const parsed = parseKichCollectionInput(collectionUri); 51 + if (!parsed) { 52 + throw new Error('Invalid collection input'); 53 + } 54 + 55 + const did = 56 + parsed.type === 'at' 57 + ? parsed.did 58 + : await resolveHandle({ handle: parsed.handle as Handle }).catch(() => undefined); 59 + if (!did) { 60 + throw new Error('Could not resolve handle'); 61 + } 62 + 63 + const record = await getRecord({ 64 + did: did as Did, 65 + collection: KICH_RECIPE_COLLECTION_COLLECTION, 66 + rkey: parsed.rkey 67 + }); 68 + 69 + if (!record?.value) { 70 + throw new Error('Collection not found'); 71 + } 72 + 73 + item.cardData.uri = `at://${did}/${KICH_RECIPE_COLLECTION_COLLECTION}/${parsed.rkey}`; 74 + item.cardData.kichHandle = parsed.type === 'url' ? parsed.handle : did; 75 + item.cardData.href = `https://kich.io/profile/${item.cardData.kichHandle}/collection/${parsed.rkey}`; 76 + return true; 77 + } catch { 78 + errorMessage = 79 + 'Enter an AT URI (at://...) or a Kich URL (https://kich.io/profile/{handle}/collection/...).'; 80 + return false; 81 + } finally { 82 + isValidating = false; 83 + } 84 + } 85 + </script> 86 + 87 + <Modal open={true} closeButton={false}> 88 + <form 89 + onsubmit={async () => { 90 + if (await validateAndCreate()) oncreate(); 91 + }} 92 + class="flex flex-col gap-2" 93 + > 94 + <Subheading>Enter a Kich collection URL or AT URI</Subheading> 95 + <Input 96 + bind:value={collectionUri} 97 + placeholder="https://kich.io/profile/hipstersmoothie.com/collection/..." 98 + class="mt-4" 99 + /> 100 + 101 + {#if errorMessage} 102 + <Alert type="error" title="Failed to create collection card" 103 + ><span>{errorMessage}</span></Alert 104 + > 105 + {/if} 106 + 107 + <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 108 + Paste an AT URI or a <code>kich.io/profile/hipstersmoothie.com/collection/...</code> URL. 109 + </p> 110 + 111 + <div class="mt-4 flex justify-end gap-2"> 112 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 113 + <Button type="submit" disabled={isValidating || !collectionUri.trim()} 114 + >{isValidating ? 'Creating...' : 'Create'}</Button 115 + > 116 + </div> 117 + </form> 118 + </Modal>
+136
src/lib/cards/social/KichRecipeCollectionCard/KichRecipeCollectionCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { parseUri } from '$lib/atproto'; 4 + import type { Did } from '@atcute/lexicons'; 5 + import { CardDefinitionsByType } from '../..'; 6 + import { getAdditionalUserData } from '$lib/website/context'; 7 + import type { ContentComponentProps } from '../../types'; 8 + import type { KichRecipeCollectionCardData } from '.'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + const data = getAdditionalUserData(); 13 + let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); 14 + 15 + let fetchedCollection = $state<KichRecipeCollectionCardData | undefined>(undefined); 16 + let isLoaded = $state(false); 17 + 18 + let cardData = $derived( 19 + fetchedCollection || 20 + ((data[item.cardType] as Record<string, KichRecipeCollectionCardData> | undefined)?.[ 21 + item.id 22 + ] as KichRecipeCollectionCardData | undefined) 23 + ); 24 + 25 + let collection = $derived(cardData?.collection); 26 + let title = $derived(collection?.name || 'Recipe Collection'); 27 + let description = $derived(collection?.description || ''); 28 + 29 + let collectionUrl = $derived.by(() => { 30 + if (item.cardData?.href && typeof item.cardData.href === 'string') { 31 + return item.cardData.href as string; 32 + } 33 + if (!parsedUri?.rkey) return 'https://kich.io'; 34 + const handle = (item.cardData?.kichHandle as string | undefined) || parsedUri.repo; 35 + return `https://kich.io/profile/${handle}/collection/${parsedUri.rkey}`; 36 + }); 37 + 38 + let imageUrl = $derived.by(() => { 39 + if (!parsedUri?.repo || !collection?.image?.ref?.$link) return undefined; 40 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${collection.image.ref.$link}@jpeg`; 41 + }); 42 + 43 + let metaItems = $derived.by(() => { 44 + const items: string[] = []; 45 + const recipeCount = cardData?.recipeCount ?? 0; 46 + items.push(`${recipeCount} recipe${recipeCount === 1 ? '' : 's'}`); 47 + return items; 48 + }); 49 + 50 + onMount(async () => { 51 + if (!cardData && item.cardData?.uri && parsedUri?.repo) { 52 + const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 53 + did: parsedUri.repo as Did, 54 + handle: '' 55 + })) as Record<string, KichRecipeCollectionCardData> | undefined; 56 + 57 + if (loadedData?.[item.id]) { 58 + fetchedCollection = loadedData[item.id]; 59 + if (!data[item.cardType]) { 60 + data[item.cardType] = {}; 61 + } 62 + (data[item.cardType] as Record<string, KichRecipeCollectionCardData>)[item.id] = 63 + fetchedCollection; 64 + } 65 + } 66 + isLoaded = true; 67 + }); 68 + </script> 69 + 70 + <svelte:head> 71 + <link 72 + rel="stylesheet" 73 + href="https://fonts.googleapis.com/css2?family=Nunito:wght@900&display=swap" 74 + /> 75 + </svelte:head> 76 + 77 + <div class="flex h-full flex-col overflow-hidden"> 78 + {#if cardData} 79 + <a 80 + href={collectionUrl} 81 + target="_blank" 82 + rel="noopener noreferrer" 83 + aria-label="Open collection on Kich" 84 + class="relative block min-h-0 flex-1" 85 + > 86 + {#if imageUrl} 87 + <img src={imageUrl} alt={title} class="rounded-t-xl h-full w-full object-cover" /> 88 + {:else} 89 + <div 90 + class="rounded-t-xl from-base-300 to-base-200 dark:from-base-800 dark:to-base-900 h-full w-full bg-gradient-to-br" 91 + ></div> 92 + {/if} 93 + 94 + <div class="image-overlay pointer-events-none absolute inset-0"></div> 95 + 96 + <div class="absolute right-0 bottom-0 left-0 p-4"> 97 + {#if metaItems.length > 0} 98 + <div class="mb-1 flex flex-wrap gap-2 text-xs"> 99 + {#each metaItems as meta, index (`meta-${index}`)} 100 + <span class="rounded-full bg-black/30 px-2 py-1 text-white/95">{meta}</span> 101 + {/each} 102 + </div> 103 + {/if} 104 + 105 + <h3 class="kich-wordmark line-clamp-2 text-xl font-semibold text-white">{title}</h3> 106 + {#if description} 107 + <p class="mt-1 line-clamp-2 text-sm text-white/90">{description}</p> 108 + {/if} 109 + </div> 110 + </a> 111 + {:else if isLoaded} 112 + <div class="flex h-full items-center justify-center"> 113 + <p class="text-base-500 dark:text-base-400 text-center text-sm">Collection not found</p> 114 + </div> 115 + {:else} 116 + <div class="flex h-full items-center justify-center"> 117 + <p class="text-base-500 dark:text-base-400 text-center text-sm">Loading collection...</p> 118 + </div> 119 + {/if} 120 + </div> 121 + 122 + <style> 123 + .kich-wordmark { 124 + font-family: 'Nunito', sans-serif; 125 + font-weight: 900; 126 + } 127 + 128 + .image-overlay { 129 + background: linear-gradient( 130 + to top, 131 + rgba(0, 0, 0, 0.9), 132 + rgba(0, 0, 0, 0.16) 40%, 133 + rgba(0, 0, 0, 0) 134 + ); 135 + } 136 + </style>
+224
src/lib/cards/social/KichRecipeCollectionCard/index.ts
··· 1 + import { getRecord, listRecords, parseUri, resolveHandle } from '$lib/atproto'; 2 + import type { Did, Handle } from '@atcute/lexicons'; 3 + import type { CardDefinition } from '../../types'; 4 + import CreateKichRecipeCollectionCardModal from './CreateKichRecipeCollectionCardModal.svelte'; 5 + import KichRecipeCollectionCard from './KichRecipeCollectionCard.svelte'; 6 + 7 + const KICH_RECIPE_COLLECTION_COLLECTION = 'io.kich.recipe.collection'; 8 + const KICH_COLLECTION_ITEM_COLLECTIONS = [ 9 + 'io.kich.recipe.collectionitem', 10 + 'io.kich.recipe.collection.recipe', 11 + 'io.kich.recipe.recipecollectionitem' 12 + ] as const; 13 + 14 + export type KichBlob = { 15 + $type: 'blob'; 16 + ref: { 17 + $link: string; 18 + }; 19 + mimeType?: string; 20 + size?: number; 21 + }; 22 + 23 + export type KichRecipeCollectionRecord = { 24 + name?: string; 25 + description?: string; 26 + image?: KichBlob; 27 + createdAt?: string; 28 + updatedAt?: string; 29 + }; 30 + 31 + export type KichRecipeCollectionCardData = { 32 + collection: KichRecipeCollectionRecord; 33 + recipeCount: number; 34 + }; 35 + 36 + export const KichRecipeCollectionCardDefinition = { 37 + type: 'kichRecipeCollection', 38 + contentComponent: KichRecipeCollectionCard, 39 + creationModalComponent: CreateKichRecipeCollectionCardModal, 40 + createNew: (card) => { 41 + card.cardType = 'kichRecipeCollection'; 42 + card.w = 4; 43 + card.h = 3; 44 + card.mobileW = 8; 45 + card.mobileH = 4; 46 + }, 47 + loadData: async (items) => { 48 + const collectionsById: Record<string, KichRecipeCollectionCardData> = {}; 49 + 50 + for (const item of items) { 51 + const uri = item.cardData?.uri; 52 + if (!uri || typeof uri !== 'string') continue; 53 + 54 + const parsed = parseUri(uri); 55 + if ( 56 + !parsed || 57 + parsed.collection !== KICH_RECIPE_COLLECTION_COLLECTION || 58 + !parsed.repo || 59 + !parsed.rkey 60 + ) { 61 + continue; 62 + } 63 + 64 + try { 65 + const did = parsed.repo.startsWith('did:') 66 + ? parsed.repo 67 + : await resolveHandle({ handle: parsed.repo as Handle }).catch(() => undefined); 68 + if (!did) continue; 69 + 70 + const record = await getRecord({ 71 + did: did as Did, 72 + collection: KICH_RECIPE_COLLECTION_COLLECTION, 73 + rkey: parsed.rkey 74 + }); 75 + 76 + if (!record?.value) continue; 77 + 78 + const recipeCount = await getCollectionRecipeCount({ 79 + did: did as Did, 80 + collectionRkey: parsed.rkey 81 + }); 82 + 83 + collectionsById[item.id] = { 84 + collection: record.value as KichRecipeCollectionRecord, 85 + recipeCount 86 + }; 87 + } catch { 88 + // Ignore individual collection fetch failures. 89 + } 90 + } 91 + 92 + return collectionsById; 93 + }, 94 + onUrlHandler: (url, item) => { 95 + const atUriMatch = url.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/?#]+)/); 96 + const kichUrlMatch = url.match( 97 + /^https?:\/\/(?:www\.)?kich\.io\/profile\/([^/]+)\/collection\/([^/?#]+)\/?$/i 98 + ); 99 + 100 + let authority: string; 101 + let rkey: string; 102 + if (atUriMatch) { 103 + const [, did, collection, matchedRkey] = atUriMatch; 104 + if (collection !== KICH_RECIPE_COLLECTION_COLLECTION) return null; 105 + authority = did; 106 + rkey = matchedRkey; 107 + } else if (kichUrlMatch) { 108 + authority = decodeURIComponent(kichUrlMatch[1]); 109 + rkey = kichUrlMatch[2]; 110 + } else { 111 + return null; 112 + } 113 + 114 + item.w = 4; 115 + item.h = 3; 116 + item.mobileW = 8; 117 + item.mobileH = 4; 118 + item.cardType = 'kichRecipeCollection'; 119 + item.cardData.uri = `at://${authority}/${KICH_RECIPE_COLLECTION_COLLECTION}/${rkey}`; 120 + item.cardData.kichHandle = authority; 121 + item.cardData.href = `https://kich.io/profile/${authority}/collection/${rkey}`; 122 + return item; 123 + }, 124 + urlHandlerPriority: 5, 125 + name: 'Kich Recipe Collection', 126 + canHaveLabel: true, 127 + keywords: ['kich', 'recipe', 'collection', 'cookbook'], 128 + groups: ['Social'], 129 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75A2.25 2.25 0 0 1 4.5 4.5h15A2.25 2.25 0 0 1 21.75 6.75v10.5A2.25 2.25 0 0 1 19.5 19.5h-15A2.25 2.25 0 0 1 2.25 17.25V6.75Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3h9m-9 3h6" /></svg>` 130 + } as CardDefinition & { type: 'kichRecipeCollection' }; 131 + 132 + async function getCollectionRecipeCount({ 133 + did, 134 + collectionRkey 135 + }: { 136 + did: Did; 137 + collectionRkey: string; 138 + }): Promise<number> { 139 + const collectionUri = `at://${did}/${KICH_RECIPE_COLLECTION_COLLECTION}/${collectionRkey}`; 140 + 141 + for (const collectionName of KICH_COLLECTION_ITEM_COLLECTIONS) { 142 + try { 143 + const records = (await listRecords({ 144 + did, 145 + collection: collectionName as `${string}.${string}.${string}`, 146 + limit: 0 147 + })) as Array<{ value?: unknown }>; 148 + 149 + if (!records?.length) continue; 150 + 151 + const count = records.reduce((acc, record) => { 152 + return ( 153 + acc + (recordBelongsToCollection(record.value, collectionUri, collectionRkey) ? 1 : 0) 154 + ); 155 + }, 0); 156 + 157 + if (count > 0) return count; 158 + } catch { 159 + // Try next candidate collection. 160 + } 161 + } 162 + 163 + return 0; 164 + } 165 + 166 + function recordBelongsToCollection( 167 + value: unknown, 168 + collectionUri: string, 169 + collectionRkey: string 170 + ): boolean { 171 + if (!value || typeof value !== 'object') return false; 172 + const node = value as Record<string, unknown>; 173 + 174 + const directCandidates = [ 175 + node.collection, 176 + node.collectionUri, 177 + node.collectionRkey, 178 + node.collectionRef, 179 + node.parentCollection, 180 + node.list, 181 + node.subject, 182 + node.ref 183 + ]; 184 + 185 + if ( 186 + directCandidates.some((candidate) => 187 + matchesCollectionRef(candidate, collectionUri, collectionRkey) 188 + ) 189 + ) { 190 + return true; 191 + } 192 + 193 + // Last resort: check all top-level values for a matching collection ref. 194 + return Object.values(node).some((candidate) => 195 + matchesCollectionRef(candidate, collectionUri, collectionRkey) 196 + ); 197 + } 198 + 199 + function matchesCollectionRef( 200 + ref: unknown, 201 + collectionUri: string, 202 + collectionRkey: string 203 + ): boolean { 204 + if (!ref) return false; 205 + 206 + if (typeof ref === 'string') { 207 + if (ref === collectionUri || ref === collectionRkey) return true; 208 + const parsed = parseUri(ref); 209 + return ( 210 + parsed?.collection === KICH_RECIPE_COLLECTION_COLLECTION && parsed?.rkey === collectionRkey 211 + ); 212 + } 213 + 214 + if (typeof ref === 'object') { 215 + const node = ref as Record<string, unknown>; 216 + return ( 217 + matchesCollectionRef(node.uri, collectionUri, collectionRkey) || 218 + matchesCollectionRef(node.rkey, collectionUri, collectionRkey) || 219 + matchesCollectionRef(node.$link, collectionUri, collectionRkey) 220 + ); 221 + } 222 + 223 + return false; 224 + }