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