tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
commandbar changes
unbedenklich
1 week ago
9d4c1246
3cdc9f4b
+1210
-266
49 changed files
expand all
collapse all
unified
split
src
app.css
lib
cards
ATProtoCollectionsCard
ATProtoCollectionsCard.svelte
index.ts
BigSocialCard
index.ts
BlueskyMediaCard
index.ts
BlueskyPostCard
index.ts
ButtonCard
index.ts
ClockCard
ClockCard.svelte
ClockCardSettings.svelte
index.ts
CountdownCard
CountdownCard.svelte
CountdownCardSettings.svelte
index.ts
DrawCard
index.ts
EmbedCard
index.ts
EventCard
index.ts
FluidTextCard
index.ts
GIFCard
index.ts
GameCards
DinoGameCard
index.ts
TetrisCard
index.ts
GitHubProfileCard
CreateGitHubProfileCardModal.svelte
index.ts
GuestbookCard
index.ts
ImageCard
index.ts
LatestBlueskyPostCard
index.ts
LinkCard
CreateLinkCardModal.svelte
index.ts
LivestreamCard
index.ts
MapCard
index.ts
PopfeedReviews
PopfeedReviewsCard.svelte
index.ts
SectionCard
index.ts
SpotifyCard
index.ts
StandardSiteDocumentListCard
StandardSiteDocumentListCard.svelte
index.ts
StatusphereCard
index.ts
TealFMPlaysCard
TealFMPlaysCard.svelte
index.ts
TextCard
index.ts
TimerCard
index.ts
VCardCard
index.ts
VideoCard
index.ts
YoutubeVideoCard
CreateYoutubeCardModal.svelte
index.ts
index.ts
types.ts
components
card-command
CardCommand.svelte
website
EditBar.svelte
EditableWebsite.svelte
+2
src/app.css
···
3
@plugin '@tailwindcss/forms';
4
@plugin '@tailwindcss/typography';
5
0
0
6
@source '../node_modules/@foxui';
7
8
@custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
···
3
@plugin '@tailwindcss/forms';
4
@plugin '@tailwindcss/typography';
5
6
+
@plugin 'tailwindcss-animate';
7
+
8
@source '../node_modules/@foxui';
9
10
@custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
+19
-5
src/lib/cards/ATProtoCollectionsCard/ATProtoCollectionsCard.svelte
···
39
<Badge size="md" class="accent:text-accent-950">{collections.length}</Badge>
40
{/if}
41
</div>
42
-
<div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4">
43
-
{#each collections ?? [] as collection (collection)}
44
-
<Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button>
45
-
{/each}
46
-
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
47
</div>
···
39
<Badge size="md" class="accent:text-accent-950">{collections.length}</Badge>
40
{/if}
41
</div>
42
+
{#if collections && collections.length > 0}
43
+
<div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4">
44
+
{#each collections as collection (collection)}
45
+
<Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button>
46
+
{/each}
47
+
</div>
48
+
{:else if collections}
49
+
<div
50
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
51
+
>
52
+
No collections found.
53
+
</div>
54
+
{:else}
55
+
<div
56
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
57
+
>
58
+
Loading collections...
59
+
</div>
60
+
{/if}
61
</div>
+6
-1
src/lib/cards/ATProtoCollectionsCard/index.ts
···
19
item.w = 4;
20
item.mobileW = 8;
21
},
22
-
sidebarButtonText: 'Atmosphere Collections'
0
0
0
0
0
23
} as CardDefinition & { type: 'atprotocollections' };
···
19
item.w = 4;
20
item.mobileW = 8;
21
},
22
+
sidebarButtonText: 'Atmosphere Collections',
23
+
24
+
name: 'ATProto Collections',
25
+
26
+
groups: ['Social'],
27
+
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="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>`
28
} as CardDefinition & { type: 'atprotocollections' };
+4
-1
src/lib/cards/BigSocialCard/index.ts
···
51
return item;
52
},
53
urlHandlerPriority: 1,
54
-
canHaveLabel: true
0
0
0
55
} as CardDefinition & { type: 'bigsocial' };
56
57
import {
···
51
return item;
52
},
53
urlHandlerPriority: 1,
54
+
canHaveLabel: true,
55
+
56
+
groups: ['Social'],
57
+
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="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z" /></svg>`
58
} as CardDefinition & { type: 'bigsocial' };
59
60
import {
+6
-1
src/lib/cards/BlueskyMediaCard/index.ts
···
8
createNew: () => {},
9
creationModalComponent: CreateBlueskyMediaCardModal,
10
sidebarButtonText: 'Bluesky Media',
11
-
canHaveLabel: true
0
0
0
0
0
12
} as CardDefinition & { type: 'blueskyMedia' };
···
8
createNew: () => {},
9
creationModalComponent: CreateBlueskyMediaCardModal,
10
sidebarButtonText: 'Bluesky Media',
11
+
canHaveLabel: true,
12
+
13
+
groups: ['Media'],
14
+
15
+
name: 'Video/Image from Bluesky',
16
+
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.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0 1 18 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0 1 18 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 0 1 6 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" /></svg>`
17
} as CardDefinition & { type: 'blueskyMedia' };
+4
-1
src/lib/cards/BlueskyPostCard/index.ts
···
63
return postsMap;
64
},
65
minW: 4,
66
-
name: 'Bluesky Post'
0
0
0
67
} as CardDefinition & { type: 'blueskyPost' };
···
63
return postsMap;
64
},
65
minW: 4,
66
+
name: 'Bluesky Post',
67
+
68
+
groups: ['Social'],
69
+
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="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>`
70
} as CardDefinition & { type: 'blueskyPost' };
+5
-1
src/lib/cards/ButtonCard/index.ts
···
27
minW: 2,
28
minH: 1,
29
maxW: 8,
30
-
maxH: 4
0
0
0
0
31
};
···
27
minW: 2,
28
minH: 1,
29
maxW: 8,
30
+
maxH: 4,
31
+
32
+
groups: ['Utilities'],
33
+
name: 'Button',
34
+
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="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>`
35
};
+87
src/lib/cards/ClockCard/ClockCard.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte';
3
+
import type { ContentComponentProps } from '../types';
4
+
import type { ClockCardData } from './index';
5
+
import { onMount } from 'svelte';
6
+
7
+
let { item }: ContentComponentProps = $props();
8
+
9
+
let cardData = $derived(item.cardData as ClockCardData);
10
+
11
+
let now = $state(new Date());
12
+
13
+
onMount(() => {
14
+
const interval = setInterval(() => {
15
+
now = new Date();
16
+
}, 1000);
17
+
return () => clearInterval(interval);
18
+
});
19
+
20
+
let clockParts = $derived.by(() => {
21
+
try {
22
+
return new Intl.DateTimeFormat('en-US', {
23
+
timeZone: cardData.timezone || 'UTC',
24
+
hour: '2-digit',
25
+
minute: '2-digit',
26
+
second: '2-digit',
27
+
hour12: false
28
+
}).formatToParts(now);
29
+
} catch {
30
+
return null;
31
+
}
32
+
});
33
+
34
+
let clockHours = $derived(
35
+
clockParts ? parseInt(clockParts.find((p) => p.type === 'hour')?.value || '0') : 0
36
+
);
37
+
let clockMinutes = $derived(
38
+
clockParts ? parseInt(clockParts.find((p) => p.type === 'minute')?.value || '0') : 0
39
+
);
40
+
let clockSeconds = $derived(
41
+
clockParts ? parseInt(clockParts.find((p) => p.type === 'second')?.value || '0') : 0
42
+
);
43
+
44
+
let timezoneDisplay = $derived.by(() => {
45
+
if (!cardData.timezone) return '';
46
+
try {
47
+
const formatter = new Intl.DateTimeFormat('en-US', {
48
+
timeZone: cardData.timezone,
49
+
timeZoneName: 'short'
50
+
});
51
+
const parts = formatter.formatToParts(now);
52
+
return parts.find((p) => p.type === 'timeZoneName')?.value || cardData.timezone;
53
+
} catch {
54
+
return cardData.timezone;
55
+
}
56
+
});
57
+
</script>
58
+
59
+
<div class="@container flex h-full w-full flex-col items-center justify-center p-4">
60
+
<NumberFlowGroup>
61
+
<div
62
+
class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-center text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
63
+
style="font-variant-numeric: tabular-nums;"
64
+
>
65
+
<NumberFlow value={clockHours} format={{ minimumIntegerDigits: 2 }} />
66
+
<span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span>
67
+
<NumberFlow
68
+
value={clockMinutes}
69
+
format={{ minimumIntegerDigits: 2 }}
70
+
digits={{ 1: { max: 5 } }}
71
+
trend={1}
72
+
/>
73
+
<span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span>
74
+
<NumberFlow
75
+
value={clockSeconds}
76
+
format={{ minimumIntegerDigits: 2 }}
77
+
digits={{ 1: { max: 5 } }}
78
+
trend={1}
79
+
/>
80
+
</div>
81
+
</NumberFlowGroup>
82
+
{#if timezoneDisplay}
83
+
<div class="text-base-500 dark:text-base-400 accent:text-base-600 mt-1 text-xs @sm:text-sm">
84
+
{timezoneDisplay}
85
+
</div>
86
+
{/if}
87
+
</div>
+74
src/lib/cards/ClockCard/ClockCardSettings.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import type { Item } from '$lib/types';
3
+
import { Button, Label } from '@foxui/core';
4
+
import type { ClockCardData } from './index';
5
+
import { onMount } from 'svelte';
6
+
7
+
let { item }: { item: Item; onclose: () => void } = $props();
8
+
9
+
let cardData = $derived(item.cardData as ClockCardData);
10
+
11
+
const timezoneOptions = [
12
+
{ value: 'Pacific/Midway', label: 'UTC-11 (Midway)' },
13
+
{ value: 'Pacific/Honolulu', label: 'UTC-10 (Honolulu)' },
14
+
{ value: 'America/Anchorage', label: 'UTC-9 (Anchorage)' },
15
+
{ value: 'America/Los_Angeles', label: 'UTC-8 (Los Angeles)' },
16
+
{ value: 'America/Denver', label: 'UTC-7 (Denver)' },
17
+
{ value: 'America/Chicago', label: 'UTC-6 (Chicago)' },
18
+
{ value: 'America/New_York', label: 'UTC-5 (New York)' },
19
+
{ value: 'America/Halifax', label: 'UTC-4 (Halifax)' },
20
+
{ value: 'America/Sao_Paulo', label: 'UTC-3 (São Paulo)' },
21
+
{ value: 'Atlantic/South_Georgia', label: 'UTC-2 (South Georgia)' },
22
+
{ value: 'Atlantic/Azores', label: 'UTC-1 (Azores)' },
23
+
{ value: 'UTC', label: 'UTC+0 (London)' },
24
+
{ value: 'Europe/Paris', label: 'UTC+1 (Paris)' },
25
+
{ value: 'Europe/Helsinki', label: 'UTC+2 (Helsinki)' },
26
+
{ value: 'Europe/Moscow', label: 'UTC+3 (Moscow)' },
27
+
{ value: 'Asia/Dubai', label: 'UTC+4 (Dubai)' },
28
+
{ value: 'Asia/Karachi', label: 'UTC+5 (Karachi)' },
29
+
{ value: 'Asia/Kolkata', label: 'UTC+5:30 (Mumbai)' },
30
+
{ value: 'Asia/Dhaka', label: 'UTC+6 (Dhaka)' },
31
+
{ value: 'Asia/Bangkok', label: 'UTC+7 (Bangkok)' },
32
+
{ value: 'Asia/Shanghai', label: 'UTC+8 (Shanghai)' },
33
+
{ value: 'Asia/Tokyo', label: 'UTC+9 (Tokyo)' },
34
+
{ value: 'Australia/Sydney', label: 'UTC+10 (Sydney)' },
35
+
{ value: 'Pacific/Noumea', label: 'UTC+11 (Noumea)' },
36
+
{ value: 'Pacific/Auckland', label: 'UTC+12 (Auckland)' }
37
+
];
38
+
39
+
onMount(() => {
40
+
if (!cardData.timezone) {
41
+
try {
42
+
item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
43
+
} catch {
44
+
item.cardData.timezone = 'UTC';
45
+
}
46
+
}
47
+
});
48
+
49
+
function useLocalTimezone() {
50
+
try {
51
+
item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
52
+
} catch {
53
+
item.cardData.timezone = 'UTC';
54
+
}
55
+
}
56
+
</script>
57
+
58
+
<div class="flex flex-col gap-4">
59
+
<div class="flex flex-col gap-2">
60
+
<Label>Timezone</Label>
61
+
<div class="flex gap-2">
62
+
<select
63
+
value={cardData.timezone || 'UTC'}
64
+
onchange={(e) => (item.cardData.timezone = e.currentTarget.value)}
65
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 flex-1 rounded-xl border px-3 py-2"
66
+
>
67
+
{#each timezoneOptions as tz (tz.value)}
68
+
<option value={tz.value}>{tz.label}</option>
69
+
{/each}
70
+
</select>
71
+
<Button size="sm" variant="ghost" onclick={useLocalTimezone}>Local</Button>
72
+
</div>
73
+
</div>
74
+
</div>
+31
src/lib/cards/ClockCard/index.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import type { CardDefinition } from '../types';
2
+
import ClockCard from './ClockCard.svelte';
3
+
import ClockCardSettings from './ClockCardSettings.svelte';
4
+
5
+
export type ClockCardData = {
6
+
timezone?: string;
7
+
};
8
+
9
+
export const ClockCardDefinition = {
10
+
type: 'clock',
11
+
contentComponent: ClockCard,
12
+
settingsComponent: ClockCardSettings,
13
+
14
+
createNew: (card) => {
15
+
card.w = 4;
16
+
card.h = 2;
17
+
card.mobileW = 8;
18
+
card.mobileH = 3;
19
+
card.cardData = {
20
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
21
+
} as ClockCardData;
22
+
},
23
+
24
+
allowSetColor: true,
25
+
name: 'Clock',
26
+
minW: 4,
27
+
canHaveLabel: true,
28
+
groups: ['Utilities'],
29
+
keywords: ['time', 'timezone', 'watch'],
30
+
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 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>`
31
+
} as CardDefinition & { type: 'clock' };
+185
src/lib/cards/CountdownCard/CountdownCard.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte';
3
+
import type { ContentComponentProps } from '../types';
4
+
import type { CountdownCardData } from './index';
5
+
import { onMount } from 'svelte';
6
+
7
+
let { item }: ContentComponentProps = $props();
8
+
9
+
let cardData = $derived(item.cardData as CountdownCardData);
10
+
11
+
let now = $state(new Date());
12
+
13
+
onMount(() => {
14
+
const interval = setInterval(() => {
15
+
now = new Date();
16
+
}, 1000);
17
+
return () => clearInterval(interval);
18
+
});
19
+
20
+
// Countdown to target date
21
+
let eventDiff = $derived.by(() => {
22
+
if (!cardData.targetDate) return null;
23
+
const target = new Date(cardData.targetDate);
24
+
return Math.max(0, target.getTime() - now.getTime());
25
+
});
26
+
27
+
let eventDays = $derived(eventDiff !== null ? Math.floor(eventDiff / (1000 * 60 * 60 * 24)) : 0);
28
+
let eventHours = $derived(
29
+
eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0
30
+
);
31
+
let eventMinutes = $derived(
32
+
eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0
33
+
);
34
+
let eventSeconds = $derived(
35
+
eventDiff !== null ? Math.floor((eventDiff % (1000 * 60)) / 1000) : 0
36
+
);
37
+
38
+
// Check if event is in the past (elapsed mode)
39
+
let isEventPast = $derived.by(() => {
40
+
if (!cardData.targetDate) return false;
41
+
return now.getTime() > new Date(cardData.targetDate).getTime();
42
+
});
43
+
44
+
// Elapsed time since past event
45
+
let elapsedDiff = $derived.by(() => {
46
+
if (!isEventPast || !cardData.targetDate) return null;
47
+
return now.getTime() - new Date(cardData.targetDate).getTime();
48
+
});
49
+
50
+
let elapsedYears = $derived(
51
+
elapsedDiff !== null ? Math.floor(elapsedDiff / (1000 * 60 * 60 * 24 * 365)) : 0
52
+
);
53
+
let elapsedDays = $derived(
54
+
elapsedDiff !== null
55
+
? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24))
56
+
: 0
57
+
);
58
+
let elapsedHours = $derived(
59
+
elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0
60
+
);
61
+
let elapsedMinutes = $derived(
62
+
elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0
63
+
);
64
+
let elapsedSeconds = $derived(
65
+
elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60)) / 1000) : 0
66
+
);
67
+
</script>
68
+
69
+
<div class="@container flex h-full w-full flex-col items-center justify-center p-4">
70
+
{#if isEventPast && elapsedDiff !== null}
71
+
<!-- Elapsed time since past event -->
72
+
<NumberFlowGroup>
73
+
<div
74
+
class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8"
75
+
style="font-variant-numeric: tabular-nums;"
76
+
>
77
+
{#if elapsedYears > 0}
78
+
<div class="flex flex-col items-center">
79
+
<NumberFlow
80
+
value={elapsedYears}
81
+
trend={1}
82
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
83
+
/>
84
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs"
85
+
>{elapsedYears === 1 ? 'year' : 'years'}</span
86
+
>
87
+
</div>
88
+
{/if}
89
+
{#if elapsedYears > 0 || elapsedDays > 0}
90
+
<div class="flex flex-col items-center">
91
+
<NumberFlow
92
+
value={elapsedDays}
93
+
trend={1}
94
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
95
+
/>
96
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs"
97
+
>{elapsedDays === 1 ? 'day' : 'days'}</span
98
+
>
99
+
</div>
100
+
{/if}
101
+
<div class="flex flex-col items-center">
102
+
<NumberFlow
103
+
value={elapsedHours}
104
+
trend={1}
105
+
format={{ minimumIntegerDigits: 2 }}
106
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
107
+
/>
108
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span>
109
+
</div>
110
+
<div class="flex flex-col items-center">
111
+
<NumberFlow
112
+
value={elapsedMinutes}
113
+
trend={1}
114
+
format={{ minimumIntegerDigits: 2 }}
115
+
digits={{ 1: { max: 5 } }}
116
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
117
+
/>
118
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span>
119
+
</div>
120
+
<div class="flex flex-col items-center">
121
+
<NumberFlow
122
+
value={elapsedSeconds}
123
+
trend={1}
124
+
format={{ minimumIntegerDigits: 2 }}
125
+
digits={{ 1: { max: 5 } }}
126
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
127
+
/>
128
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span>
129
+
</div>
130
+
</div>
131
+
</NumberFlowGroup>
132
+
{:else if eventDiff !== null}
133
+
<!-- Countdown to future event -->
134
+
<NumberFlowGroup>
135
+
<div
136
+
class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8"
137
+
style="font-variant-numeric: tabular-nums;"
138
+
>
139
+
{#if eventDays > 0}
140
+
<div class="flex flex-col items-center">
141
+
<NumberFlow
142
+
value={eventDays}
143
+
trend={-1}
144
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
145
+
/>
146
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs"
147
+
>{eventDays === 1 ? 'day' : 'days'}</span
148
+
>
149
+
</div>
150
+
{/if}
151
+
<div class="flex flex-col items-center">
152
+
<NumberFlow
153
+
value={eventHours}
154
+
trend={-1}
155
+
format={{ minimumIntegerDigits: 2 }}
156
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
157
+
/>
158
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span>
159
+
</div>
160
+
<div class="flex flex-col items-center">
161
+
<NumberFlow
162
+
value={eventMinutes}
163
+
trend={-1}
164
+
format={{ minimumIntegerDigits: 2 }}
165
+
digits={{ 1: { max: 5 } }}
166
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
167
+
/>
168
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span>
169
+
</div>
170
+
<div class="flex flex-col items-center">
171
+
<NumberFlow
172
+
value={eventSeconds}
173
+
trend={-1}
174
+
format={{ minimumIntegerDigits: 2 }}
175
+
digits={{ 1: { max: 5 } }}
176
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
177
+
/>
178
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span>
179
+
</div>
180
+
</div>
181
+
</NumberFlowGroup>
182
+
{:else}
183
+
<div class="text-base-500 text-sm">Set a target date in settings</div>
184
+
{/if}
185
+
</div>
+44
src/lib/cards/CountdownCard/CountdownCardSettings.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import type { Item } from '$lib/types';
3
+
import { Input, Label } from '@foxui/core';
4
+
import type { CountdownCardData } from './index';
5
+
6
+
let { item }: { item: Item; onclose: () => void } = $props();
7
+
8
+
let cardData = $derived(item.cardData as CountdownCardData);
9
+
10
+
let targetDateValue = $derived.by(() => {
11
+
if (!cardData.targetDate) return '';
12
+
return new Date(cardData.targetDate).toISOString().split('T')[0];
13
+
});
14
+
15
+
let targetTimeValue = $derived.by(() => {
16
+
if (!cardData.targetDate) return '12:00';
17
+
return new Date(cardData.targetDate).toTimeString().slice(0, 5);
18
+
});
19
+
20
+
function updateTargetDate(dateStr: string, timeStr: string) {
21
+
if (!dateStr) return;
22
+
item.cardData.targetDate = new Date(`${dateStr}T${timeStr}`).toISOString();
23
+
}
24
+
</script>
25
+
26
+
<div class="flex flex-col gap-4">
27
+
<div class="flex flex-col gap-2">
28
+
<Label>Target Date & Time</Label>
29
+
<div class="flex gap-2">
30
+
<Input
31
+
type="date"
32
+
value={targetDateValue}
33
+
onchange={(e) => updateTargetDate(e.currentTarget.value, targetTimeValue)}
34
+
class="flex-1"
35
+
/>
36
+
<Input
37
+
type="time"
38
+
value={targetTimeValue}
39
+
onchange={(e) => updateTargetDate(targetDateValue, e.currentTarget.value)}
40
+
class="w-28"
41
+
/>
42
+
</div>
43
+
</div>
44
+
</div>
+29
src/lib/cards/CountdownCard/index.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import type { CardDefinition } from '../types';
2
+
import CountdownCard from './CountdownCard.svelte';
3
+
import CountdownCardSettings from './CountdownCardSettings.svelte';
4
+
5
+
export type CountdownCardData = {
6
+
targetDate?: string;
7
+
};
8
+
9
+
export const CountdownCardDefinition = {
10
+
type: 'countdown',
11
+
contentComponent: CountdownCard,
12
+
settingsComponent: CountdownCardSettings,
13
+
14
+
createNew: (card) => {
15
+
card.w = 4;
16
+
card.h = 2;
17
+
card.mobileW = 8;
18
+
card.mobileH = 3;
19
+
card.cardData = {} as CountdownCardData;
20
+
},
21
+
22
+
allowSetColor: true,
23
+
name: 'Countdown',
24
+
minW: 4,
25
+
canHaveLabel: true,
26
+
groups: ['Utilities'],
27
+
keywords: ['timer', 'event', 'date', 'countdown'],
28
+
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 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z M19.5 4.5l-1.5 1.5M4.5 4.5l1.5 1.5M12 2.25V3.75M9 2.25h6" /></svg>`
29
+
} as CardDefinition & { type: 'countdown' };
+4
-1
src/lib/cards/DrawCard/index.ts
···
23
strokeWidth: 1,
24
locked: true
25
};
26
-
}
0
0
0
27
} as CardDefinition & { type: 'draw' };
···
23
strokeWidth: 1,
24
locked: true
25
};
26
+
},
27
+
28
+
groups: ['Visual'],
29
+
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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /></svg>`
30
} as CardDefinition & { type: 'draw' };
+3
-1
src/lib/cards/EmbedCard/index.ts
···
19
// change: (item) => {
20
// return item;
21
// },
22
-
name: 'Embed Card'
0
0
23
} as CardDefinition & { type: 'embed' };
···
19
// change: (item) => {
20
// return item;
21
// },
22
+
name: 'Embed',
23
+
groups: ['Media'],
24
+
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="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>`
25
} as CardDefinition & { type: 'embed' };
+4
-1
src/lib/cards/EventCard/index.ts
···
112
113
urlHandlerPriority: 5,
114
115
-
name: 'Event Card'
0
0
0
116
} as CardDefinition & { type: 'event' };
···
112
113
urlHandlerPriority: 5,
114
115
+
name: 'Event',
116
+
117
+
groups: ['Social'],
118
+
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="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg>`
119
} as CardDefinition & { type: 'event' };
+5
-1
src/lib/cards/FluidTextCard/index.ts
···
23
sidebarButtonText: 'Fluid Text',
24
defaultColor: 'transparent',
25
allowSetColor: true,
26
-
minW: 2
0
0
0
0
27
} as CardDefinition & { type: 'fluid-text' };
···
23
sidebarButtonText: 'Fluid Text',
24
defaultColor: 'transparent',
25
allowSetColor: true,
26
+
minW: 2,
27
+
28
+
groups: ['Visual'],
29
+
name: 'Fluid Text',
30
+
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 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></svg>`
31
} as CardDefinition & { type: 'fluid-text' };
+4
-1
src/lib/cards/GIFCard/index.ts
···
45
return null;
46
},
47
urlHandlerPriority: 5,
48
-
name: 'GIF'
0
0
0
49
} as CardDefinition & { type: 'gif' };
···
45
return null;
46
},
47
urlHandlerPriority: 5,
48
+
name: 'GIF',
49
+
50
+
groups: ['Media'],
51
+
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.75 8.25v7.5m-6-3.75h3v3.75m-3-7.5h3M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /></svg>`
52
} as CardDefinition & { type: 'gif' };
+5
-1
src/lib/cards/GameCards/DinoGameCard/index.ts
···
14
card.mobileH = 6;
15
card.cardData = {};
16
},
17
-
canHaveLabel: true
0
0
0
0
18
} as CardDefinition & { type: 'dino-game' };
···
14
card.mobileH = 6;
15
card.cardData = {};
16
},
17
+
canHaveLabel: true,
18
+
19
+
groups: ['Games'],
20
+
name: 'Dino Game',
21
+
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="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.491 48.491 0 0 1-4.163-.3c-1.228-.158-2.33.895-2.33 2.134v0c0 1.26 1.09 2.22 2.34 2.14a48.089 48.089 0 0 1 3.27-.108c.43 0 .78.348.78.78v0c0 .22-.09.422-.234.577a8.398 8.398 0 0 0-2.07 4.238c-.19 1.14.513 2.163 1.578 2.428a2.07 2.07 0 0 0 2.478-1.41c.203-.636.37-1.294.524-1.947.128-.537.612-.898 1.16-.84 1.378.15 2.782.18 4.17.076 1.156-.087 2.03-1.09 1.883-2.24a8.52 8.52 0 0 0-1.568-3.7A2.01 2.01 0 0 1 18 8.053v0c0-1.064.82-1.98 1.88-2.08A48.678 48.678 0 0 0 24 5.328v0" /></svg>`
22
} as CardDefinition & { type: 'dino-game' };
+6
-1
src/lib/cards/GameCards/TetrisCard/index.ts
···
19
card.cardData = {};
20
},
21
maxH: 10,
22
-
canHaveLabel: true
0
0
0
0
0
23
} as CardDefinition & { type: 'tetris' };
···
19
card.cardData = {};
20
},
21
maxH: 10,
22
+
canHaveLabel: true,
23
+
24
+
groups: ['Games'],
25
+
26
+
name: 'Tetris',
27
+
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="M14 4h-4v4H6v4h4v4h4v-4h4V8h-4V4Z" /></svg>`
28
} as CardDefinition & { type: 'tetris' };
+70
src/lib/cards/GitHubProfileCard/CreateGitHubProfileCardModal.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import { Button, Input, Modal, Subheading } from '@foxui/core';
3
+
import type { CreationModalComponentProps } from '../types';
4
+
5
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
6
+
7
+
let errorMessage = $state('');
8
+
</script>
9
+
10
+
<Modal open={true} closeButton={false}>
11
+
<form
12
+
onsubmit={() => {
13
+
let input = item.cardData.href?.trim();
14
+
if (!input) return;
15
+
16
+
let username: string | undefined;
17
+
18
+
// Try parsing as URL first
19
+
try {
20
+
const parsed = new URL(input);
21
+
if (/^(www\.)?github\.com$/.test(parsed.hostname)) {
22
+
const segments = parsed.pathname.split('/').filter(Boolean);
23
+
if (
24
+
segments.length === 1 &&
25
+
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(segments[0])
26
+
) {
27
+
username = segments[0];
28
+
}
29
+
}
30
+
} catch {
31
+
// Not a URL, try as plain username
32
+
if (/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input)) {
33
+
username = input;
34
+
}
35
+
}
36
+
37
+
if (!username) {
38
+
errorMessage = 'Please enter a valid GitHub username or profile URL';
39
+
return;
40
+
}
41
+
42
+
item.cardData.user = username;
43
+
item.cardData.href = `https://github.com/${username}`;
44
+
45
+
item.w = 6;
46
+
item.mobileW = 8;
47
+
item.h = 3;
48
+
item.mobileH = 6;
49
+
50
+
oncreate?.();
51
+
}}
52
+
class="flex flex-col gap-2"
53
+
>
54
+
<Subheading>Enter a GitHub username or profile URL</Subheading>
55
+
<Input
56
+
bind:value={item.cardData.href}
57
+
placeholder="username or https://github.com/username"
58
+
class="mt-4"
59
+
/>
60
+
61
+
{#if errorMessage}
62
+
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
63
+
{/if}
64
+
65
+
<div class="mt-4 flex justify-end gap-2">
66
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
67
+
<Button type="submit">Create</Button>
68
+
</div>
69
+
</form>
70
+
</Modal>
+6
-1
src/lib/cards/GitHubProfileCard/index.ts
···
1
import type { CardDefinition } from '../types';
0
2
import type GithubContributionsGraph from './GithubContributionsGraph.svelte';
3
import GitHubProfileCard from './GitHubProfileCard.svelte';
4
import type { GitHubContributionsData } from './types';
···
8
export const GithubProfileCardDefitition = {
9
type: 'githubProfile',
10
contentComponent: GitHubProfileCard,
0
11
12
loadData: async (items) => {
13
const githubData: Record<string, GithubContributionsGraph> = {};
···
50
51
return item;
52
},
53
-
name: 'Github Profile'
0
0
0
54
} as CardDefinition & { type: 'githubProfile' };
55
56
function getGitHubUsername(url: string | undefined): string | undefined {
···
1
import type { CardDefinition } from '../types';
2
+
import CreateGitHubProfileCardModal from './CreateGitHubProfileCardModal.svelte';
3
import type GithubContributionsGraph from './GithubContributionsGraph.svelte';
4
import GitHubProfileCard from './GitHubProfileCard.svelte';
5
import type { GitHubContributionsData } from './types';
···
9
export const GithubProfileCardDefitition = {
10
type: 'githubProfile',
11
contentComponent: GitHubProfileCard,
12
+
creationModalComponent: CreateGitHubProfileCardModal,
13
14
loadData: async (items) => {
15
const githubData: Record<string, GithubContributionsGraph> = {};
···
52
53
return item;
54
},
55
+
name: 'Github Profile',
56
+
57
+
groups: ['Social'],
58
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>`
59
} as CardDefinition & { type: 'githubProfile' };
60
61
function getGitHubUsername(url: string | undefined): string | undefined {
+3
-1
src/lib/cards/GuestbookCard/index.ts
···
60
61
return results;
62
},
63
-
name: 'Guestbook'
0
0
64
} as CardDefinition & { type: 'guestbook' };
···
60
61
return results;
62
},
63
+
name: 'Guestbook',
64
+
groups: ['Social'],
65
+
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.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>`
66
} as CardDefinition & { type: 'guestbook' };
+19
-2
src/lib/cards/ImageCard/index.ts
···
42
},
43
urlHandlerPriority: 3,
44
45
-
name: 'Image Card',
0
0
0
0
46
47
-
canHaveLabel: true
0
0
0
0
0
0
0
0
0
0
0
0
0
48
} as CardDefinition & { type: 'image' };
···
42
},
43
urlHandlerPriority: 3,
44
45
+
name: 'Image',
46
+
47
+
canHaveLabel: true,
48
+
49
+
groups: ['Core'],
50
51
+
icon: `<svg
52
+
xmlns="http://www.w3.org/2000/svg"
53
+
fill="none"
54
+
viewBox="0 0 24 24"
55
+
stroke-width="2"
56
+
stroke="currentColor"
57
+
class="size-4"
58
+
>
59
+
<path
60
+
stroke-linecap="round"
61
+
stroke-linejoin="round"
62
+
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
63
+
/>
64
+
</svg>`
65
} as CardDefinition & { type: 'image' };
+6
-1
src/lib/cards/LatestBlueskyPostCard/index.ts
···
18
19
return JSON.parse(JSON.stringify(authorFeed));
20
},
21
-
minW: 4
0
0
0
0
0
22
} as CardDefinition & { type: 'latestPost' };
···
18
19
return JSON.parse(JSON.stringify(authorFeed));
20
},
21
+
minW: 4,
22
+
23
+
name: 'Latest Bluesky Post',
24
+
25
+
groups: ['Social'],
26
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>`
27
} as CardDefinition & { type: 'latestPost' };
+44
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import { Button, Input, Modal, Subheading } from '@foxui/core';
3
+
import type { CreationModalComponentProps } from '../types';
4
+
import { validateLink } from '$lib/helper';
5
+
6
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
7
+
8
+
let isFetchingLocation = $state(false);
9
+
10
+
let errorMessage = $state('');
11
+
</script>
12
+
13
+
<Modal open={true} closeButton={false}>
14
+
<form
15
+
onsubmit={() => {
16
+
if (!item.cardData.href.trim()) return;
17
+
18
+
let link = validateLink(item.cardData.href);
19
+
if (!link) {
20
+
errorMessage = 'Invalid link';
21
+
return;
22
+
}
23
+
24
+
item.cardData.href = link;
25
+
item.cardData.domain = new URL(link).hostname;
26
+
item.cardData.hasFetched = false;
27
+
28
+
oncreate?.();
29
+
}}
30
+
class="flex flex-col gap-2"
31
+
>
32
+
<Subheading>Enter a link</Subheading>
33
+
<Input bind:value={item.cardData.href} class="mt-4" />
34
+
35
+
{#if errorMessage}
36
+
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
37
+
{/if}
38
+
39
+
<div class="mt-4 flex justify-end gap-2">
40
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
41
+
<Button type="submit" disabled={isFetchingLocation}>Create</Button>
42
+
</div>
43
+
</form>
44
+
</Modal>
+22
-2
src/lib/cards/LinkCard/index.ts
···
1
import { checkAndUploadImage, validateLink } from '$lib/helper';
2
import type { CardDefinition } from '../types';
0
3
import EditingLinkCard from './EditingLinkCard.svelte';
4
import LinkCard from './LinkCard.svelte';
5
import LinkCardSettings from './LinkCardSettings.svelte';
···
13
},
14
settingsComponent: LinkCardSettings,
15
16
-
name: 'Link Card',
0
0
17
canChange: (item) => Boolean(validateLink(item.cardData?.href)),
18
change: (item) => {
19
const href = validateLink(item.cardData?.href);
···
36
await checkAndUploadImage(item.cardData, 'favicon');
37
return item;
38
},
39
-
urlHandlerPriority: 0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
40
} as CardDefinition & { type: 'link' };
···
1
import { checkAndUploadImage, validateLink } from '$lib/helper';
2
import type { CardDefinition } from '../types';
3
+
import CreateLinkCardModal from './CreateLinkCardModal.svelte';
4
import EditingLinkCard from './EditingLinkCard.svelte';
5
import LinkCard from './LinkCard.svelte';
6
import LinkCardSettings from './LinkCardSettings.svelte';
···
14
},
15
settingsComponent: LinkCardSettings,
16
17
+
creationModalComponent: CreateLinkCardModal,
18
+
19
+
name: 'Link',
20
canChange: (item) => Boolean(validateLink(item.cardData?.href)),
21
change: (item) => {
22
const href = validateLink(item.cardData?.href);
···
39
await checkAndUploadImage(item.cardData, 'favicon');
40
return item;
41
},
42
+
urlHandlerPriority: 0,
43
+
44
+
groups: ['Core'],
45
+
46
+
icon: `<svg
47
+
xmlns="http://www.w3.org/2000/svg"
48
+
fill="none"
49
+
viewBox="-2 -2 28 28"
50
+
stroke-width="2"
51
+
stroke="currentColor"
52
+
class="size-4"
53
+
>
54
+
<path
55
+
stroke-linecap="round"
56
+
stroke-linejoin="round"
57
+
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
58
+
/>
59
+
</svg>`
60
} as CardDefinition & { type: 'link' };
+3
-1
src/lib/cards/LivestreamCard/index.ts
···
81
82
urlHandlerPriority: 5,
83
84
-
name: 'stream.place Card'
0
0
85
} as CardDefinition & { type: 'latestLivestream' };
86
87
export const LivestreamEmbedCardDefitition = {
···
81
82
urlHandlerPriority: 5,
83
84
+
name: 'Latest Livestream (stream.place)',
85
+
groups: ['Media'],
86
+
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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>`
87
} as CardDefinition & { type: 'latestLivestream' };
88
89
export const LivestreamEmbedCardDefitition = {
+10
-1
src/lib/cards/MapCard/index.ts
···
17
creationModalComponent: CreateMapCardModal,
18
allowSetColor: false,
19
canHaveLabel: true,
20
-
settingsComponent: MapCardSettings
0
0
0
0
0
0
0
0
0
21
} as CardDefinition & { type: 'mapLocation' };
22
23
export function getZoomLevel(type: string | undefined): number {
···
17
creationModalComponent: CreateMapCardModal,
18
allowSetColor: false,
19
canHaveLabel: true,
20
+
settingsComponent: MapCardSettings,
21
+
22
+
groups: ['Core'],
23
+
24
+
name: 'Map',
25
+
26
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4">
27
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z" />
28
+
</svg>
29
+
`
30
} as CardDefinition & { type: 'mapLocation' };
31
32
export function getZoomLevel(type: string | undefined): number {
+37
-23
src/lib/cards/PopfeedReviews/PopfeedReviewsCard.svelte
···
30
</script>
31
32
<div class="z-10 flex h-full gap-4 overflow-x-scroll p-4">
33
-
{#each feed ?? [] as review (review.uri)}
34
-
{#if review.value.rating !== undefined && review.value.posterUrl}
35
-
<a
36
-
rel="noopener noreferrer"
37
-
target="_blank"
38
-
class="flex"
39
-
href="https://popfeed.social/review/{review.uri}"
40
-
>
41
-
<div
42
-
class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1"
43
>
44
-
<img
45
-
src={review.value.posterUrl}
46
-
alt=""
47
-
class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover"
48
-
/>
49
-
50
<div
51
-
class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent"
52
-
></div>
0
0
0
0
0
53
54
-
<Rating class="z-10 text-lg" rating={review.value.rating} />
55
-
</div>
56
-
</a>
57
-
{/if}
58
-
{/each}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
59
</div>
···
30
</script>
31
32
<div class="z-10 flex h-full gap-4 overflow-x-scroll p-4">
33
+
{#if feed && feed.length > 0}
34
+
{#each feed as review (review.uri)}
35
+
{#if review.value.rating !== undefined && review.value.posterUrl}
36
+
<a
37
+
rel="noopener noreferrer"
38
+
target="_blank"
39
+
class="flex"
40
+
href="https://popfeed.social/review/{review.uri}"
0
0
41
>
0
0
0
0
0
0
42
<div
43
+
class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1"
44
+
>
45
+
<img
46
+
src={review.value.posterUrl}
47
+
alt=""
48
+
class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover"
49
+
/>
50
51
+
<div
52
+
class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent"
53
+
></div>
54
+
55
+
<Rating class="z-10 text-lg" rating={review.value.rating} />
56
+
</div>
57
+
</a>
58
+
{/if}
59
+
{/each}
60
+
{:else if feed}
61
+
<div
62
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full w-full items-center justify-center text-center text-sm"
63
+
>
64
+
No reviews yet.
65
+
</div>
66
+
{:else}
67
+
<div
68
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full w-full items-center justify-center text-center text-sm"
69
+
>
70
+
Loading reviews...
71
+
</div>
72
+
{/if}
73
</div>
+5
-1
src/lib/cards/PopfeedReviews/index.ts
···
18
},
19
minH: 3,
20
sidebarButtonText: 'Popfeed Reviews',
21
-
canHaveLabel: true
0
0
0
0
22
} as CardDefinition & { type: 'recentPopfeedReviews' };
···
18
},
19
minH: 3,
20
sidebarButtonText: 'Popfeed Reviews',
21
+
canHaveLabel: true,
22
+
23
+
groups: ['Media'],
24
+
name: 'Movie and TV Reviews',
25
+
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="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>`
26
} as CardDefinition & { type: 'recentPopfeedReviews' };
+16
-1
src/lib/cards/SectionCard/index.ts
···
26
defaultColor: 'transparent',
27
maxH: 1,
28
canResize: false,
29
-
settingsComponent: SectionCardSettings
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
30
} as CardDefinition & { type: 'section' };
31
32
export const textAlignClasses: Record<string, string> = {
···
26
defaultColor: 'transparent',
27
maxH: 1,
28
canResize: false,
29
+
settingsComponent: SectionCardSettings,
30
+
31
+
name: 'Heading',
32
+
groups: ['Core'],
33
+
34
+
icon: `<svg
35
+
xmlns="http://www.w3.org/2000/svg"
36
+
viewBox="0 0 24 24"
37
+
fill="none"
38
+
stroke="currentColor"
39
+
stroke-width="2"
40
+
stroke-linecap="round"
41
+
stroke-linejoin="round"
42
+
class="size-4"
43
+
><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg
44
+
>`
45
} as CardDefinition & { type: 'section' };
46
47
export const textAlignClasses: Record<string, string> = {
+4
-1
src/lib/cards/SpotifyCard/index.ts
···
40
name: 'Spotify Embed',
41
canResize: true,
42
minW: 4,
43
-
minH: 5
0
0
0
44
} as CardDefinition & { type: typeof cardType };
45
46
// Match Spotify album and playlist URLs
···
40
name: 'Spotify Embed',
41
canResize: true,
42
minW: 4,
43
+
minH: 5,
44
+
45
+
groups: ['Media'],
46
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" /></svg>`
47
} as CardDefinition & { type: typeof cardType };
48
49
// Match Spotify album and playlist URLs
+34
-8
src/lib/cards/StandardSiteDocumentListCard/StandardSiteDocumentListCard.svelte
···
27
</script>
28
29
<div class="flex h-full flex-col gap-10 overflow-y-scroll p-8">
30
-
{#each feed ?? [] as document (document.uri)}
31
-
<BlogEntry
32
-
title={document.value.title}
33
-
description={document.value.description}
34
-
date={document.value.publishedAt}
35
-
href={document.value.href}
36
-
/>
37
-
{/each}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
38
</div>
···
27
</script>
28
29
<div class="flex h-full flex-col gap-10 overflow-y-scroll p-8">
30
+
{#if feed && feed.length > 0}
31
+
{#each feed as document (document.uri)}
32
+
<BlogEntry
33
+
title={document.value.title}
34
+
description={document.value.description}
35
+
date={document.value.publishedAt}
36
+
href={document.value.href}
37
+
/>
38
+
{/each}
39
+
{:else if feed}
40
+
<div
41
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full flex-col items-center justify-center gap-2 text-center text-sm"
42
+
>
43
+
<span>No blog posts found.</span>
44
+
<span>
45
+
Create some on <a
46
+
href="https://leaflet.pub"
47
+
target="_blank"
48
+
rel="noopener noreferrer"
49
+
class="underline">Leaflet</a
50
+
>
51
+
or
52
+
<a href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class="underline"
53
+
>Pckt</a
54
+
>
55
+
</span>
56
+
</div>
57
+
{:else}
58
+
<div
59
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
60
+
>
61
+
Loading blog posts...
62
+
</div>
63
+
{/if}
64
</div>
+6
-1
src/lib/cards/StandardSiteDocumentListCard/index.ts
···
42
return records;
43
},
44
45
-
sidebarButtonText: 'site.standard.document list'
0
0
0
0
0
46
} as CardDefinition & { type: 'site.standard.document list' };
···
42
return records;
43
},
44
45
+
sidebarButtonText: 'site.standard.document list',
46
+
47
+
name: 'Blog Posts',
48
+
49
+
groups: ['Content'],
50
+
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="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>`
51
} as CardDefinition & { type: 'site.standard.document list' };
+5
-1
src/lib/cards/StatusphereCard/index.ts
···
47
item.cardData.label = item.cardData.title;
48
}
49
},
50
-
canHaveLabel: true
0
0
0
0
51
} as CardDefinition & { type: 'statusphere' };
52
53
export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
···
47
item.cardData.label = item.cardData.title;
48
}
49
},
50
+
canHaveLabel: true,
51
+
52
+
name: 'Emoji',
53
+
groups: ['Media'],
54
+
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="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" /></svg>`
55
} as CardDefinition & { type: 'statusphere' };
56
57
export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+22
-8
src/lib/cards/TealFMPlaysCard/TealFMPlaysCard.svelte
···
85
{/snippet}
86
87
<div class="z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4">
88
-
{#each feed ?? [] as play (play.uri)}
89
-
{#if play.value.originUrl}
90
-
<a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full">
0
0
0
0
91
{@render musicItem(play)}
92
-
</a>
93
-
{:else}
94
-
{@render musicItem(play)}
95
-
{/if}
96
-
{/each}
0
0
0
0
0
0
0
0
0
0
97
</div>
···
85
{/snippet}
86
87
<div class="z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4">
88
+
{#if feed && feed.length > 0}
89
+
{#each feed as play (play.uri)}
90
+
{#if play.value.originUrl}
91
+
<a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full">
92
+
{@render musicItem(play)}
93
+
</a>
94
+
{:else}
95
{@render musicItem(play)}
96
+
{/if}
97
+
{/each}
98
+
{:else if feed}
99
+
<div
100
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
101
+
>
102
+
No recent plays found.
103
+
</div>
104
+
{:else}
105
+
<div
106
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm"
107
+
>
108
+
Loading plays...
109
+
</div>
110
+
{/if}
111
</div>
+6
-1
src/lib/cards/TealFMPlaysCard/index.ts
···
22
},
23
minW: 4,
24
sidebarButtonText: 'teal.fm Plays',
25
-
canHaveLabel: true
0
0
0
0
0
26
} as CardDefinition & { type: 'recentTealFMPlays' };
···
22
},
23
minW: 4,
24
sidebarButtonText: 'teal.fm Plays',
25
+
canHaveLabel: true,
26
+
27
+
name: 'Teal.fm Plays',
28
+
29
+
groups: ['Media'],
30
+
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="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>`
31
} as CardDefinition & { type: 'recentTealFMPlays' };
+16
-1
src/lib/cards/TextCard/index.ts
···
14
};
15
},
16
17
-
settingsComponent: TextCardSettings
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
18
} as CardDefinition & { type: 'text' };
19
20
export const textAlignClasses: Record<string, string> = {
···
14
};
15
},
16
17
+
settingsComponent: TextCardSettings,
18
+
19
+
name: 'Text',
20
+
21
+
groups: ['Core'],
22
+
23
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4"
24
+
><path
25
+
fill="none"
26
+
stroke="currentColor"
27
+
stroke-linecap="round"
28
+
stroke-linejoin="round"
29
+
stroke-width="2"
30
+
d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392"
31
+
/></svg
32
+
>`
33
} as CardDefinition & { type: 'text' };
34
35
export const textAlignClasses: Record<string, string> = {
+15
-3
src/lib/cards/TimerCard/index.ts
···
17
type: 'timer',
18
contentComponent: TimerCard,
19
settingsComponent: TimerCardSettings,
20
-
sidebarButtonText: 'Timer',
21
22
createNew: (card) => {
23
card.w = 4;
···
31
},
32
33
allowSetColor: true,
34
-
name: 'Timer Card',
35
minW: 4,
36
-
canHaveLabel: true
0
0
0
0
0
0
0
0
0
0
0
0
0
0
37
} as CardDefinition & { type: 'timer' };
···
17
type: 'timer',
18
contentComponent: TimerCard,
19
settingsComponent: TimerCardSettings,
0
20
21
createNew: (card) => {
22
card.w = 4;
···
30
},
31
32
allowSetColor: true,
0
33
minW: 4,
34
+
canHaveLabel: true,
35
+
36
+
migrate: (item) => {
37
+
const data = item.cardData as TimerCardData;
38
+
if (data.mode === 'event') {
39
+
item.cardType = 'countdown';
40
+
item.cardData = { targetDate: data.targetDate };
41
+
} else {
42
+
item.cardType = 'clock';
43
+
item.cardData = { timezone: data.timezone };
44
+
}
45
+
if (data.label) {
46
+
item.cardData.label = data.label;
47
+
}
48
+
}
49
} as CardDefinition & { type: 'timer' };
+3
-1
src/lib/cards/VCardCard/index.ts
···
122
123
sidebarButtonText: 'vCard',
124
allowSetColor: true,
125
-
name: 'vCard Card'
0
0
126
} as CardDefinition & { type: 'vcard' };
···
122
123
sidebarButtonText: 'vCard',
124
allowSetColor: true,
125
+
name: 'vCard Card',
126
+
groups: ['Social'],
127
+
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="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" /></svg>`
128
} as CardDefinition & { type: 'vcard' };
+3
-1
src/lib/cards/VideoCard/index.ts
···
59
},
60
settingsComponent: VideoCardSettings,
61
62
-
name: 'Video Card'
0
0
63
} as CardDefinition & { type: 'video' };
···
59
},
60
settingsComponent: VideoCardSettings,
61
62
+
name: 'Video',
63
+
groups: ['Media'],
64
+
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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>`
65
} as CardDefinition & { type: 'video' };
+52
src/lib/cards/YoutubeVideoCard/CreateYoutubeCardModal.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import { Button, Input, Modal, Subheading } from '@foxui/core';
3
+
import type { CreationModalComponentProps } from '../types';
4
+
import { matcher } from './index';
5
+
6
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
7
+
8
+
let errorMessage = $state('');
9
+
</script>
10
+
11
+
<Modal open={true} closeButton={false}>
12
+
<form
13
+
onsubmit={() => {
14
+
const url = item.cardData.href?.trim();
15
+
if (!url) return;
16
+
17
+
const id = matcher(url);
18
+
if (!id) {
19
+
errorMessage = 'Please enter a valid YouTube URL';
20
+
return;
21
+
}
22
+
23
+
item.cardData.youtubeId = id;
24
+
item.cardData.poster = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
25
+
item.cardData.showInline = true;
26
+
27
+
item.w = 4;
28
+
item.mobileW = 8;
29
+
item.h = 3;
30
+
item.mobileH = 5;
31
+
32
+
oncreate?.();
33
+
}}
34
+
class="flex flex-col gap-2"
35
+
>
36
+
<Subheading>Enter a YouTube URL</Subheading>
37
+
<Input
38
+
bind:value={item.cardData.href}
39
+
placeholder="https://youtube.com/watch?v=..."
40
+
class="mt-4"
41
+
/>
42
+
43
+
{#if errorMessage}
44
+
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
45
+
{/if}
46
+
47
+
<div class="mt-4 flex justify-end gap-2">
48
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
49
+
<Button type="submit">Create</Button>
50
+
</div>
51
+
</form>
52
+
</Modal>
+12
-1
src/lib/cards/YoutubeVideoCard/index.ts
···
1
import type { CardDefinition } from '../types';
0
2
import YoutubeCard from './YoutubeCard.svelte';
3
import YoutubeCardSettings from './YoutubeCardSettings.svelte';
4
···
6
type: 'youtubeVideo',
7
contentComponent: YoutubeCard,
8
settingsComponent: YoutubeCardSettings,
0
9
createNew: (card) => {
10
card.cardType = 'youtubeVideo';
11
card.cardData = {};
···
51
52
return item;
53
},
54
-
name: 'Youtube Video'
0
0
0
0
0
0
0
0
0
55
} as CardDefinition & { type: 'youtubeVideo' };
56
57
// Thanks to eleventy-plugin-youtube-embed
···
1
import type { CardDefinition } from '../types';
2
+
import CreateYoutubeCardModal from './CreateYoutubeCardModal.svelte';
3
import YoutubeCard from './YoutubeCard.svelte';
4
import YoutubeCardSettings from './YoutubeCardSettings.svelte';
5
···
7
type: 'youtubeVideo',
8
contentComponent: YoutubeCard,
9
settingsComponent: YoutubeCardSettings,
10
+
creationModalComponent: CreateYoutubeCardModal,
11
createNew: (card) => {
12
card.cardType = 'youtubeVideo';
13
card.cardData = {};
···
53
54
return item;
55
},
56
+
name: 'Youtube Video',
57
+
58
+
groups: ['Media'],
59
+
60
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-3" viewBox="0 0 256 180"
61
+
><path
62
+
fill="currentColor"
63
+
d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134"
64
+
/><path fill="currentColor" class="invert" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg
65
+
>`
66
} as CardDefinition & { type: 'youtubeVideo' };
67
68
// Thanks to eleventy-plugin-youtube-embed
+4
src/lib/cards/index.ts
···
30
import { VCardCardDefinition } from './VCardCard';
31
import { DrawCardDefinition } from './DrawCard';
32
import { TimerCardDefinition } from './TimerCard';
0
0
33
import { SpotifyCardDefinition } from './SpotifyCard';
34
import { ButtonCardDefinition } from './ButtonCard';
35
import { GuestbookCardDefinition } from './GuestbookCard';
···
69
VCardCardDefinition,
70
DrawCardDefinition,
71
TimerCardDefinition,
0
0
72
SpotifyCardDefinition
73
// Model3DCardDefinition
74
] as const;
···
30
import { VCardCardDefinition } from './VCardCard';
31
import { DrawCardDefinition } from './DrawCard';
32
import { TimerCardDefinition } from './TimerCard';
33
+
import { ClockCardDefinition } from './ClockCard';
34
+
import { CountdownCardDefinition } from './CountdownCard';
35
import { SpotifyCardDefinition } from './SpotifyCard';
36
import { ButtonCardDefinition } from './ButtonCard';
37
import { GuestbookCardDefinition } from './GuestbookCard';
···
71
VCardCardDefinition,
72
DrawCardDefinition,
73
TimerCardDefinition,
74
+
ClockCardDefinition,
75
+
CountdownCardDefinition,
76
SpotifyCardDefinition
77
// Model3DCardDefinition
78
] as const;
+6
src/lib/cards/types.ts
···
73
canHaveLabel?: boolean;
74
75
migrate?: (item: Item) => void;
0
0
0
0
0
0
76
};
···
73
canHaveLabel?: boolean;
74
75
migrate?: (item: Item) => void;
76
+
77
+
groups?: string[];
78
+
79
+
keywords?: string[];
80
+
81
+
icon?: string;
82
};
+192
src/lib/components/card-command/CardCommand.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import { AllCardDefinitions } from '$lib/cards';
3
+
import type { CardDefinition } from '$lib/cards/types';
4
+
import { Command, Dialog } from 'bits-ui';
5
+
import { isTyping } from '$lib/helper';
6
+
7
+
const CardDefGroups = [
8
+
'Core',
9
+
...Array.from(
10
+
new Set(
11
+
AllCardDefinitions.map((cardDef) => cardDef.groups)
12
+
.flat()
13
+
.filter((g) => g)
14
+
)
15
+
)
16
+
.sort()
17
+
.filter((g) => g !== 'Core')
18
+
];
19
+
20
+
let {
21
+
open = $bindable(false),
22
+
onselect,
23
+
onlink
24
+
}: {
25
+
open: boolean;
26
+
onselect: (cardDef: CardDefinition) => void;
27
+
onlink?: (url: string, cardDef: CardDefinition) => void;
28
+
} = $props();
29
+
30
+
let searchValue = $state('');
31
+
32
+
let normalizedUrl = $derived.by(() => {
33
+
if (!searchValue || searchValue.length < 8) return '';
34
+
try {
35
+
const val = searchValue.trim();
36
+
const urlStr = val.startsWith('http') ? val : `https://${val}`;
37
+
const url = new URL(urlStr);
38
+
if (!url.hostname.includes('.')) return '';
39
+
return urlStr;
40
+
} catch {
41
+
return '';
42
+
}
43
+
});
44
+
45
+
let urlMatchingCards = $derived.by(() => {
46
+
if (!normalizedUrl) return [];
47
+
return AllCardDefinitions.filter((d) => d.onUrlHandler)
48
+
.filter((d) => {
49
+
try {
50
+
const testItem = { cardData: {} };
51
+
return d.onUrlHandler!(normalizedUrl, testItem as any);
52
+
} catch {
53
+
return false;
54
+
}
55
+
})
56
+
.toSorted((a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0));
57
+
});
58
+
59
+
function selectUrl(cardDef: CardDefinition) {
60
+
const url = normalizedUrl;
61
+
open = false;
62
+
searchValue = '';
63
+
onlink?.(url, cardDef);
64
+
}
65
+
66
+
function commandFilter(value: string, search: string, keywords?: string[]): number {
67
+
if (value.startsWith('url:')) return 1;
68
+
const s = search.toLowerCase();
69
+
for (const t of [value, ...(keywords ?? [])]) {
70
+
if (t.toLowerCase().includes(s)) return 1;
71
+
}
72
+
return 0;
73
+
}
74
+
75
+
function handleKeydown(e: KeyboardEvent) {
76
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
77
+
e.preventDefault();
78
+
open = true;
79
+
}
80
+
if (e.key === '+' && !isTyping()) {
81
+
e.preventDefault();
82
+
open = true;
83
+
}
84
+
}
85
+
</script>
86
+
87
+
<svelte:document onkeydown={handleKeydown} />
88
+
89
+
<Dialog.Root bind:open>
90
+
<Dialog.Portal>
91
+
<Dialog.Overlay
92
+
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
93
+
/>
94
+
<Dialog.Content
95
+
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-36 left-[50%] z-50 w-full max-w-[94%] translate-x-[-50%] outline-hidden sm:max-w-lg md:w-full"
96
+
>
97
+
<Dialog.Title class="sr-only">Command Menu</Dialog.Title>
98
+
<Dialog.Description class="sr-only">
99
+
This is the command menu. Use the arrow keys to navigate and press ⌘K to open the search
100
+
bar.
101
+
</Dialog.Description>
102
+
<Command.Root
103
+
filter={commandFilter}
104
+
class="border-base-200 dark:border-base-800 mx-auto flex h-full w-full max-w-[90vw] flex-col overflow-hidden rounded-2xl border bg-white dark:bg-black"
105
+
>
106
+
<Command.Input
107
+
class="focus-override placeholder:text-base-900/50 dark:placeholder:text-base-50/50 border-base-200 dark:border-base-800 bg-base-100 mx-1 mt-1 inline-flex truncate rounded-2xl rounded-tl-2xl px-4 text-sm transition-colors focus:ring-0 focus:outline-hidden dark:bg-black"
108
+
placeholder="Search for a card or paste a link..."
109
+
oninput={(e) => {
110
+
searchValue = e.currentTarget.value;
111
+
}}
112
+
/>
113
+
114
+
<Command.List
115
+
class="focus:outline-accent-500/50 max-h-[50vh] overflow-x-hidden overflow-y-auto rounded-br-2xl rounded-bl-2xl bg-white px-2 pb-2 focus:border-0 dark:bg-black"
116
+
>
117
+
<Command.Viewport>
118
+
<Command.Empty
119
+
class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm"
120
+
>
121
+
No results found.
122
+
</Command.Empty>
123
+
124
+
{#if urlMatchingCards.length > 0}
125
+
<Command.Group>
126
+
<Command.GroupHeading
127
+
class="text-base-600 dark:text-base-400 px-3 pt-3 pb-2 text-xs"
128
+
>
129
+
Add from link
130
+
</Command.GroupHeading>
131
+
<Command.GroupItems>
132
+
{#each urlMatchingCards as cardDef (cardDef.type)}
133
+
<Command.Item
134
+
value="url:{cardDef.type}"
135
+
onSelect={() => {
136
+
selectUrl(cardDef);
137
+
}}
138
+
class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none"
139
+
>
140
+
{#if cardDef.icon}
141
+
<div class="text-base-700 dark:text-base-300">
142
+
{@html cardDef.icon}
143
+
</div>
144
+
{/if}
145
+
{cardDef.name}
146
+
</Command.Item>
147
+
{/each}
148
+
</Command.GroupItems>
149
+
</Command.Group>
150
+
<Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" />
151
+
{/if}
152
+
153
+
{#each CardDefGroups as group, index (group)}
154
+
{#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))}
155
+
<Command.Group>
156
+
<Command.GroupHeading
157
+
class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs"
158
+
>
159
+
{group}
160
+
</Command.GroupHeading>
161
+
<Command.GroupItems>
162
+
{#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef (cardDef.type)}
163
+
<Command.Item
164
+
onSelect={() => {
165
+
open = false;
166
+
searchValue = '';
167
+
onselect(cardDef);
168
+
}}
169
+
class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none"
170
+
keywords={[group, cardDef.type, ...(cardDef.keywords || [])]}
171
+
>
172
+
{#if cardDef.icon}
173
+
<div class="text-base-700 dark:text-base-300">
174
+
{@html cardDef.icon}
175
+
</div>
176
+
{/if}
177
+
{cardDef.name}
178
+
</Command.Item>
179
+
{/each}
180
+
</Command.GroupItems>
181
+
</Command.Group>
182
+
{#if index < CardDefGroups.length - 1}
183
+
<Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" />
184
+
{/if}
185
+
{/if}
186
+
{/each}
187
+
</Command.Viewport>
188
+
</Command.List>
189
+
</Command.Root>
190
+
</Dialog.Content>
191
+
</Dialog.Portal>
192
+
</Dialog.Root>
+4
-175
src/lib/website/EditBar.svelte
···
2
import { dev } from '$app/environment';
3
import { user } from '$lib/atproto';
4
import type { WebsiteData } from '$lib/types';
5
-
import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core';
6
7
let {
8
data,
9
-
linkValue = $bindable(),
10
-
newCard,
11
-
addLink,
12
13
showingMobileView = $bindable(),
14
isSaving = $bindable(),
···
16
17
save,
18
19
-
handleImageInputChange,
20
-
handleVideoInputChange
21
}: {
22
data: WebsiteData;
23
-
linkValue: string;
24
-
newCard: (type: string) => void;
25
-
addLink: (url: string) => void;
26
27
showingMobileView: boolean;
28
···
31
32
save: () => Promise<void>;
33
34
-
handleImageInputChange: (evt: Event) => void;
35
-
handleVideoInputChange: (evt: Event) => void;
36
} = $props();
37
-
38
-
let linkPopoverOpen = $state(false);
39
-
40
-
let imageInputRef: HTMLInputElement | undefined = $state();
41
-
let videoInputRef: HTMLInputElement | undefined = $state();
42
43
function getShareUrl() {
44
const base = typeof window !== 'undefined' ? window.location.origin : '';
···
54
}
55
</script>
56
57
-
<input
58
-
type="file"
59
-
accept="image/*"
60
-
onchange={handleImageInputChange}
61
-
class="hidden"
62
-
multiple
63
-
bind:this={imageInputRef}
64
-
/>
65
-
66
-
<input
67
-
type="file"
68
-
accept="video/*"
69
-
onchange={handleVideoInputChange}
70
-
class="hidden"
71
-
multiple
72
-
bind:this={videoInputRef}
73
-
/>
74
-
75
{#if dev || (user.isLoggedIn && user.profile?.did === data.did)}
76
<Navbar
77
class={[
···
80
]}
81
>
82
<div class="flex items-center gap-2">
83
-
<Button
84
-
size="iconLg"
85
-
variant="ghost"
86
-
class="backdrop-blur-none"
87
-
onclick={() => {
88
-
newCard('section');
89
-
}}
90
-
>
91
-
<svg
92
-
xmlns="http://www.w3.org/2000/svg"
93
-
viewBox="0 0 24 24"
94
-
fill="none"
95
-
stroke="currentColor"
96
-
stroke-width="2"
97
-
stroke-linecap="round"
98
-
stroke-linejoin="round"
99
-
><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg
100
-
>
101
-
</Button>
102
-
103
-
<Button
104
-
size="iconLg"
105
-
variant="ghost"
106
-
class="backdrop-blur-none"
107
-
onclick={() => {
108
-
newCard('text');
109
-
}}
110
-
>
111
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
112
-
><path
113
-
fill="none"
114
-
stroke="currentColor"
115
-
stroke-linecap="round"
116
-
stroke-linejoin="round"
117
-
stroke-width="2"
118
-
d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392"
119
-
/></svg
120
-
>
121
-
</Button>
122
-
123
-
<Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900">
124
-
{#snippet child({ props })}
125
-
<Button
126
-
size="iconLg"
127
-
variant="ghost"
128
-
class="backdrop-blur-none"
129
-
onclick={() => {
130
-
newCard('link');
131
-
}}
132
-
{...props}
133
-
>
134
-
<svg
135
-
xmlns="http://www.w3.org/2000/svg"
136
-
fill="none"
137
-
viewBox="-2 -2 28 28"
138
-
stroke-width="2"
139
-
stroke="currentColor"
140
-
>
141
-
<path
142
-
stroke-linecap="round"
143
-
stroke-linejoin="round"
144
-
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
145
-
/>
146
-
</svg>
147
-
</Button>
148
-
{/snippet}
149
-
<Input
150
-
spellcheck={false}
151
-
type="url"
152
-
bind:value={linkValue}
153
-
onkeydown={(event) => {
154
-
if (event.code === 'Enter') {
155
-
addLink(linkValue);
156
-
event.preventDefault();
157
-
}
158
-
}}
159
-
placeholder="Enter link"
160
-
/>
161
-
<Button onclick={() => addLink(linkValue)} size="icon"
162
-
><svg
163
-
xmlns="http://www.w3.org/2000/svg"
164
-
fill="none"
165
-
viewBox="0 0 24 24"
166
-
stroke-width="2"
167
-
stroke="currentColor"
168
-
class="size-6"
169
-
>
170
-
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
171
-
</svg>
172
-
</Button>
173
-
</Popover>
174
-
175
-
<Button
176
-
size="iconLg"
177
-
variant="ghost"
178
-
class="backdrop-blur-none"
179
-
onclick={() => {
180
-
imageInputRef?.click();
181
-
}}
182
-
>
183
-
<svg
184
-
xmlns="http://www.w3.org/2000/svg"
185
-
fill="none"
186
-
viewBox="0 0 24 24"
187
-
stroke-width="2"
188
-
stroke="currentColor"
189
-
>
190
-
<path
191
-
stroke-linecap="round"
192
-
stroke-linejoin="round"
193
-
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
194
-
/>
195
-
</svg>
196
-
</Button>
197
-
198
-
{#if dev}
199
-
<Button
200
-
size="iconLg"
201
-
variant="ghost"
202
-
class="backdrop-blur-none"
203
-
onclick={() => {
204
-
videoInputRef?.click();
205
-
}}
206
-
>
207
-
<svg
208
-
xmlns="http://www.w3.org/2000/svg"
209
-
fill="none"
210
-
viewBox="0 0 24 24"
211
-
stroke-width="1.5"
212
-
stroke="currentColor"
213
-
>
214
-
<path
215
-
stroke-linecap="round"
216
-
stroke-linejoin="round"
217
-
d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"
218
-
/>
219
-
</svg>
220
-
</Button>
221
-
{/if}
222
-
223
-
<Button size="iconLg" variant="ghost" class="backdrop-blur-none" popovertarget="mobile-menu">
224
<svg
225
xmlns="http://www.w3.org/2000/svg"
226
fill="none"
···
2
import { dev } from '$app/environment';
3
import { user } from '$lib/atproto';
4
import type { WebsiteData } from '$lib/types';
5
+
import { Button, Navbar, Toggle, toast } from '@foxui/core';
6
7
let {
8
data,
0
0
0
9
10
showingMobileView = $bindable(),
11
isSaving = $bindable(),
···
13
14
save,
15
16
+
showCardCommand
0
17
}: {
18
data: WebsiteData;
0
0
0
19
20
showingMobileView: boolean;
21
···
24
25
save: () => Promise<void>;
26
27
+
showCardCommand: () => void;
0
28
} = $props();
0
0
0
0
0
29
30
function getShareUrl() {
31
const base = typeof window !== 'undefined' ? window.location.origin : '';
···
41
}
42
</script>
43
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
44
{#if dev || (user.isLoggedIn && user.profile?.did === data.did)}
45
<Navbar
46
class={[
···
49
]}
50
>
51
<div class="flex items-center gap-2">
52
+
<Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
53
<svg
54
xmlns="http://www.w3.org/2000/svg"
55
fill="none"
+58
-13
src/lib/website/EditableWebsite.svelte
···
24
import EditingCard from '../cards/Card/EditingCard.svelte';
25
import { AllCardDefinitions, CardDefinitionsByType } from '../cards';
26
import { tick, type Component } from 'svelte';
27
-
import type { CreationModalComponentProps } from '../cards/types';
28
import { dev } from '$app/environment';
29
import { setIsMobile } from './context';
30
import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte';
···
38
import { user } from '$lib/atproto';
39
import { launchConfetti } from '@foxui/visual';
40
import Controls from './Controls.svelte';
0
41
42
let {
43
data
···
362
return { x: gridX, y: gridY, swapWithId, placement };
363
}
364
365
-
let linkValue = $state('');
366
-
367
-
function addLink(url: string) {
368
let link = validateLink(url);
369
if (!link) {
370
toast.error('invalid link');
···
372
}
373
let item = createEmptyCard(data.page);
374
0
0
0
0
0
0
0
0
375
for (const cardDef of AllCardDefinitions.toSorted(
376
(a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
377
)) {
···
384
break;
385
}
386
}
387
-
388
-
if (linkValue === url) {
389
-
linkValue = '';
390
-
}
391
}
392
393
function getImageDimensions(src: string): Promise<{ width: number; height: number }> {
···
645
}
646
647
// $inspect(items);
0
0
648
</script>
649
650
<svelte:body
···
684
</div>
685
{/if}
686
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
687
<Controls bind:data />
688
689
{#if showingMobileView}
···
900
</div>
901
</Sidebar>
902
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
903
<EditBar
904
{data}
905
-
bind:linkValue
906
bind:isSaving
907
bind:showingMobileView
908
{hasUnsavedChanges}
909
-
{newCard}
910
-
{addLink}
911
{save}
912
-
{handleImageInputChange}
913
-
{handleVideoInputChange}
0
914
/>
915
916
<Toaster />
···
24
import EditingCard from '../cards/Card/EditingCard.svelte';
25
import { AllCardDefinitions, CardDefinitionsByType } from '../cards';
26
import { tick, type Component } from 'svelte';
27
+
import type { CardDefinition, CreationModalComponentProps } from '../cards/types';
28
import { dev } from '$app/environment';
29
import { setIsMobile } from './context';
30
import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte';
···
38
import { user } from '$lib/atproto';
39
import { launchConfetti } from '@foxui/visual';
40
import Controls from './Controls.svelte';
41
+
import CardCommand from '$lib/components/card-command/CardCommand.svelte';
42
43
let {
44
data
···
363
return { x: gridX, y: gridY, swapWithId, placement };
364
}
365
366
+
function addLink(url: string, specificCardDef?: CardDefinition) {
0
0
367
let link = validateLink(url);
368
if (!link) {
369
toast.error('invalid link');
···
371
}
372
let item = createEmptyCard(data.page);
373
374
+
if (specificCardDef?.onUrlHandler?.(link, item)) {
375
+
item.cardType = specificCardDef.type;
376
+
newItem.item = item;
377
+
saveNewItem();
378
+
toast(specificCardDef.name + ' added!');
379
+
return;
380
+
}
381
+
382
for (const cardDef of AllCardDefinitions.toSorted(
383
(a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
384
)) {
···
391
break;
392
}
393
}
0
0
0
0
394
}
395
396
function getImageDimensions(src: string): Promise<{ width: number; height: number }> {
···
648
}
649
650
// $inspect(items);
651
+
652
+
let showCardCommand = $state(true);
653
</script>
654
655
<svelte:body
···
689
</div>
690
{/if}
691
692
+
<CardCommand
693
+
bind:open={showCardCommand}
694
+
onselect={(cardDef: CardDefinition) => {
695
+
if (cardDef.type === 'image') {
696
+
const input = document.getElementById('image-input') as HTMLInputElement;
697
+
if (input) {
698
+
input.click();
699
+
return;
700
+
}
701
+
} else if (cardDef.type === 'video') {
702
+
const input = document.getElementById('video-input') as HTMLInputElement;
703
+
if (input) {
704
+
input.click();
705
+
return;
706
+
}
707
+
} else {
708
+
newCard(cardDef.type);
709
+
}
710
+
}}
711
+
onlink={(url, cardDef) => {
712
+
addLink(url, cardDef);
713
+
}}
714
+
/>
715
+
716
<Controls bind:data />
717
718
{#if showingMobileView}
···
929
</div>
930
</Sidebar>
931
932
+
<input
933
+
type="file"
934
+
accept="image/*"
935
+
onchange={handleImageInputChange}
936
+
class="hidden"
937
+
id="image-input"
938
+
multiple
939
+
/>
940
+
941
+
<input
942
+
type="file"
943
+
accept="video/*"
944
+
onchange={handleVideoInputChange}
945
+
class="hidden"
946
+
id="video-input"
947
+
multiple
948
+
/>
949
+
950
<EditBar
951
{data}
0
952
bind:isSaving
953
bind:showingMobileView
954
{hasUnsavedChanges}
0
0
955
{save}
956
+
showCardCommand={() => {
957
+
showCardCommand = true;
958
+
}}
959
/>
960
961
<Toaster />