your personal website on atproto - mirror blento.app

build

+1466 -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 || 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>
+119
src/lib/cards/social/KichRecipeCard/KichMascot.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * Fills use `var(--color-accent-*)` from `app.css` (@theme inline). 4 + * Rough mapping: pan/lid main 500↔800, pan inner 400↔900, hat 800, face 500↔700, 5 + * whisk handle 300, whisk wires 400↔700. 6 + */ 7 + let { 8 + class: className = '', 9 + size = 200 10 + }: { 11 + class?: string; 12 + size?: number | string; 13 + } = $props(); 14 + </script> 15 + 16 + <svg 17 + version="1.1" 18 + xmlns="http://www.w3.org/2000/svg" 19 + xmlns:xlink="http://www.w3.org/1999/xlink" 20 + height={size} 21 + width={size} 22 + viewBox="0 0 2000 2000" 23 + class={className} 24 + aria-hidden="true" 25 + > 26 + <defs> 27 + <path 28 + id="SVGID_1_" 29 + d="M1787.7,919.57c-50.73-11.06-57.3,98.11-61.92,119.4c-15.58,71.93-93.74,137.57-126.55,137.57 30 + c-3.44,0-19.91-202.39-34.17-243.95c-5.11-14.91-805.52,100.63-917.74,124.88c-4.49,0.97-7.87,4.62-7.87,9.21l0.04,45.67 31 + c0,0-227.54-143.74-491.22-125.86c-71.83,4.87-78.94,32.62-78.94,69.74c0,72.82,80.53,73.79,85,73.79 32 + c3.3,0.06,194.65,3.02,252.1,10.44c5.38,0.69-12.19,80.56,95.49,174.89c100.67,88.18,180.47,70.41,180.47,70.41 33 + s3.76,196.51,73.77,256.56c0.08,0.07,0.15,0.14,0.22,0.21c42.48,48.98,110.86,70.85,154.37,70.85 34 + c46.05,0,554.58-72.38,574.77-78.81c51.79-16.5,102.53-59.5,130.78-118.21c31-64.43-4.1-259.98-4.1-259.98 35 + s119.54-33.26,174.57-159.6C1806.48,1051.52,1852.7,933.74,1787.7,919.57z M667.68,1306c-15.44,4.03-73.42-9.77-101.49-32.87 36 + c-73-60.07-71.77-116.46-71.77-116.46s120.54,15.61,161.76,77.71C666.51,1249.93,672.93,1304.63,667.68,1306z" 37 + ></path> 38 + <clipPath id="SVGID_2_"> 39 + <use xlink:href="#SVGID_1_" style="overflow: visible"></use> 40 + </clipPath> 41 + </defs> 42 + <g id="Pan"> 43 + <g id="Layer_7"> 44 + <g> 45 + <path 46 + clip-path="url(#SVGID_2_)" 47 + fill="light-dark(var(--color-accent-500), var(--color-accent-700))" 48 + d="M-33.22,908.19c-29.56,13.8,1915.64-65.87,1915.64-65.87s-6.28,846.46-8.25,876.02l-1771.46,98.34 49 + C102.72,1816.67-18.15,901.15-33.22,908.19z" 50 + ></path> 51 + <path 52 + clip-path="url(#SVGID_2_)" 53 + fill="light-dark(var(--color-accent-400), var(--color-accent-600))" 54 + d="M745.58,955.31c0,0,55.68,484.78,79.25,552.86c12.29,35.49,88.96,134.37,199.43,127.73 55 + c130.71-7.86,487.54-73.71,494.31-76.36c74.55-29.29,101.33-101.47,110.49-149.34c4.81-25.15,412.2-594.72,412.2-594.72 56 + s-472.68,182.49-472.68,78.04L745.58,955.31z" 57 + ></path> 58 + </g> 59 + </g> 60 + </g> 61 + <g id="Hat"> 62 + <path 63 + fill="light-dark(var(--color-accent-500), var(--color-accent-600))" 64 + d="M824.37,762.02c-24.77-56.65-38.77-131.39-38.77-131.39S630.07,677.6,644.38,506.95 65 + c7.57-90.3,127.4-88.63,127.4-88.63s13.39-107.72,95.01-128.43c105.53-26.77,164.82,44.9,164.82,44.9s99.81-82.45,167.79,6.53 66 + c82.58,108.08-50.38,178.25-50.38,178.25l38.3,144.24l-34.74,13.24c0,0-38.31-123.27-156.72-97.16 67 + c-36.01,7.94-92.1,47.15-79.84,147.62L824.37,762.02z" 68 + ></path> 69 + </g> 70 + <g id="Lid"> 71 + <path 72 + fill="light-dark(var(--color-accent-500), var(--color-accent-700))" 73 + d="M629.5,1013.47c0,0-3.17-78.23,159.4-177.48c89.55-54.67,192.07-80.36,192.07-80.36 74 + s-73.44-100.53,33.56-127.37c102.47-25.71,88,96.6,88,96.6s178.21-15.1,228.64-7.61c2.89,0.43,5.74,0.9,8.55,1.32 75 + c188.11,27.93,200.21,90.26,200.21,90.26s-310.02,70.36-427.13,97.46C1001.07,932.12,629.5,1013.47,629.5,1013.47z" 76 + ></path> 77 + </g> 78 + <g id="Face"> 79 + <path 80 + fill="light-dark(var(--color-accent-700), var(--color-accent-800))" 81 + d="M1054.91,1348.62c-2.74-7.7,24.57-28.5,29.8-21.03c28.26,40.36,94.1,54.33,127.73,48.39 82 + c31.53-5.57,78.25-27.8,99.46-77.43c3.51-8.21,29.93,4.46,30.71,13.35c4.46,50.9-78.44,97.71-125.49,104.8 83 + C1156.41,1425.85,1070.29,1391.85,1054.91,1348.62z" 84 + ></path> 85 + <path 86 + fill="light-dark(var(--color-accent-700), var(--color-accent-800))" 87 + d="M963.36,1257.03c-5.61,5.61-28.87,5.91-33.4-8.57c-7.36-23.52,13.14-68.73,58.34-77.48 88 + c45.57-8.82,89.24,24.61,90.89,50.84c0.5,7.93-7.87,22.88-24.1,20.71c-17.71-2.36-21.71-45.44-60.03-30.86 89 + C964.05,1223.49,973.43,1246.96,963.36,1257.03z" 90 + ></path> 91 + <path 92 + fill="light-dark(var(--color-accent-700), var(--color-accent-800))" 93 + d="M1432.96,1175.75c-3.34,23.86-21.56,22.48-30.03,18.54c-13.35-6.2-16.76-37.29-47.37-35.87 94 + c-36.94,1.72-28.93,37.69-40.16,45.71c-7.14,5.1-25.09,4.56-33.9-7.78c-9.94-13.91,24.59-77.1,60.14-79.49 95 + C1409.04,1112.32,1434.37,1165.67,1432.96,1175.75z" 96 + ></path> 97 + </g> 98 + <g id="Whisk"> 99 + <path 100 + fill="light-dark(var(--color-accent-500), var(--color-accent-800))" 101 + d="M1734.3,935.14c13.79-8.82,46.53-19.44,63.72-18.42c0.46,0.03,15.94,7.3,17.44,11.46 102 + c7.91,21.98,68.71,284.74,69.2,289.26c1.67,15.33,2.32,48.82-38.35,53.79c-44.73,5.47-52.79-24.14-54.26-30.39 103 + c-1.36-5.82-60.52-242.2-67.21-288.27C1724.13,947.66,1732.81,936.1,1734.3,935.14z" 104 + ></path> 105 + <g> 106 + <g> 107 + <path 108 + fill="light-dark(var(--color-accent-400), var(--color-accent-600))" 109 + d="M1822.76,556.21c-22.53-95.48-122.57-128.57-172.27-114.36c-56.73,16.21-128.14,73.55-101.96,189.73 110 + c23.23,103.05,161.51,245.48,187.37,302.46c3.05,6.72,59.25-10.59,60.32-18.82C1807.03,832.15,1846.87,658.38,1822.76,556.21z 111 + M1575.7,619.43c-18.58-69.18,33.78-117.74,33.78-117.74s5.1,64.78,12.2,103.77c7.92,43.49,73.28,213.63,73.28,213.63 112 + S1590.64,675.05,1575.7,619.43z M1652.42,593.37c-6.27-28.57-21.41-110.86,7.24-116.78c29.27-6.04,46.85,77.63,54.04,106.64 113 + c23.68,95.55,27.02,244.65,27.02,244.65S1668.83,668.14,1652.42,593.37z M1775.88,805.11c0,0-9.58-154.64-27.44-224.28 114 + c-7.1-27.71-39.85-104.54-39.85-104.54s68.22,8.67,86.65,85.72C1814,640.35,1775.88,805.11,1775.88,805.11z" 115 + ></path> 116 + </g> 117 + </g> 118 + </g> 119 + </svg>
+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-top-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>
+137
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 KichMascot from '../KichRecipeCard/KichMascot.svelte'; 9 + import type { KichRecipeCollectionCardData } from '.'; 10 + 11 + let { item }: ContentComponentProps = $props(); 12 + 13 + const data = getAdditionalUserData(); 14 + let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null); 15 + 16 + let fetchedCollection = $state<KichRecipeCollectionCardData | undefined>(undefined); 17 + let isLoaded = $state(false); 18 + 19 + let cardData = $derived( 20 + fetchedCollection || 21 + ((data[item.cardType] as Record<string, KichRecipeCollectionCardData> | undefined)?.[ 22 + item.id 23 + ] as KichRecipeCollectionCardData | undefined) 24 + ); 25 + 26 + let collection = $derived(cardData?.collection); 27 + let title = $derived(collection?.name || 'Recipe Collection'); 28 + let description = $derived(collection?.description || ''); 29 + 30 + let collectionUrl = $derived.by(() => { 31 + if (item.cardData?.href && typeof item.cardData.href === 'string') { 32 + return item.cardData.href as string; 33 + } 34 + if (!parsedUri?.rkey) return 'https://kich.io'; 35 + const handle = (item.cardData?.kichHandle as string | undefined) || parsedUri.repo; 36 + return `https://kich.io/profile/${handle}/collection/${parsedUri.rkey}`; 37 + }); 38 + 39 + let imageUrl = $derived.by(() => { 40 + if (!parsedUri?.repo || !collection?.image?.ref?.$link) return undefined; 41 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${collection.image.ref.$link}@jpeg`; 42 + }); 43 + 44 + let metaItems = $derived.by(() => { 45 + const items: string[] = []; 46 + const recipeCount = cardData?.recipeCount ?? 0; 47 + items.push(`${recipeCount} recipe${recipeCount === 1 ? '' : 's'}`); 48 + return items; 49 + }); 50 + 51 + onMount(async () => { 52 + if (!cardData && item.cardData?.uri && parsedUri?.repo) { 53 + const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 54 + did: parsedUri.repo as Did, 55 + handle: '' 56 + })) as Record<string, KichRecipeCollectionCardData> | undefined; 57 + 58 + if (loadedData?.[item.id]) { 59 + fetchedCollection = loadedData[item.id]; 60 + if (!data[item.cardType]) { 61 + data[item.cardType] = {}; 62 + } 63 + (data[item.cardType] as Record<string, KichRecipeCollectionCardData>)[item.id] = 64 + fetchedCollection; 65 + } 66 + } 67 + isLoaded = true; 68 + }); 69 + </script> 70 + 71 + <svelte:head> 72 + <link 73 + rel="stylesheet" 74 + href="https://fonts.googleapis.com/css2?family=Nunito:wght@900&display=swap" 75 + /> 76 + </svelte:head> 77 + 78 + <div class="flex h-full flex-col overflow-hidden"> 79 + {#if cardData} 80 + <a 81 + href={collectionUrl} 82 + target="_blank" 83 + rel="noopener noreferrer" 84 + aria-label="Open collection on Kich" 85 + class="relative block min-h-0 flex-1" 86 + > 87 + {#if imageUrl} 88 + <img src={imageUrl} alt={title} class="rounded-top-xl h-full w-full object-cover" /> 89 + {:else} 90 + <div 91 + class="rounded-top-xl from-base-300 to-base-200 dark:from-base-800 dark:to-base-900 h-full w-full bg-gradient-to-br" 92 + ></div> 93 + {/if} 94 + 95 + <div class="image-overlay pointer-events-none absolute inset-0"></div> 96 + 97 + <div class="absolute right-0 bottom-0 left-0 p-4"> 98 + {#if metaItems.length > 0} 99 + <div class="mb-1 flex flex-wrap gap-2 text-xs"> 100 + {#each metaItems as meta, index (`meta-${index}`)} 101 + <span class="rounded-full bg-black/30 px-2 py-1 text-white/95">{meta}</span> 102 + {/each} 103 + </div> 104 + {/if} 105 + 106 + <h3 class="line-clamp-2 text-xl font-semibold text-white">{title}</h3> 107 + {#if description} 108 + <p class="mt-1 line-clamp-2 text-sm text-white/90">{description}</p> 109 + {/if} 110 + </div> 111 + </a> 112 + {:else if isLoaded} 113 + <div class="flex h-full items-center justify-center"> 114 + <p class="text-base-500 dark:text-base-400 text-center text-sm">Collection not found</p> 115 + </div> 116 + {:else} 117 + <div class="flex h-full items-center justify-center"> 118 + <p class="text-base-500 dark:text-base-400 text-center text-sm">Loading collection...</p> 119 + </div> 120 + {/if} 121 + </div> 122 + 123 + <style> 124 + .kich-wordmark { 125 + font-family: 'Nunito', sans-serif; 126 + font-weight: 900; 127 + } 128 + 129 + .image-overlay { 130 + background: linear-gradient( 131 + to top, 132 + rgba(0, 0, 0, 0.9), 133 + rgba(0, 0, 0, 0.16) 40%, 134 + rgba(0, 0, 0, 0) 135 + ); 136 + } 137 + </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 + }