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
3
@plugin '@tailwindcss/forms';
4
4
@plugin '@tailwindcss/typography';
5
5
6
6
+
@plugin 'tailwindcss-animate';
7
7
+
6
8
@source '../node_modules/@foxui';
7
9
8
10
@custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
+19
-5
src/lib/cards/ATProtoCollectionsCard/ATProtoCollectionsCard.svelte
···
39
39
<Badge size="md" class="accent:text-accent-950">{collections.length}</Badge>
40
40
{/if}
41
41
</div>
42
42
-
<div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4">
43
43
-
{#each collections ?? [] as collection (collection)}
44
44
-
<Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button>
45
45
-
{/each}
46
46
-
</div>
42
42
+
{#if collections && collections.length > 0}
43
43
+
<div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4">
44
44
+
{#each collections as collection (collection)}
45
45
+
<Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button>
46
46
+
{/each}
47
47
+
</div>
48
48
+
{:else if collections}
49
49
+
<div
50
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
51
+
>
52
52
+
No collections found.
53
53
+
</div>
54
54
+
{:else}
55
55
+
<div
56
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
57
+
>
58
58
+
Loading collections...
59
59
+
</div>
60
60
+
{/if}
47
61
</div>
+6
-1
src/lib/cards/ATProtoCollectionsCard/index.ts
···
19
19
item.w = 4;
20
20
item.mobileW = 8;
21
21
},
22
22
-
sidebarButtonText: 'Atmosphere Collections'
22
22
+
sidebarButtonText: 'Atmosphere Collections',
23
23
+
24
24
+
name: 'ATProto Collections',
25
25
+
26
26
+
groups: ['Social'],
27
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>`
23
28
} as CardDefinition & { type: 'atprotocollections' };
+4
-1
src/lib/cards/BigSocialCard/index.ts
···
51
51
return item;
52
52
},
53
53
urlHandlerPriority: 1,
54
54
-
canHaveLabel: true
54
54
+
canHaveLabel: true,
55
55
+
56
56
+
groups: ['Social'],
57
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>`
55
58
} as CardDefinition & { type: 'bigsocial' };
56
59
57
60
import {
+6
-1
src/lib/cards/BlueskyMediaCard/index.ts
···
8
8
createNew: () => {},
9
9
creationModalComponent: CreateBlueskyMediaCardModal,
10
10
sidebarButtonText: 'Bluesky Media',
11
11
-
canHaveLabel: true
11
11
+
canHaveLabel: true,
12
12
+
13
13
+
groups: ['Media'],
14
14
+
15
15
+
name: 'Video/Image from Bluesky',
16
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>`
12
17
} as CardDefinition & { type: 'blueskyMedia' };
+4
-1
src/lib/cards/BlueskyPostCard/index.ts
···
63
63
return postsMap;
64
64
},
65
65
minW: 4,
66
66
-
name: 'Bluesky Post'
66
66
+
name: 'Bluesky Post',
67
67
+
68
68
+
groups: ['Social'],
69
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>`
67
70
} as CardDefinition & { type: 'blueskyPost' };
+5
-1
src/lib/cards/ButtonCard/index.ts
···
27
27
minW: 2,
28
28
minH: 1,
29
29
maxW: 8,
30
30
-
maxH: 4
30
30
+
maxH: 4,
31
31
+
32
32
+
groups: ['Utilities'],
33
33
+
name: 'Button',
34
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>`
31
35
};
+87
src/lib/cards/ClockCard/ClockCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte';
3
3
+
import type { ContentComponentProps } from '../types';
4
4
+
import type { ClockCardData } from './index';
5
5
+
import { onMount } from 'svelte';
6
6
+
7
7
+
let { item }: ContentComponentProps = $props();
8
8
+
9
9
+
let cardData = $derived(item.cardData as ClockCardData);
10
10
+
11
11
+
let now = $state(new Date());
12
12
+
13
13
+
onMount(() => {
14
14
+
const interval = setInterval(() => {
15
15
+
now = new Date();
16
16
+
}, 1000);
17
17
+
return () => clearInterval(interval);
18
18
+
});
19
19
+
20
20
+
let clockParts = $derived.by(() => {
21
21
+
try {
22
22
+
return new Intl.DateTimeFormat('en-US', {
23
23
+
timeZone: cardData.timezone || 'UTC',
24
24
+
hour: '2-digit',
25
25
+
minute: '2-digit',
26
26
+
second: '2-digit',
27
27
+
hour12: false
28
28
+
}).formatToParts(now);
29
29
+
} catch {
30
30
+
return null;
31
31
+
}
32
32
+
});
33
33
+
34
34
+
let clockHours = $derived(
35
35
+
clockParts ? parseInt(clockParts.find((p) => p.type === 'hour')?.value || '0') : 0
36
36
+
);
37
37
+
let clockMinutes = $derived(
38
38
+
clockParts ? parseInt(clockParts.find((p) => p.type === 'minute')?.value || '0') : 0
39
39
+
);
40
40
+
let clockSeconds = $derived(
41
41
+
clockParts ? parseInt(clockParts.find((p) => p.type === 'second')?.value || '0') : 0
42
42
+
);
43
43
+
44
44
+
let timezoneDisplay = $derived.by(() => {
45
45
+
if (!cardData.timezone) return '';
46
46
+
try {
47
47
+
const formatter = new Intl.DateTimeFormat('en-US', {
48
48
+
timeZone: cardData.timezone,
49
49
+
timeZoneName: 'short'
50
50
+
});
51
51
+
const parts = formatter.formatToParts(now);
52
52
+
return parts.find((p) => p.type === 'timeZoneName')?.value || cardData.timezone;
53
53
+
} catch {
54
54
+
return cardData.timezone;
55
55
+
}
56
56
+
});
57
57
+
</script>
58
58
+
59
59
+
<div class="@container flex h-full w-full flex-col items-center justify-center p-4">
60
60
+
<NumberFlowGroup>
61
61
+
<div
62
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
63
+
style="font-variant-numeric: tabular-nums;"
64
64
+
>
65
65
+
<NumberFlow value={clockHours} format={{ minimumIntegerDigits: 2 }} />
66
66
+
<span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span>
67
67
+
<NumberFlow
68
68
+
value={clockMinutes}
69
69
+
format={{ minimumIntegerDigits: 2 }}
70
70
+
digits={{ 1: { max: 5 } }}
71
71
+
trend={1}
72
72
+
/>
73
73
+
<span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span>
74
74
+
<NumberFlow
75
75
+
value={clockSeconds}
76
76
+
format={{ minimumIntegerDigits: 2 }}
77
77
+
digits={{ 1: { max: 5 } }}
78
78
+
trend={1}
79
79
+
/>
80
80
+
</div>
81
81
+
</NumberFlowGroup>
82
82
+
{#if timezoneDisplay}
83
83
+
<div class="text-base-500 dark:text-base-400 accent:text-base-600 mt-1 text-xs @sm:text-sm">
84
84
+
{timezoneDisplay}
85
85
+
</div>
86
86
+
{/if}
87
87
+
</div>
+74
src/lib/cards/ClockCard/ClockCardSettings.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { Item } from '$lib/types';
3
3
+
import { Button, Label } from '@foxui/core';
4
4
+
import type { ClockCardData } from './index';
5
5
+
import { onMount } from 'svelte';
6
6
+
7
7
+
let { item }: { item: Item; onclose: () => void } = $props();
8
8
+
9
9
+
let cardData = $derived(item.cardData as ClockCardData);
10
10
+
11
11
+
const timezoneOptions = [
12
12
+
{ value: 'Pacific/Midway', label: 'UTC-11 (Midway)' },
13
13
+
{ value: 'Pacific/Honolulu', label: 'UTC-10 (Honolulu)' },
14
14
+
{ value: 'America/Anchorage', label: 'UTC-9 (Anchorage)' },
15
15
+
{ value: 'America/Los_Angeles', label: 'UTC-8 (Los Angeles)' },
16
16
+
{ value: 'America/Denver', label: 'UTC-7 (Denver)' },
17
17
+
{ value: 'America/Chicago', label: 'UTC-6 (Chicago)' },
18
18
+
{ value: 'America/New_York', label: 'UTC-5 (New York)' },
19
19
+
{ value: 'America/Halifax', label: 'UTC-4 (Halifax)' },
20
20
+
{ value: 'America/Sao_Paulo', label: 'UTC-3 (São Paulo)' },
21
21
+
{ value: 'Atlantic/South_Georgia', label: 'UTC-2 (South Georgia)' },
22
22
+
{ value: 'Atlantic/Azores', label: 'UTC-1 (Azores)' },
23
23
+
{ value: 'UTC', label: 'UTC+0 (London)' },
24
24
+
{ value: 'Europe/Paris', label: 'UTC+1 (Paris)' },
25
25
+
{ value: 'Europe/Helsinki', label: 'UTC+2 (Helsinki)' },
26
26
+
{ value: 'Europe/Moscow', label: 'UTC+3 (Moscow)' },
27
27
+
{ value: 'Asia/Dubai', label: 'UTC+4 (Dubai)' },
28
28
+
{ value: 'Asia/Karachi', label: 'UTC+5 (Karachi)' },
29
29
+
{ value: 'Asia/Kolkata', label: 'UTC+5:30 (Mumbai)' },
30
30
+
{ value: 'Asia/Dhaka', label: 'UTC+6 (Dhaka)' },
31
31
+
{ value: 'Asia/Bangkok', label: 'UTC+7 (Bangkok)' },
32
32
+
{ value: 'Asia/Shanghai', label: 'UTC+8 (Shanghai)' },
33
33
+
{ value: 'Asia/Tokyo', label: 'UTC+9 (Tokyo)' },
34
34
+
{ value: 'Australia/Sydney', label: 'UTC+10 (Sydney)' },
35
35
+
{ value: 'Pacific/Noumea', label: 'UTC+11 (Noumea)' },
36
36
+
{ value: 'Pacific/Auckland', label: 'UTC+12 (Auckland)' }
37
37
+
];
38
38
+
39
39
+
onMount(() => {
40
40
+
if (!cardData.timezone) {
41
41
+
try {
42
42
+
item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
43
43
+
} catch {
44
44
+
item.cardData.timezone = 'UTC';
45
45
+
}
46
46
+
}
47
47
+
});
48
48
+
49
49
+
function useLocalTimezone() {
50
50
+
try {
51
51
+
item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
52
52
+
} catch {
53
53
+
item.cardData.timezone = 'UTC';
54
54
+
}
55
55
+
}
56
56
+
</script>
57
57
+
58
58
+
<div class="flex flex-col gap-4">
59
59
+
<div class="flex flex-col gap-2">
60
60
+
<Label>Timezone</Label>
61
61
+
<div class="flex gap-2">
62
62
+
<select
63
63
+
value={cardData.timezone || 'UTC'}
64
64
+
onchange={(e) => (item.cardData.timezone = e.currentTarget.value)}
65
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
66
+
>
67
67
+
{#each timezoneOptions as tz (tz.value)}
68
68
+
<option value={tz.value}>{tz.label}</option>
69
69
+
{/each}
70
70
+
</select>
71
71
+
<Button size="sm" variant="ghost" onclick={useLocalTimezone}>Local</Button>
72
72
+
</div>
73
73
+
</div>
74
74
+
</div>
+31
src/lib/cards/ClockCard/index.ts
···
1
1
+
import type { CardDefinition } from '../types';
2
2
+
import ClockCard from './ClockCard.svelte';
3
3
+
import ClockCardSettings from './ClockCardSettings.svelte';
4
4
+
5
5
+
export type ClockCardData = {
6
6
+
timezone?: string;
7
7
+
};
8
8
+
9
9
+
export const ClockCardDefinition = {
10
10
+
type: 'clock',
11
11
+
contentComponent: ClockCard,
12
12
+
settingsComponent: ClockCardSettings,
13
13
+
14
14
+
createNew: (card) => {
15
15
+
card.w = 4;
16
16
+
card.h = 2;
17
17
+
card.mobileW = 8;
18
18
+
card.mobileH = 3;
19
19
+
card.cardData = {
20
20
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
21
21
+
} as ClockCardData;
22
22
+
},
23
23
+
24
24
+
allowSetColor: true,
25
25
+
name: 'Clock',
26
26
+
minW: 4,
27
27
+
canHaveLabel: true,
28
28
+
groups: ['Utilities'],
29
29
+
keywords: ['time', 'timezone', 'watch'],
30
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
31
+
} as CardDefinition & { type: 'clock' };
+185
src/lib/cards/CountdownCard/CountdownCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte';
3
3
+
import type { ContentComponentProps } from '../types';
4
4
+
import type { CountdownCardData } from './index';
5
5
+
import { onMount } from 'svelte';
6
6
+
7
7
+
let { item }: ContentComponentProps = $props();
8
8
+
9
9
+
let cardData = $derived(item.cardData as CountdownCardData);
10
10
+
11
11
+
let now = $state(new Date());
12
12
+
13
13
+
onMount(() => {
14
14
+
const interval = setInterval(() => {
15
15
+
now = new Date();
16
16
+
}, 1000);
17
17
+
return () => clearInterval(interval);
18
18
+
});
19
19
+
20
20
+
// Countdown to target date
21
21
+
let eventDiff = $derived.by(() => {
22
22
+
if (!cardData.targetDate) return null;
23
23
+
const target = new Date(cardData.targetDate);
24
24
+
return Math.max(0, target.getTime() - now.getTime());
25
25
+
});
26
26
+
27
27
+
let eventDays = $derived(eventDiff !== null ? Math.floor(eventDiff / (1000 * 60 * 60 * 24)) : 0);
28
28
+
let eventHours = $derived(
29
29
+
eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0
30
30
+
);
31
31
+
let eventMinutes = $derived(
32
32
+
eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0
33
33
+
);
34
34
+
let eventSeconds = $derived(
35
35
+
eventDiff !== null ? Math.floor((eventDiff % (1000 * 60)) / 1000) : 0
36
36
+
);
37
37
+
38
38
+
// Check if event is in the past (elapsed mode)
39
39
+
let isEventPast = $derived.by(() => {
40
40
+
if (!cardData.targetDate) return false;
41
41
+
return now.getTime() > new Date(cardData.targetDate).getTime();
42
42
+
});
43
43
+
44
44
+
// Elapsed time since past event
45
45
+
let elapsedDiff = $derived.by(() => {
46
46
+
if (!isEventPast || !cardData.targetDate) return null;
47
47
+
return now.getTime() - new Date(cardData.targetDate).getTime();
48
48
+
});
49
49
+
50
50
+
let elapsedYears = $derived(
51
51
+
elapsedDiff !== null ? Math.floor(elapsedDiff / (1000 * 60 * 60 * 24 * 365)) : 0
52
52
+
);
53
53
+
let elapsedDays = $derived(
54
54
+
elapsedDiff !== null
55
55
+
? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24))
56
56
+
: 0
57
57
+
);
58
58
+
let elapsedHours = $derived(
59
59
+
elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0
60
60
+
);
61
61
+
let elapsedMinutes = $derived(
62
62
+
elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0
63
63
+
);
64
64
+
let elapsedSeconds = $derived(
65
65
+
elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60)) / 1000) : 0
66
66
+
);
67
67
+
</script>
68
68
+
69
69
+
<div class="@container flex h-full w-full flex-col items-center justify-center p-4">
70
70
+
{#if isEventPast && elapsedDiff !== null}
71
71
+
<!-- Elapsed time since past event -->
72
72
+
<NumberFlowGroup>
73
73
+
<div
74
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
75
+
style="font-variant-numeric: tabular-nums;"
76
76
+
>
77
77
+
{#if elapsedYears > 0}
78
78
+
<div class="flex flex-col items-center">
79
79
+
<NumberFlow
80
80
+
value={elapsedYears}
81
81
+
trend={1}
82
82
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
83
83
+
/>
84
84
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs"
85
85
+
>{elapsedYears === 1 ? 'year' : 'years'}</span
86
86
+
>
87
87
+
</div>
88
88
+
{/if}
89
89
+
{#if elapsedYears > 0 || elapsedDays > 0}
90
90
+
<div class="flex flex-col items-center">
91
91
+
<NumberFlow
92
92
+
value={elapsedDays}
93
93
+
trend={1}
94
94
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
95
95
+
/>
96
96
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs"
97
97
+
>{elapsedDays === 1 ? 'day' : 'days'}</span
98
98
+
>
99
99
+
</div>
100
100
+
{/if}
101
101
+
<div class="flex flex-col items-center">
102
102
+
<NumberFlow
103
103
+
value={elapsedHours}
104
104
+
trend={1}
105
105
+
format={{ minimumIntegerDigits: 2 }}
106
106
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
107
107
+
/>
108
108
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span>
109
109
+
</div>
110
110
+
<div class="flex flex-col items-center">
111
111
+
<NumberFlow
112
112
+
value={elapsedMinutes}
113
113
+
trend={1}
114
114
+
format={{ minimumIntegerDigits: 2 }}
115
115
+
digits={{ 1: { max: 5 } }}
116
116
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
117
117
+
/>
118
118
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span>
119
119
+
</div>
120
120
+
<div class="flex flex-col items-center">
121
121
+
<NumberFlow
122
122
+
value={elapsedSeconds}
123
123
+
trend={1}
124
124
+
format={{ minimumIntegerDigits: 2 }}
125
125
+
digits={{ 1: { max: 5 } }}
126
126
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
127
127
+
/>
128
128
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span>
129
129
+
</div>
130
130
+
</div>
131
131
+
</NumberFlowGroup>
132
132
+
{:else if eventDiff !== null}
133
133
+
<!-- Countdown to future event -->
134
134
+
<NumberFlowGroup>
135
135
+
<div
136
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
137
+
style="font-variant-numeric: tabular-nums;"
138
138
+
>
139
139
+
{#if eventDays > 0}
140
140
+
<div class="flex flex-col items-center">
141
141
+
<NumberFlow
142
142
+
value={eventDays}
143
143
+
trend={-1}
144
144
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
145
145
+
/>
146
146
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs"
147
147
+
>{eventDays === 1 ? 'day' : 'days'}</span
148
148
+
>
149
149
+
</div>
150
150
+
{/if}
151
151
+
<div class="flex flex-col items-center">
152
152
+
<NumberFlow
153
153
+
value={eventHours}
154
154
+
trend={-1}
155
155
+
format={{ minimumIntegerDigits: 2 }}
156
156
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
157
157
+
/>
158
158
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span>
159
159
+
</div>
160
160
+
<div class="flex flex-col items-center">
161
161
+
<NumberFlow
162
162
+
value={eventMinutes}
163
163
+
trend={-1}
164
164
+
format={{ minimumIntegerDigits: 2 }}
165
165
+
digits={{ 1: { max: 5 } }}
166
166
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
167
167
+
/>
168
168
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span>
169
169
+
</div>
170
170
+
<div class="flex flex-col items-center">
171
171
+
<NumberFlow
172
172
+
value={eventSeconds}
173
173
+
trend={-1}
174
174
+
format={{ minimumIntegerDigits: 2 }}
175
175
+
digits={{ 1: { max: 5 } }}
176
176
+
class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl"
177
177
+
/>
178
178
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span>
179
179
+
</div>
180
180
+
</div>
181
181
+
</NumberFlowGroup>
182
182
+
{:else}
183
183
+
<div class="text-base-500 text-sm">Set a target date in settings</div>
184
184
+
{/if}
185
185
+
</div>
+44
src/lib/cards/CountdownCard/CountdownCardSettings.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { Item } from '$lib/types';
3
3
+
import { Input, Label } from '@foxui/core';
4
4
+
import type { CountdownCardData } from './index';
5
5
+
6
6
+
let { item }: { item: Item; onclose: () => void } = $props();
7
7
+
8
8
+
let cardData = $derived(item.cardData as CountdownCardData);
9
9
+
10
10
+
let targetDateValue = $derived.by(() => {
11
11
+
if (!cardData.targetDate) return '';
12
12
+
return new Date(cardData.targetDate).toISOString().split('T')[0];
13
13
+
});
14
14
+
15
15
+
let targetTimeValue = $derived.by(() => {
16
16
+
if (!cardData.targetDate) return '12:00';
17
17
+
return new Date(cardData.targetDate).toTimeString().slice(0, 5);
18
18
+
});
19
19
+
20
20
+
function updateTargetDate(dateStr: string, timeStr: string) {
21
21
+
if (!dateStr) return;
22
22
+
item.cardData.targetDate = new Date(`${dateStr}T${timeStr}`).toISOString();
23
23
+
}
24
24
+
</script>
25
25
+
26
26
+
<div class="flex flex-col gap-4">
27
27
+
<div class="flex flex-col gap-2">
28
28
+
<Label>Target Date & Time</Label>
29
29
+
<div class="flex gap-2">
30
30
+
<Input
31
31
+
type="date"
32
32
+
value={targetDateValue}
33
33
+
onchange={(e) => updateTargetDate(e.currentTarget.value, targetTimeValue)}
34
34
+
class="flex-1"
35
35
+
/>
36
36
+
<Input
37
37
+
type="time"
38
38
+
value={targetTimeValue}
39
39
+
onchange={(e) => updateTargetDate(targetDateValue, e.currentTarget.value)}
40
40
+
class="w-28"
41
41
+
/>
42
42
+
</div>
43
43
+
</div>
44
44
+
</div>
+29
src/lib/cards/CountdownCard/index.ts
···
1
1
+
import type { CardDefinition } from '../types';
2
2
+
import CountdownCard from './CountdownCard.svelte';
3
3
+
import CountdownCardSettings from './CountdownCardSettings.svelte';
4
4
+
5
5
+
export type CountdownCardData = {
6
6
+
targetDate?: string;
7
7
+
};
8
8
+
9
9
+
export const CountdownCardDefinition = {
10
10
+
type: 'countdown',
11
11
+
contentComponent: CountdownCard,
12
12
+
settingsComponent: CountdownCardSettings,
13
13
+
14
14
+
createNew: (card) => {
15
15
+
card.w = 4;
16
16
+
card.h = 2;
17
17
+
card.mobileW = 8;
18
18
+
card.mobileH = 3;
19
19
+
card.cardData = {} as CountdownCardData;
20
20
+
},
21
21
+
22
22
+
allowSetColor: true,
23
23
+
name: 'Countdown',
24
24
+
minW: 4,
25
25
+
canHaveLabel: true,
26
26
+
groups: ['Utilities'],
27
27
+
keywords: ['timer', 'event', 'date', 'countdown'],
28
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
29
+
} as CardDefinition & { type: 'countdown' };
+4
-1
src/lib/cards/DrawCard/index.ts
···
23
23
strokeWidth: 1,
24
24
locked: true
25
25
};
26
26
-
}
26
26
+
},
27
27
+
28
28
+
groups: ['Visual'],
29
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>`
27
30
} as CardDefinition & { type: 'draw' };
+3
-1
src/lib/cards/EmbedCard/index.ts
···
19
19
// change: (item) => {
20
20
// return item;
21
21
// },
22
22
-
name: 'Embed Card'
22
22
+
name: 'Embed',
23
23
+
groups: ['Media'],
24
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>`
23
25
} as CardDefinition & { type: 'embed' };
+4
-1
src/lib/cards/EventCard/index.ts
···
112
112
113
113
urlHandlerPriority: 5,
114
114
115
115
-
name: 'Event Card'
115
115
+
name: 'Event',
116
116
+
117
117
+
groups: ['Social'],
118
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>`
116
119
} as CardDefinition & { type: 'event' };
+5
-1
src/lib/cards/FluidTextCard/index.ts
···
23
23
sidebarButtonText: 'Fluid Text',
24
24
defaultColor: 'transparent',
25
25
allowSetColor: true,
26
26
-
minW: 2
26
26
+
minW: 2,
27
27
+
28
28
+
groups: ['Visual'],
29
29
+
name: 'Fluid Text',
30
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>`
27
31
} as CardDefinition & { type: 'fluid-text' };
+4
-1
src/lib/cards/GIFCard/index.ts
···
45
45
return null;
46
46
},
47
47
urlHandlerPriority: 5,
48
48
-
name: 'GIF'
48
48
+
name: 'GIF',
49
49
+
50
50
+
groups: ['Media'],
51
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>`
49
52
} as CardDefinition & { type: 'gif' };
+5
-1
src/lib/cards/GameCards/DinoGameCard/index.ts
···
14
14
card.mobileH = 6;
15
15
card.cardData = {};
16
16
},
17
17
-
canHaveLabel: true
17
17
+
canHaveLabel: true,
18
18
+
19
19
+
groups: ['Games'],
20
20
+
name: 'Dino Game',
21
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>`
18
22
} as CardDefinition & { type: 'dino-game' };
+6
-1
src/lib/cards/GameCards/TetrisCard/index.ts
···
19
19
card.cardData = {};
20
20
},
21
21
maxH: 10,
22
22
-
canHaveLabel: true
22
22
+
canHaveLabel: true,
23
23
+
24
24
+
groups: ['Games'],
25
25
+
26
26
+
name: 'Tetris',
27
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>`
23
28
} as CardDefinition & { type: 'tetris' };
+70
src/lib/cards/GitHubProfileCard/CreateGitHubProfileCardModal.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Button, Input, Modal, Subheading } from '@foxui/core';
3
3
+
import type { CreationModalComponentProps } from '../types';
4
4
+
5
5
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
6
6
+
7
7
+
let errorMessage = $state('');
8
8
+
</script>
9
9
+
10
10
+
<Modal open={true} closeButton={false}>
11
11
+
<form
12
12
+
onsubmit={() => {
13
13
+
let input = item.cardData.href?.trim();
14
14
+
if (!input) return;
15
15
+
16
16
+
let username: string | undefined;
17
17
+
18
18
+
// Try parsing as URL first
19
19
+
try {
20
20
+
const parsed = new URL(input);
21
21
+
if (/^(www\.)?github\.com$/.test(parsed.hostname)) {
22
22
+
const segments = parsed.pathname.split('/').filter(Boolean);
23
23
+
if (
24
24
+
segments.length === 1 &&
25
25
+
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(segments[0])
26
26
+
) {
27
27
+
username = segments[0];
28
28
+
}
29
29
+
}
30
30
+
} catch {
31
31
+
// Not a URL, try as plain username
32
32
+
if (/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input)) {
33
33
+
username = input;
34
34
+
}
35
35
+
}
36
36
+
37
37
+
if (!username) {
38
38
+
errorMessage = 'Please enter a valid GitHub username or profile URL';
39
39
+
return;
40
40
+
}
41
41
+
42
42
+
item.cardData.user = username;
43
43
+
item.cardData.href = `https://github.com/${username}`;
44
44
+
45
45
+
item.w = 6;
46
46
+
item.mobileW = 8;
47
47
+
item.h = 3;
48
48
+
item.mobileH = 6;
49
49
+
50
50
+
oncreate?.();
51
51
+
}}
52
52
+
class="flex flex-col gap-2"
53
53
+
>
54
54
+
<Subheading>Enter a GitHub username or profile URL</Subheading>
55
55
+
<Input
56
56
+
bind:value={item.cardData.href}
57
57
+
placeholder="username or https://github.com/username"
58
58
+
class="mt-4"
59
59
+
/>
60
60
+
61
61
+
{#if errorMessage}
62
62
+
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
63
63
+
{/if}
64
64
+
65
65
+
<div class="mt-4 flex justify-end gap-2">
66
66
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
67
67
+
<Button type="submit">Create</Button>
68
68
+
</div>
69
69
+
</form>
70
70
+
</Modal>
+6
-1
src/lib/cards/GitHubProfileCard/index.ts
···
1
1
import type { CardDefinition } from '../types';
2
2
+
import CreateGitHubProfileCardModal from './CreateGitHubProfileCardModal.svelte';
2
3
import type GithubContributionsGraph from './GithubContributionsGraph.svelte';
3
4
import GitHubProfileCard from './GitHubProfileCard.svelte';
4
5
import type { GitHubContributionsData } from './types';
···
8
9
export const GithubProfileCardDefitition = {
9
10
type: 'githubProfile',
10
11
contentComponent: GitHubProfileCard,
12
12
+
creationModalComponent: CreateGitHubProfileCardModal,
11
13
12
14
loadData: async (items) => {
13
15
const githubData: Record<string, GithubContributionsGraph> = {};
···
50
52
51
53
return item;
52
54
},
53
53
-
name: 'Github Profile'
55
55
+
name: 'Github Profile',
56
56
+
57
57
+
groups: ['Social'],
58
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>`
54
59
} as CardDefinition & { type: 'githubProfile' };
55
60
56
61
function getGitHubUsername(url: string | undefined): string | undefined {
+3
-1
src/lib/cards/GuestbookCard/index.ts
···
60
60
61
61
return results;
62
62
},
63
63
-
name: 'Guestbook'
63
63
+
name: 'Guestbook',
64
64
+
groups: ['Social'],
65
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>`
64
66
} as CardDefinition & { type: 'guestbook' };
+19
-2
src/lib/cards/ImageCard/index.ts
···
42
42
},
43
43
urlHandlerPriority: 3,
44
44
45
45
-
name: 'Image Card',
45
45
+
name: 'Image',
46
46
+
47
47
+
canHaveLabel: true,
48
48
+
49
49
+
groups: ['Core'],
46
50
47
47
-
canHaveLabel: true
51
51
+
icon: `<svg
52
52
+
xmlns="http://www.w3.org/2000/svg"
53
53
+
fill="none"
54
54
+
viewBox="0 0 24 24"
55
55
+
stroke-width="2"
56
56
+
stroke="currentColor"
57
57
+
class="size-4"
58
58
+
>
59
59
+
<path
60
60
+
stroke-linecap="round"
61
61
+
stroke-linejoin="round"
62
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
63
+
/>
64
64
+
</svg>`
48
65
} as CardDefinition & { type: 'image' };
+6
-1
src/lib/cards/LatestBlueskyPostCard/index.ts
···
18
18
19
19
return JSON.parse(JSON.stringify(authorFeed));
20
20
},
21
21
-
minW: 4
21
21
+
minW: 4,
22
22
+
23
23
+
name: 'Latest Bluesky Post',
24
24
+
25
25
+
groups: ['Social'],
26
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>`
22
27
} as CardDefinition & { type: 'latestPost' };
+44
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Button, Input, Modal, Subheading } from '@foxui/core';
3
3
+
import type { CreationModalComponentProps } from '../types';
4
4
+
import { validateLink } from '$lib/helper';
5
5
+
6
6
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
7
7
+
8
8
+
let isFetchingLocation = $state(false);
9
9
+
10
10
+
let errorMessage = $state('');
11
11
+
</script>
12
12
+
13
13
+
<Modal open={true} closeButton={false}>
14
14
+
<form
15
15
+
onsubmit={() => {
16
16
+
if (!item.cardData.href.trim()) return;
17
17
+
18
18
+
let link = validateLink(item.cardData.href);
19
19
+
if (!link) {
20
20
+
errorMessage = 'Invalid link';
21
21
+
return;
22
22
+
}
23
23
+
24
24
+
item.cardData.href = link;
25
25
+
item.cardData.domain = new URL(link).hostname;
26
26
+
item.cardData.hasFetched = false;
27
27
+
28
28
+
oncreate?.();
29
29
+
}}
30
30
+
class="flex flex-col gap-2"
31
31
+
>
32
32
+
<Subheading>Enter a link</Subheading>
33
33
+
<Input bind:value={item.cardData.href} class="mt-4" />
34
34
+
35
35
+
{#if errorMessage}
36
36
+
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
37
37
+
{/if}
38
38
+
39
39
+
<div class="mt-4 flex justify-end gap-2">
40
40
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
41
41
+
<Button type="submit" disabled={isFetchingLocation}>Create</Button>
42
42
+
</div>
43
43
+
</form>
44
44
+
</Modal>
+22
-2
src/lib/cards/LinkCard/index.ts
···
1
1
import { checkAndUploadImage, validateLink } from '$lib/helper';
2
2
import type { CardDefinition } from '../types';
3
3
+
import CreateLinkCardModal from './CreateLinkCardModal.svelte';
3
4
import EditingLinkCard from './EditingLinkCard.svelte';
4
5
import LinkCard from './LinkCard.svelte';
5
6
import LinkCardSettings from './LinkCardSettings.svelte';
···
13
14
},
14
15
settingsComponent: LinkCardSettings,
15
16
16
16
-
name: 'Link Card',
17
17
+
creationModalComponent: CreateLinkCardModal,
18
18
+
19
19
+
name: 'Link',
17
20
canChange: (item) => Boolean(validateLink(item.cardData?.href)),
18
21
change: (item) => {
19
22
const href = validateLink(item.cardData?.href);
···
36
39
await checkAndUploadImage(item.cardData, 'favicon');
37
40
return item;
38
41
},
39
39
-
urlHandlerPriority: 0
42
42
+
urlHandlerPriority: 0,
43
43
+
44
44
+
groups: ['Core'],
45
45
+
46
46
+
icon: `<svg
47
47
+
xmlns="http://www.w3.org/2000/svg"
48
48
+
fill="none"
49
49
+
viewBox="-2 -2 28 28"
50
50
+
stroke-width="2"
51
51
+
stroke="currentColor"
52
52
+
class="size-4"
53
53
+
>
54
54
+
<path
55
55
+
stroke-linecap="round"
56
56
+
stroke-linejoin="round"
57
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
58
+
/>
59
59
+
</svg>`
40
60
} as CardDefinition & { type: 'link' };
+3
-1
src/lib/cards/LivestreamCard/index.ts
···
81
81
82
82
urlHandlerPriority: 5,
83
83
84
84
-
name: 'stream.place Card'
84
84
+
name: 'Latest Livestream (stream.place)',
85
85
+
groups: ['Media'],
86
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>`
85
87
} as CardDefinition & { type: 'latestLivestream' };
86
88
87
89
export const LivestreamEmbedCardDefitition = {
+10
-1
src/lib/cards/MapCard/index.ts
···
17
17
creationModalComponent: CreateMapCardModal,
18
18
allowSetColor: false,
19
19
canHaveLabel: true,
20
20
-
settingsComponent: MapCardSettings
20
20
+
settingsComponent: MapCardSettings,
21
21
+
22
22
+
groups: ['Core'],
23
23
+
24
24
+
name: 'Map',
25
25
+
26
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
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
28
+
</svg>
29
29
+
`
21
30
} as CardDefinition & { type: 'mapLocation' };
22
31
23
32
export function getZoomLevel(type: string | undefined): number {
+37
-23
src/lib/cards/PopfeedReviews/PopfeedReviewsCard.svelte
···
30
30
</script>
31
31
32
32
<div class="z-10 flex h-full gap-4 overflow-x-scroll p-4">
33
33
-
{#each feed ?? [] as review (review.uri)}
34
34
-
{#if review.value.rating !== undefined && review.value.posterUrl}
35
35
-
<a
36
36
-
rel="noopener noreferrer"
37
37
-
target="_blank"
38
38
-
class="flex"
39
39
-
href="https://popfeed.social/review/{review.uri}"
40
40
-
>
41
41
-
<div
42
42
-
class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1"
33
33
+
{#if feed && feed.length > 0}
34
34
+
{#each feed as review (review.uri)}
35
35
+
{#if review.value.rating !== undefined && review.value.posterUrl}
36
36
+
<a
37
37
+
rel="noopener noreferrer"
38
38
+
target="_blank"
39
39
+
class="flex"
40
40
+
href="https://popfeed.social/review/{review.uri}"
43
41
>
44
44
-
<img
45
45
-
src={review.value.posterUrl}
46
46
-
alt=""
47
47
-
class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover"
48
48
-
/>
49
49
-
50
42
<div
51
51
-
class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent"
52
52
-
></div>
43
43
+
class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1"
44
44
+
>
45
45
+
<img
46
46
+
src={review.value.posterUrl}
47
47
+
alt=""
48
48
+
class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover"
49
49
+
/>
53
50
54
54
-
<Rating class="z-10 text-lg" rating={review.value.rating} />
55
55
-
</div>
56
56
-
</a>
57
57
-
{/if}
58
58
-
{/each}
51
51
+
<div
52
52
+
class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent"
53
53
+
></div>
54
54
+
55
55
+
<Rating class="z-10 text-lg" rating={review.value.rating} />
56
56
+
</div>
57
57
+
</a>
58
58
+
{/if}
59
59
+
{/each}
60
60
+
{:else if feed}
61
61
+
<div
62
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
63
+
>
64
64
+
No reviews yet.
65
65
+
</div>
66
66
+
{:else}
67
67
+
<div
68
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
69
+
>
70
70
+
Loading reviews...
71
71
+
</div>
72
72
+
{/if}
59
73
</div>
+5
-1
src/lib/cards/PopfeedReviews/index.ts
···
18
18
},
19
19
minH: 3,
20
20
sidebarButtonText: 'Popfeed Reviews',
21
21
-
canHaveLabel: true
21
21
+
canHaveLabel: true,
22
22
+
23
23
+
groups: ['Media'],
24
24
+
name: 'Movie and TV Reviews',
25
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>`
22
26
} as CardDefinition & { type: 'recentPopfeedReviews' };
+16
-1
src/lib/cards/SectionCard/index.ts
···
26
26
defaultColor: 'transparent',
27
27
maxH: 1,
28
28
canResize: false,
29
29
-
settingsComponent: SectionCardSettings
29
29
+
settingsComponent: SectionCardSettings,
30
30
+
31
31
+
name: 'Heading',
32
32
+
groups: ['Core'],
33
33
+
34
34
+
icon: `<svg
35
35
+
xmlns="http://www.w3.org/2000/svg"
36
36
+
viewBox="0 0 24 24"
37
37
+
fill="none"
38
38
+
stroke="currentColor"
39
39
+
stroke-width="2"
40
40
+
stroke-linecap="round"
41
41
+
stroke-linejoin="round"
42
42
+
class="size-4"
43
43
+
><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg
44
44
+
>`
30
45
} as CardDefinition & { type: 'section' };
31
46
32
47
export const textAlignClasses: Record<string, string> = {
+4
-1
src/lib/cards/SpotifyCard/index.ts
···
40
40
name: 'Spotify Embed',
41
41
canResize: true,
42
42
minW: 4,
43
43
-
minH: 5
43
43
+
minH: 5,
44
44
+
45
45
+
groups: ['Media'],
46
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>`
44
47
} as CardDefinition & { type: typeof cardType };
45
48
46
49
// Match Spotify album and playlist URLs
+34
-8
src/lib/cards/StandardSiteDocumentListCard/StandardSiteDocumentListCard.svelte
···
27
27
</script>
28
28
29
29
<div class="flex h-full flex-col gap-10 overflow-y-scroll p-8">
30
30
-
{#each feed ?? [] as document (document.uri)}
31
31
-
<BlogEntry
32
32
-
title={document.value.title}
33
33
-
description={document.value.description}
34
34
-
date={document.value.publishedAt}
35
35
-
href={document.value.href}
36
36
-
/>
37
37
-
{/each}
30
30
+
{#if feed && feed.length > 0}
31
31
+
{#each feed as document (document.uri)}
32
32
+
<BlogEntry
33
33
+
title={document.value.title}
34
34
+
description={document.value.description}
35
35
+
date={document.value.publishedAt}
36
36
+
href={document.value.href}
37
37
+
/>
38
38
+
{/each}
39
39
+
{:else if feed}
40
40
+
<div
41
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
42
+
>
43
43
+
<span>No blog posts found.</span>
44
44
+
<span>
45
45
+
Create some on <a
46
46
+
href="https://leaflet.pub"
47
47
+
target="_blank"
48
48
+
rel="noopener noreferrer"
49
49
+
class="underline">Leaflet</a
50
50
+
>
51
51
+
or
52
52
+
<a href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class="underline"
53
53
+
>Pckt</a
54
54
+
>
55
55
+
</span>
56
56
+
</div>
57
57
+
{:else}
58
58
+
<div
59
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
60
+
>
61
61
+
Loading blog posts...
62
62
+
</div>
63
63
+
{/if}
38
64
</div>
+6
-1
src/lib/cards/StandardSiteDocumentListCard/index.ts
···
42
42
return records;
43
43
},
44
44
45
45
-
sidebarButtonText: 'site.standard.document list'
45
45
+
sidebarButtonText: 'site.standard.document list',
46
46
+
47
47
+
name: 'Blog Posts',
48
48
+
49
49
+
groups: ['Content'],
50
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>`
46
51
} as CardDefinition & { type: 'site.standard.document list' };
+5
-1
src/lib/cards/StatusphereCard/index.ts
···
47
47
item.cardData.label = item.cardData.title;
48
48
}
49
49
},
50
50
-
canHaveLabel: true
50
50
+
canHaveLabel: true,
51
51
+
52
52
+
name: 'Emoji',
53
53
+
groups: ['Media'],
54
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>`
51
55
} as CardDefinition & { type: 'statusphere' };
52
56
53
57
export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+22
-8
src/lib/cards/TealFMPlaysCard/TealFMPlaysCard.svelte
···
85
85
{/snippet}
86
86
87
87
<div class="z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4">
88
88
-
{#each feed ?? [] as play (play.uri)}
89
89
-
{#if play.value.originUrl}
90
90
-
<a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full">
88
88
+
{#if feed && feed.length > 0}
89
89
+
{#each feed as play (play.uri)}
90
90
+
{#if play.value.originUrl}
91
91
+
<a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full">
92
92
+
{@render musicItem(play)}
93
93
+
</a>
94
94
+
{:else}
91
95
{@render musicItem(play)}
92
92
-
</a>
93
93
-
{:else}
94
94
-
{@render musicItem(play)}
95
95
-
{/if}
96
96
-
{/each}
96
96
+
{/if}
97
97
+
{/each}
98
98
+
{:else if feed}
99
99
+
<div
100
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
101
+
>
102
102
+
No recent plays found.
103
103
+
</div>
104
104
+
{:else}
105
105
+
<div
106
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
107
+
>
108
108
+
Loading plays...
109
109
+
</div>
110
110
+
{/if}
97
111
</div>
+6
-1
src/lib/cards/TealFMPlaysCard/index.ts
···
22
22
},
23
23
minW: 4,
24
24
sidebarButtonText: 'teal.fm Plays',
25
25
-
canHaveLabel: true
25
25
+
canHaveLabel: true,
26
26
+
27
27
+
name: 'Teal.fm Plays',
28
28
+
29
29
+
groups: ['Media'],
30
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>`
26
31
} as CardDefinition & { type: 'recentTealFMPlays' };
+16
-1
src/lib/cards/TextCard/index.ts
···
14
14
};
15
15
},
16
16
17
17
-
settingsComponent: TextCardSettings
17
17
+
settingsComponent: TextCardSettings,
18
18
+
19
19
+
name: 'Text',
20
20
+
21
21
+
groups: ['Core'],
22
22
+
23
23
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4"
24
24
+
><path
25
25
+
fill="none"
26
26
+
stroke="currentColor"
27
27
+
stroke-linecap="round"
28
28
+
stroke-linejoin="round"
29
29
+
stroke-width="2"
30
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
31
+
/></svg
32
32
+
>`
18
33
} as CardDefinition & { type: 'text' };
19
34
20
35
export const textAlignClasses: Record<string, string> = {
+15
-3
src/lib/cards/TimerCard/index.ts
···
17
17
type: 'timer',
18
18
contentComponent: TimerCard,
19
19
settingsComponent: TimerCardSettings,
20
20
-
sidebarButtonText: 'Timer',
21
20
22
21
createNew: (card) => {
23
22
card.w = 4;
···
31
30
},
32
31
33
32
allowSetColor: true,
34
34
-
name: 'Timer Card',
35
33
minW: 4,
36
36
-
canHaveLabel: true
34
34
+
canHaveLabel: true,
35
35
+
36
36
+
migrate: (item) => {
37
37
+
const data = item.cardData as TimerCardData;
38
38
+
if (data.mode === 'event') {
39
39
+
item.cardType = 'countdown';
40
40
+
item.cardData = { targetDate: data.targetDate };
41
41
+
} else {
42
42
+
item.cardType = 'clock';
43
43
+
item.cardData = { timezone: data.timezone };
44
44
+
}
45
45
+
if (data.label) {
46
46
+
item.cardData.label = data.label;
47
47
+
}
48
48
+
}
37
49
} as CardDefinition & { type: 'timer' };
+3
-1
src/lib/cards/VCardCard/index.ts
···
122
122
123
123
sidebarButtonText: 'vCard',
124
124
allowSetColor: true,
125
125
-
name: 'vCard Card'
125
125
+
name: 'vCard Card',
126
126
+
groups: ['Social'],
127
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>`
126
128
} as CardDefinition & { type: 'vcard' };
+3
-1
src/lib/cards/VideoCard/index.ts
···
59
59
},
60
60
settingsComponent: VideoCardSettings,
61
61
62
62
-
name: 'Video Card'
62
62
+
name: 'Video',
63
63
+
groups: ['Media'],
64
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>`
63
65
} as CardDefinition & { type: 'video' };
+52
src/lib/cards/YoutubeVideoCard/CreateYoutubeCardModal.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Button, Input, Modal, Subheading } from '@foxui/core';
3
3
+
import type { CreationModalComponentProps } from '../types';
4
4
+
import { matcher } from './index';
5
5
+
6
6
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
7
7
+
8
8
+
let errorMessage = $state('');
9
9
+
</script>
10
10
+
11
11
+
<Modal open={true} closeButton={false}>
12
12
+
<form
13
13
+
onsubmit={() => {
14
14
+
const url = item.cardData.href?.trim();
15
15
+
if (!url) return;
16
16
+
17
17
+
const id = matcher(url);
18
18
+
if (!id) {
19
19
+
errorMessage = 'Please enter a valid YouTube URL';
20
20
+
return;
21
21
+
}
22
22
+
23
23
+
item.cardData.youtubeId = id;
24
24
+
item.cardData.poster = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
25
25
+
item.cardData.showInline = true;
26
26
+
27
27
+
item.w = 4;
28
28
+
item.mobileW = 8;
29
29
+
item.h = 3;
30
30
+
item.mobileH = 5;
31
31
+
32
32
+
oncreate?.();
33
33
+
}}
34
34
+
class="flex flex-col gap-2"
35
35
+
>
36
36
+
<Subheading>Enter a YouTube URL</Subheading>
37
37
+
<Input
38
38
+
bind:value={item.cardData.href}
39
39
+
placeholder="https://youtube.com/watch?v=..."
40
40
+
class="mt-4"
41
41
+
/>
42
42
+
43
43
+
{#if errorMessage}
44
44
+
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
45
45
+
{/if}
46
46
+
47
47
+
<div class="mt-4 flex justify-end gap-2">
48
48
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
49
49
+
<Button type="submit">Create</Button>
50
50
+
</div>
51
51
+
</form>
52
52
+
</Modal>
+12
-1
src/lib/cards/YoutubeVideoCard/index.ts
···
1
1
import type { CardDefinition } from '../types';
2
2
+
import CreateYoutubeCardModal from './CreateYoutubeCardModal.svelte';
2
3
import YoutubeCard from './YoutubeCard.svelte';
3
4
import YoutubeCardSettings from './YoutubeCardSettings.svelte';
4
5
···
6
7
type: 'youtubeVideo',
7
8
contentComponent: YoutubeCard,
8
9
settingsComponent: YoutubeCardSettings,
10
10
+
creationModalComponent: CreateYoutubeCardModal,
9
11
createNew: (card) => {
10
12
card.cardType = 'youtubeVideo';
11
13
card.cardData = {};
···
51
53
52
54
return item;
53
55
},
54
54
-
name: 'Youtube Video'
56
56
+
name: 'Youtube Video',
57
57
+
58
58
+
groups: ['Media'],
59
59
+
60
60
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-3" viewBox="0 0 256 180"
61
61
+
><path
62
62
+
fill="currentColor"
63
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
64
+
/><path fill="currentColor" class="invert" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg
65
65
+
>`
55
66
} as CardDefinition & { type: 'youtubeVideo' };
56
67
57
68
// Thanks to eleventy-plugin-youtube-embed
+4
src/lib/cards/index.ts
···
30
30
import { VCardCardDefinition } from './VCardCard';
31
31
import { DrawCardDefinition } from './DrawCard';
32
32
import { TimerCardDefinition } from './TimerCard';
33
33
+
import { ClockCardDefinition } from './ClockCard';
34
34
+
import { CountdownCardDefinition } from './CountdownCard';
33
35
import { SpotifyCardDefinition } from './SpotifyCard';
34
36
import { ButtonCardDefinition } from './ButtonCard';
35
37
import { GuestbookCardDefinition } from './GuestbookCard';
···
69
71
VCardCardDefinition,
70
72
DrawCardDefinition,
71
73
TimerCardDefinition,
74
74
+
ClockCardDefinition,
75
75
+
CountdownCardDefinition,
72
76
SpotifyCardDefinition
73
77
// Model3DCardDefinition
74
78
] as const;
+6
src/lib/cards/types.ts
···
73
73
canHaveLabel?: boolean;
74
74
75
75
migrate?: (item: Item) => void;
76
76
+
77
77
+
groups?: string[];
78
78
+
79
79
+
keywords?: string[];
80
80
+
81
81
+
icon?: string;
76
82
};
+192
src/lib/components/card-command/CardCommand.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { AllCardDefinitions } from '$lib/cards';
3
3
+
import type { CardDefinition } from '$lib/cards/types';
4
4
+
import { Command, Dialog } from 'bits-ui';
5
5
+
import { isTyping } from '$lib/helper';
6
6
+
7
7
+
const CardDefGroups = [
8
8
+
'Core',
9
9
+
...Array.from(
10
10
+
new Set(
11
11
+
AllCardDefinitions.map((cardDef) => cardDef.groups)
12
12
+
.flat()
13
13
+
.filter((g) => g)
14
14
+
)
15
15
+
)
16
16
+
.sort()
17
17
+
.filter((g) => g !== 'Core')
18
18
+
];
19
19
+
20
20
+
let {
21
21
+
open = $bindable(false),
22
22
+
onselect,
23
23
+
onlink
24
24
+
}: {
25
25
+
open: boolean;
26
26
+
onselect: (cardDef: CardDefinition) => void;
27
27
+
onlink?: (url: string, cardDef: CardDefinition) => void;
28
28
+
} = $props();
29
29
+
30
30
+
let searchValue = $state('');
31
31
+
32
32
+
let normalizedUrl = $derived.by(() => {
33
33
+
if (!searchValue || searchValue.length < 8) return '';
34
34
+
try {
35
35
+
const val = searchValue.trim();
36
36
+
const urlStr = val.startsWith('http') ? val : `https://${val}`;
37
37
+
const url = new URL(urlStr);
38
38
+
if (!url.hostname.includes('.')) return '';
39
39
+
return urlStr;
40
40
+
} catch {
41
41
+
return '';
42
42
+
}
43
43
+
});
44
44
+
45
45
+
let urlMatchingCards = $derived.by(() => {
46
46
+
if (!normalizedUrl) return [];
47
47
+
return AllCardDefinitions.filter((d) => d.onUrlHandler)
48
48
+
.filter((d) => {
49
49
+
try {
50
50
+
const testItem = { cardData: {} };
51
51
+
return d.onUrlHandler!(normalizedUrl, testItem as any);
52
52
+
} catch {
53
53
+
return false;
54
54
+
}
55
55
+
})
56
56
+
.toSorted((a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0));
57
57
+
});
58
58
+
59
59
+
function selectUrl(cardDef: CardDefinition) {
60
60
+
const url = normalizedUrl;
61
61
+
open = false;
62
62
+
searchValue = '';
63
63
+
onlink?.(url, cardDef);
64
64
+
}
65
65
+
66
66
+
function commandFilter(value: string, search: string, keywords?: string[]): number {
67
67
+
if (value.startsWith('url:')) return 1;
68
68
+
const s = search.toLowerCase();
69
69
+
for (const t of [value, ...(keywords ?? [])]) {
70
70
+
if (t.toLowerCase().includes(s)) return 1;
71
71
+
}
72
72
+
return 0;
73
73
+
}
74
74
+
75
75
+
function handleKeydown(e: KeyboardEvent) {
76
76
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
77
77
+
e.preventDefault();
78
78
+
open = true;
79
79
+
}
80
80
+
if (e.key === '+' && !isTyping()) {
81
81
+
e.preventDefault();
82
82
+
open = true;
83
83
+
}
84
84
+
}
85
85
+
</script>
86
86
+
87
87
+
<svelte:document onkeydown={handleKeydown} />
88
88
+
89
89
+
<Dialog.Root bind:open>
90
90
+
<Dialog.Portal>
91
91
+
<Dialog.Overlay
92
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
93
+
/>
94
94
+
<Dialog.Content
95
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
96
+
>
97
97
+
<Dialog.Title class="sr-only">Command Menu</Dialog.Title>
98
98
+
<Dialog.Description class="sr-only">
99
99
+
This is the command menu. Use the arrow keys to navigate and press ⌘K to open the search
100
100
+
bar.
101
101
+
</Dialog.Description>
102
102
+
<Command.Root
103
103
+
filter={commandFilter}
104
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
105
+
>
106
106
+
<Command.Input
107
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
108
+
placeholder="Search for a card or paste a link..."
109
109
+
oninput={(e) => {
110
110
+
searchValue = e.currentTarget.value;
111
111
+
}}
112
112
+
/>
113
113
+
114
114
+
<Command.List
115
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
116
+
>
117
117
+
<Command.Viewport>
118
118
+
<Command.Empty
119
119
+
class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm"
120
120
+
>
121
121
+
No results found.
122
122
+
</Command.Empty>
123
123
+
124
124
+
{#if urlMatchingCards.length > 0}
125
125
+
<Command.Group>
126
126
+
<Command.GroupHeading
127
127
+
class="text-base-600 dark:text-base-400 px-3 pt-3 pb-2 text-xs"
128
128
+
>
129
129
+
Add from link
130
130
+
</Command.GroupHeading>
131
131
+
<Command.GroupItems>
132
132
+
{#each urlMatchingCards as cardDef (cardDef.type)}
133
133
+
<Command.Item
134
134
+
value="url:{cardDef.type}"
135
135
+
onSelect={() => {
136
136
+
selectUrl(cardDef);
137
137
+
}}
138
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
139
+
>
140
140
+
{#if cardDef.icon}
141
141
+
<div class="text-base-700 dark:text-base-300">
142
142
+
{@html cardDef.icon}
143
143
+
</div>
144
144
+
{/if}
145
145
+
{cardDef.name}
146
146
+
</Command.Item>
147
147
+
{/each}
148
148
+
</Command.GroupItems>
149
149
+
</Command.Group>
150
150
+
<Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" />
151
151
+
{/if}
152
152
+
153
153
+
{#each CardDefGroups as group, index (group)}
154
154
+
{#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))}
155
155
+
<Command.Group>
156
156
+
<Command.GroupHeading
157
157
+
class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs"
158
158
+
>
159
159
+
{group}
160
160
+
</Command.GroupHeading>
161
161
+
<Command.GroupItems>
162
162
+
{#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef (cardDef.type)}
163
163
+
<Command.Item
164
164
+
onSelect={() => {
165
165
+
open = false;
166
166
+
searchValue = '';
167
167
+
onselect(cardDef);
168
168
+
}}
169
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
170
+
keywords={[group, cardDef.type, ...(cardDef.keywords || [])]}
171
171
+
>
172
172
+
{#if cardDef.icon}
173
173
+
<div class="text-base-700 dark:text-base-300">
174
174
+
{@html cardDef.icon}
175
175
+
</div>
176
176
+
{/if}
177
177
+
{cardDef.name}
178
178
+
</Command.Item>
179
179
+
{/each}
180
180
+
</Command.GroupItems>
181
181
+
</Command.Group>
182
182
+
{#if index < CardDefGroups.length - 1}
183
183
+
<Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" />
184
184
+
{/if}
185
185
+
{/if}
186
186
+
{/each}
187
187
+
</Command.Viewport>
188
188
+
</Command.List>
189
189
+
</Command.Root>
190
190
+
</Dialog.Content>
191
191
+
</Dialog.Portal>
192
192
+
</Dialog.Root>
+4
-175
src/lib/website/EditBar.svelte
···
2
2
import { dev } from '$app/environment';
3
3
import { user } from '$lib/atproto';
4
4
import type { WebsiteData } from '$lib/types';
5
5
-
import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core';
5
5
+
import { Button, Navbar, Toggle, toast } from '@foxui/core';
6
6
7
7
let {
8
8
data,
9
9
-
linkValue = $bindable(),
10
10
-
newCard,
11
11
-
addLink,
12
9
13
10
showingMobileView = $bindable(),
14
11
isSaving = $bindable(),
···
16
13
17
14
save,
18
15
19
19
-
handleImageInputChange,
20
20
-
handleVideoInputChange
16
16
+
showCardCommand
21
17
}: {
22
18
data: WebsiteData;
23
23
-
linkValue: string;
24
24
-
newCard: (type: string) => void;
25
25
-
addLink: (url: string) => void;
26
19
27
20
showingMobileView: boolean;
28
21
···
31
24
32
25
save: () => Promise<void>;
33
26
34
34
-
handleImageInputChange: (evt: Event) => void;
35
35
-
handleVideoInputChange: (evt: Event) => void;
27
27
+
showCardCommand: () => void;
36
28
} = $props();
37
37
-
38
38
-
let linkPopoverOpen = $state(false);
39
39
-
40
40
-
let imageInputRef: HTMLInputElement | undefined = $state();
41
41
-
let videoInputRef: HTMLInputElement | undefined = $state();
42
29
43
30
function getShareUrl() {
44
31
const base = typeof window !== 'undefined' ? window.location.origin : '';
···
54
41
}
55
42
</script>
56
43
57
57
-
<input
58
58
-
type="file"
59
59
-
accept="image/*"
60
60
-
onchange={handleImageInputChange}
61
61
-
class="hidden"
62
62
-
multiple
63
63
-
bind:this={imageInputRef}
64
64
-
/>
65
65
-
66
66
-
<input
67
67
-
type="file"
68
68
-
accept="video/*"
69
69
-
onchange={handleVideoInputChange}
70
70
-
class="hidden"
71
71
-
multiple
72
72
-
bind:this={videoInputRef}
73
73
-
/>
74
74
-
75
44
{#if dev || (user.isLoggedIn && user.profile?.did === data.did)}
76
45
<Navbar
77
46
class={[
···
80
49
]}
81
50
>
82
51
<div class="flex items-center gap-2">
83
83
-
<Button
84
84
-
size="iconLg"
85
85
-
variant="ghost"
86
86
-
class="backdrop-blur-none"
87
87
-
onclick={() => {
88
88
-
newCard('section');
89
89
-
}}
90
90
-
>
91
91
-
<svg
92
92
-
xmlns="http://www.w3.org/2000/svg"
93
93
-
viewBox="0 0 24 24"
94
94
-
fill="none"
95
95
-
stroke="currentColor"
96
96
-
stroke-width="2"
97
97
-
stroke-linecap="round"
98
98
-
stroke-linejoin="round"
99
99
-
><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg
100
100
-
>
101
101
-
</Button>
102
102
-
103
103
-
<Button
104
104
-
size="iconLg"
105
105
-
variant="ghost"
106
106
-
class="backdrop-blur-none"
107
107
-
onclick={() => {
108
108
-
newCard('text');
109
109
-
}}
110
110
-
>
111
111
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
112
112
-
><path
113
113
-
fill="none"
114
114
-
stroke="currentColor"
115
115
-
stroke-linecap="round"
116
116
-
stroke-linejoin="round"
117
117
-
stroke-width="2"
118
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
119
-
/></svg
120
120
-
>
121
121
-
</Button>
122
122
-
123
123
-
<Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900">
124
124
-
{#snippet child({ props })}
125
125
-
<Button
126
126
-
size="iconLg"
127
127
-
variant="ghost"
128
128
-
class="backdrop-blur-none"
129
129
-
onclick={() => {
130
130
-
newCard('link');
131
131
-
}}
132
132
-
{...props}
133
133
-
>
134
134
-
<svg
135
135
-
xmlns="http://www.w3.org/2000/svg"
136
136
-
fill="none"
137
137
-
viewBox="-2 -2 28 28"
138
138
-
stroke-width="2"
139
139
-
stroke="currentColor"
140
140
-
>
141
141
-
<path
142
142
-
stroke-linecap="round"
143
143
-
stroke-linejoin="round"
144
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
145
-
/>
146
146
-
</svg>
147
147
-
</Button>
148
148
-
{/snippet}
149
149
-
<Input
150
150
-
spellcheck={false}
151
151
-
type="url"
152
152
-
bind:value={linkValue}
153
153
-
onkeydown={(event) => {
154
154
-
if (event.code === 'Enter') {
155
155
-
addLink(linkValue);
156
156
-
event.preventDefault();
157
157
-
}
158
158
-
}}
159
159
-
placeholder="Enter link"
160
160
-
/>
161
161
-
<Button onclick={() => addLink(linkValue)} size="icon"
162
162
-
><svg
163
163
-
xmlns="http://www.w3.org/2000/svg"
164
164
-
fill="none"
165
165
-
viewBox="0 0 24 24"
166
166
-
stroke-width="2"
167
167
-
stroke="currentColor"
168
168
-
class="size-6"
169
169
-
>
170
170
-
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
171
171
-
</svg>
172
172
-
</Button>
173
173
-
</Popover>
174
174
-
175
175
-
<Button
176
176
-
size="iconLg"
177
177
-
variant="ghost"
178
178
-
class="backdrop-blur-none"
179
179
-
onclick={() => {
180
180
-
imageInputRef?.click();
181
181
-
}}
182
182
-
>
183
183
-
<svg
184
184
-
xmlns="http://www.w3.org/2000/svg"
185
185
-
fill="none"
186
186
-
viewBox="0 0 24 24"
187
187
-
stroke-width="2"
188
188
-
stroke="currentColor"
189
189
-
>
190
190
-
<path
191
191
-
stroke-linecap="round"
192
192
-
stroke-linejoin="round"
193
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
194
-
/>
195
195
-
</svg>
196
196
-
</Button>
197
197
-
198
198
-
{#if dev}
199
199
-
<Button
200
200
-
size="iconLg"
201
201
-
variant="ghost"
202
202
-
class="backdrop-blur-none"
203
203
-
onclick={() => {
204
204
-
videoInputRef?.click();
205
205
-
}}
206
206
-
>
207
207
-
<svg
208
208
-
xmlns="http://www.w3.org/2000/svg"
209
209
-
fill="none"
210
210
-
viewBox="0 0 24 24"
211
211
-
stroke-width="1.5"
212
212
-
stroke="currentColor"
213
213
-
>
214
214
-
<path
215
215
-
stroke-linecap="round"
216
216
-
stroke-linejoin="round"
217
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
218
-
/>
219
219
-
</svg>
220
220
-
</Button>
221
221
-
{/if}
222
222
-
223
223
-
<Button size="iconLg" variant="ghost" class="backdrop-blur-none" popovertarget="mobile-menu">
52
52
+
<Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}>
224
53
<svg
225
54
xmlns="http://www.w3.org/2000/svg"
226
55
fill="none"
+58
-13
src/lib/website/EditableWebsite.svelte
···
24
24
import EditingCard from '../cards/Card/EditingCard.svelte';
25
25
import { AllCardDefinitions, CardDefinitionsByType } from '../cards';
26
26
import { tick, type Component } from 'svelte';
27
27
-
import type { CreationModalComponentProps } from '../cards/types';
27
27
+
import type { CardDefinition, CreationModalComponentProps } from '../cards/types';
28
28
import { dev } from '$app/environment';
29
29
import { setIsMobile } from './context';
30
30
import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte';
···
38
38
import { user } from '$lib/atproto';
39
39
import { launchConfetti } from '@foxui/visual';
40
40
import Controls from './Controls.svelte';
41
41
+
import CardCommand from '$lib/components/card-command/CardCommand.svelte';
41
42
42
43
let {
43
44
data
···
362
363
return { x: gridX, y: gridY, swapWithId, placement };
363
364
}
364
365
365
365
-
let linkValue = $state('');
366
366
-
367
367
-
function addLink(url: string) {
366
366
+
function addLink(url: string, specificCardDef?: CardDefinition) {
368
367
let link = validateLink(url);
369
368
if (!link) {
370
369
toast.error('invalid link');
···
372
371
}
373
372
let item = createEmptyCard(data.page);
374
373
374
374
+
if (specificCardDef?.onUrlHandler?.(link, item)) {
375
375
+
item.cardType = specificCardDef.type;
376
376
+
newItem.item = item;
377
377
+
saveNewItem();
378
378
+
toast(specificCardDef.name + ' added!');
379
379
+
return;
380
380
+
}
381
381
+
375
382
for (const cardDef of AllCardDefinitions.toSorted(
376
383
(a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
377
384
)) {
···
384
391
break;
385
392
}
386
393
}
387
387
-
388
388
-
if (linkValue === url) {
389
389
-
linkValue = '';
390
390
-
}
391
394
}
392
395
393
396
function getImageDimensions(src: string): Promise<{ width: number; height: number }> {
···
645
648
}
646
649
647
650
// $inspect(items);
651
651
+
652
652
+
let showCardCommand = $state(true);
648
653
</script>
649
654
650
655
<svelte:body
···
684
689
</div>
685
690
{/if}
686
691
692
692
+
<CardCommand
693
693
+
bind:open={showCardCommand}
694
694
+
onselect={(cardDef: CardDefinition) => {
695
695
+
if (cardDef.type === 'image') {
696
696
+
const input = document.getElementById('image-input') as HTMLInputElement;
697
697
+
if (input) {
698
698
+
input.click();
699
699
+
return;
700
700
+
}
701
701
+
} else if (cardDef.type === 'video') {
702
702
+
const input = document.getElementById('video-input') as HTMLInputElement;
703
703
+
if (input) {
704
704
+
input.click();
705
705
+
return;
706
706
+
}
707
707
+
} else {
708
708
+
newCard(cardDef.type);
709
709
+
}
710
710
+
}}
711
711
+
onlink={(url, cardDef) => {
712
712
+
addLink(url, cardDef);
713
713
+
}}
714
714
+
/>
715
715
+
687
716
<Controls bind:data />
688
717
689
718
{#if showingMobileView}
···
900
929
</div>
901
930
</Sidebar>
902
931
932
932
+
<input
933
933
+
type="file"
934
934
+
accept="image/*"
935
935
+
onchange={handleImageInputChange}
936
936
+
class="hidden"
937
937
+
id="image-input"
938
938
+
multiple
939
939
+
/>
940
940
+
941
941
+
<input
942
942
+
type="file"
943
943
+
accept="video/*"
944
944
+
onchange={handleVideoInputChange}
945
945
+
class="hidden"
946
946
+
id="video-input"
947
947
+
multiple
948
948
+
/>
949
949
+
903
950
<EditBar
904
951
{data}
905
905
-
bind:linkValue
906
952
bind:isSaving
907
953
bind:showingMobileView
908
954
{hasUnsavedChanges}
909
909
-
{newCard}
910
910
-
{addLink}
911
955
{save}
912
912
-
{handleImageInputChange}
913
913
-
{handleVideoInputChange}
956
956
+
showCardCommand={() => {
957
957
+
showCardCommand = true;
958
958
+
}}
914
959
/>
915
960
916
961
<Toaster />