tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
16
fork
atom
overview
issues
pulls
pipelines
commit
Florian
6 days ago
bb73ff93
cda2c9ee
+595
-145
5 changed files
expand all
collapse all
unified
split
src
lib
cards
BaseCard
BaseCard.svelte
BaseEditingCard.svelte
website
EditBar.svelte
EditableWebsite.svelte
context.ts
+11
-1
src/lib/cards/BaseCard/BaseCard.svelte
···
5
5
import type { Snippet } from 'svelte';
6
6
import type { HTMLAttributes } from 'svelte/elements';
7
7
import { getColor } from '..';
8
8
+
import { getIsCoarse } from '$lib/website/context';
9
9
+
10
10
+
function tryGetIsCoarse(): (() => boolean) | undefined {
11
11
+
try {
12
12
+
return getIsCoarse();
13
13
+
} catch {
14
14
+
return undefined;
15
15
+
}
16
16
+
}
17
17
+
const isCoarse = tryGetIsCoarse();
8
18
9
19
const colors = {
10
20
base: 'bg-base-200/50 dark:bg-base-950/50',
···
39
49
id={item.id}
40
50
data-flip-id={item.id}
41
51
bind:this={ref}
42
42
-
draggable={isEditing && !locked}
52
52
+
draggable={isEditing && !locked && !isCoarse?.()}
43
53
class={[
44
54
'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2',
45
55
color ? (colors[color] ?? colors.accent) : colors.base,
+36
-9
src/lib/cards/BaseCard/BaseEditingCard.svelte
···
7
7
import { ColorSelect } from '@foxui/colors';
8
8
import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..';
9
9
import { COLUMNS } from '$lib';
10
10
-
import { getCanEdit, getIsMobile } from '$lib/website/context';
10
10
+
import {
11
11
+
getCanEdit,
12
12
+
getIsCoarse,
13
13
+
getIsMobile,
14
14
+
getSelectedCardId,
15
15
+
getSelectCard
16
16
+
} from '$lib/website/context';
11
17
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
12
18
import { fixAllCollisions, fixCollisions } from '$lib/helper';
13
19
···
53
59
54
60
let canEdit = getCanEdit();
55
61
let isMobile = getIsMobile();
62
62
+
let isCoarse = getIsCoarse();
63
63
+
64
64
+
let selectedCardId = getSelectedCardId();
65
65
+
let selectCard = getSelectCard();
66
66
+
let isSelected = $derived(selectedCardId?.() === item.id);
67
67
+
let isDimmed = $derived(isCoarse?.() && selectedCardId?.() != null && !isSelected);
56
68
57
69
let colorPopoverOpen = $state(false);
58
70
···
173
185
{item}
174
186
isEditing={true}
175
187
bind:ref
176
176
-
showOutline={isResizing}
188
188
+
showOutline={isResizing || (isCoarse?.() && isSelected)}
177
189
locked={item.cardData?.locked}
178
178
-
class="scale-100 opacity-100 starting:scale-0 starting:opacity-0"
190
190
+
class={[
191
191
+
'scale-100 starting:scale-0 starting:opacity-0',
192
192
+
isCoarse?.() && isSelected ? 'ring-accent-500 z-10 ring-2 ring-offset-2' : '',
193
193
+
isDimmed ? 'opacity-70' : 'opacity-100'
194
194
+
]}
179
195
{...rest}
180
196
>
181
197
{#if !item.cardData?.locked}
182
182
-
<div class="absolute inset-0 cursor-grab"></div>
198
198
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
199
199
+
<div
200
200
+
role="button"
201
201
+
tabindex="-1"
202
202
+
class={['absolute inset-0', isCoarse?.() ? 'cursor-pointer' : 'cursor-grab']}
203
203
+
onclick={(e) => {
204
204
+
if (isCoarse?.()) {
205
205
+
e.stopPropagation();
206
206
+
selectCard?.(item.id);
207
207
+
}
208
208
+
}}
209
209
+
></div>
183
210
{/if}
184
211
{@render children?.()}
185
212
···
187
214
<div
188
215
class={cn(
189
216
'bg-base-200/50 dark:bg-base-900/50 absolute top-2 left-2 z-100 w-fit max-w-[calc(100%-1rem)] rounded-xl p-1 px-2 backdrop-blur-md',
190
190
-
!item.cardData.label && 'hidden group-hover/card:block'
217
217
+
!item.cardData.label && 'hidden lg:group-hover/card:block'
191
218
)}
192
219
>
193
220
<PlainTextEditor
···
205
232
{#if changeOptions.length > 1}
206
233
<div
207
234
class={[
208
208
-
'absolute -top-3 -right-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex',
235
235
+
'absolute -top-3 -right-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex',
209
236
changePopoverOpen ? 'inline-flex' : ''
210
237
]}
211
238
>
···
253
280
onclick={() => {
254
281
ondelete();
255
282
}}
256
256
-
class="absolute -top-3 -left-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex"
283
283
+
class="absolute -top-3 -left-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex"
257
284
>
258
285
<svg
259
286
xmlns="http://www.w3.org/2000/svg"
···
274
301
275
302
<div
276
303
class={[
277
277
-
'absolute -bottom-7 w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover/card:inline-flex',
304
304
+
'absolute -bottom-7 w-full items-center justify-center text-xs lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex',
278
305
colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden'
279
306
]}
280
307
>
···
411
438
<!-- Resize handle at bottom right corner -->
412
439
<div
413
440
onpointerdown={handleResizeStart}
414
414
-
class="bg-base-300/70 dark:bg-base-900/70 pointer-events-auto absolute right-0.5 bottom-0.5 hidden cursor-se-resize rounded-md rounded-br-3xl p-1 group-hover/card:block"
441
441
+
class="bg-base-300/70 dark:bg-base-900/70 pointer-events-auto absolute right-0.5 bottom-0.5 hidden cursor-se-resize rounded-md rounded-br-3xl p-1 lg:group-hover/card:block"
415
442
>
416
443
<svg
417
444
xmlns="http://www.w3.org/2000/svg"
+346
-119
src/lib/website/EditBar.svelte
···
1
1
<script lang="ts">
2
2
import { dev } from '$app/environment';
3
3
import { user } from '$lib/atproto';
4
4
-
import type { WebsiteData } from '$lib/types';
4
4
+
import { COLUMNS } from '$lib';
5
5
+
import type { Item, WebsiteData } from '$lib/types';
6
6
+
import { CardDefinitionsByType } from '$lib/cards';
5
7
import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core';
8
8
+
import { ColorSelect } from '@foxui/colors';
6
9
7
10
let {
8
11
data,
···
17
20
save,
18
21
19
22
handleImageInputChange,
20
20
-
handleVideoInputChange
23
23
+
handleVideoInputChange,
24
24
+
25
25
+
selectedCard = null,
26
26
+
isMobile = false,
27
27
+
isCoarse = false,
28
28
+
ondeselect,
29
29
+
ondelete,
30
30
+
onsetsize
21
31
}: {
22
32
data: WebsiteData;
23
33
linkValue: string;
···
33
43
34
44
handleImageInputChange: (evt: Event) => void;
35
45
handleVideoInputChange: (evt: Event) => void;
46
46
+
47
47
+
selectedCard?: Item | null;
48
48
+
isMobile?: boolean;
49
49
+
isCoarse?: boolean;
50
50
+
ondeselect?: () => void;
51
51
+
ondelete?: () => void;
52
52
+
onsetsize?: (w: number, h: number) => void;
36
53
} = $props();
37
54
38
55
let linkPopoverOpen = $state(false);
···
52
69
await navigator.clipboard.writeText(url);
53
70
toast.success('Link copied to clipboard!');
54
71
}
72
72
+
73
73
+
let colorsChoices = [
74
74
+
{ class: 'text-base-500', label: 'base' },
75
75
+
{ class: 'text-accent-500', label: 'accent' },
76
76
+
{ class: 'text-base-300 dark:text-base-700', label: 'transparent' },
77
77
+
{ class: 'text-red-500', label: 'red' },
78
78
+
{ class: 'text-orange-500', label: 'orange' },
79
79
+
{ class: 'text-amber-500', label: 'amber' },
80
80
+
{ class: 'text-yellow-500', label: 'yellow' },
81
81
+
{ class: 'text-lime-500', label: 'lime' },
82
82
+
{ class: 'text-green-500', label: 'green' },
83
83
+
{ class: 'text-emerald-500', label: 'emerald' },
84
84
+
{ class: 'text-teal-500', label: 'teal' },
85
85
+
{ class: 'text-cyan-500', label: 'cyan' },
86
86
+
{ class: 'text-sky-500', label: 'sky' },
87
87
+
{ class: 'text-blue-500', label: 'blue' },
88
88
+
{ class: 'text-indigo-500', label: 'indigo' },
89
89
+
{ class: 'text-violet-500', label: 'violet' },
90
90
+
{ class: 'text-purple-500', label: 'purple' },
91
91
+
{ class: 'text-fuchsia-500', label: 'fuchsia' },
92
92
+
{ class: 'text-pink-500', label: 'pink' },
93
93
+
{ class: 'text-rose-500', label: 'rose' }
94
94
+
];
95
95
+
96
96
+
let selectedColor = $derived(
97
97
+
selectedCard
98
98
+
? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label)
99
99
+
: undefined
100
100
+
);
101
101
+
102
102
+
let cardDef = $derived(
103
103
+
selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null
104
104
+
);
105
105
+
106
106
+
let colorPopoverOpen = $state(false);
107
107
+
let settingsPopoverOpen = $state(false);
108
108
+
109
109
+
const minW = $derived(cardDef?.minW ?? 2);
110
110
+
const minH = $derived(cardDef?.minH ?? 2);
111
111
+
const maxW = $derived(cardDef?.maxW ?? COLUMNS);
112
112
+
const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6));
113
113
+
114
114
+
function canSetSize(w: number, h: number) {
115
115
+
if (!cardDef) return false;
116
116
+
if (isMobile) {
117
117
+
return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH;
118
118
+
}
119
119
+
return w >= minW && w <= maxW && h >= minH && h <= maxH;
120
120
+
}
121
121
+
122
122
+
const showMobileEditControls = $derived(isCoarse && selectedCard);
55
123
</script>
56
124
57
125
<input
···
74
142
75
143
{#if dev || (user.isLoggedIn && user.profile?.did === data.did)}
76
144
<Navbar
77
77
-
class={[
78
78
-
'dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto lg:inline-flex',
79
79
-
!dev ? 'hidden' : ''
80
80
-
]}
145
145
+
class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto"
81
146
>
82
82
-
<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
147
147
+
{#if showMobileEditControls}
148
148
+
<!-- Mobile edit controls when a card is selected -->
149
149
+
<div class="flex items-center gap-1">
150
150
+
<Button
151
151
+
size="iconLg"
152
152
+
variant="ghost"
153
153
+
class="backdrop-blur-none"
154
154
+
onclick={() => ondeselect?.()}
155
155
+
>
156
156
+
<svg
157
157
+
xmlns="http://www.w3.org/2000/svg"
158
158
+
fill="none"
159
159
+
viewBox="0 0 24 24"
160
160
+
stroke-width="2"
161
161
+
stroke="currentColor"
162
162
+
class="size-5"
163
163
+
>
164
164
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
165
165
+
</svg>
166
166
+
</Button>
167
167
+
168
168
+
<Button
169
169
+
size="iconLg"
170
170
+
variant="ghost"
171
171
+
class="text-rose-500 backdrop-blur-none"
172
172
+
onclick={() => ondelete?.()}
100
173
>
101
101
-
</Button>
174
174
+
<svg
175
175
+
xmlns="http://www.w3.org/2000/svg"
176
176
+
fill="none"
177
177
+
viewBox="0 0 24 24"
178
178
+
stroke-width="1.5"
179
179
+
stroke="currentColor"
180
180
+
class="size-5"
181
181
+
>
182
182
+
<path
183
183
+
stroke-linecap="round"
184
184
+
stroke-linejoin="round"
185
185
+
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
186
186
+
/>
187
187
+
</svg>
188
188
+
</Button>
102
189
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
190
190
+
{#if cardDef?.allowSetColor !== false}
191
191
+
<Popover bind:open={colorPopoverOpen}>
192
192
+
{#snippet child({ props })}
193
193
+
<button
194
194
+
{...props}
195
195
+
class={[
196
196
+
'm-1 size-5 cursor-pointer rounded-full',
197
197
+
!selectedCard?.color ||
198
198
+
selectedCard.color === 'base' ||
199
199
+
selectedCard.color === 'transparent'
200
200
+
? 'text-base-800 dark:text-base-200'
201
201
+
: 'text-accent-500'
202
202
+
]}
203
203
+
>
204
204
+
<svg
205
205
+
xmlns="http://www.w3.org/2000/svg"
206
206
+
viewBox="0 0 24 24"
207
207
+
fill="currentColor"
208
208
+
class="size-5"
209
209
+
>
210
210
+
<path
211
211
+
fill-rule="evenodd"
212
212
+
d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z"
213
213
+
clip-rule="evenodd"
214
214
+
/>
215
215
+
</svg>
216
216
+
</button>
217
217
+
{/snippet}
218
218
+
<ColorSelect
219
219
+
selected={selectedColor}
220
220
+
colors={colorsChoices}
221
221
+
onselected={(color, previous) => {
222
222
+
if (typeof previous === 'string' || typeof color === 'string') {
223
223
+
return;
224
224
+
}
225
225
+
if (selectedCard) {
226
226
+
selectedCard.color = color.label;
227
227
+
}
228
228
+
}}
229
229
+
class="w-64"
230
230
+
/>
231
231
+
</Popover>
232
232
+
{/if}
233
233
+
234
234
+
{#if canSetSize(2, 2)}
235
235
+
<button
236
236
+
onclick={() => onsetsize?.(4, 4)}
237
237
+
class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2 text-xs font-bold"
238
238
+
>
239
239
+
S
240
240
+
</button>
241
241
+
{/if}
242
242
+
{#if canSetSize(4, 2)}
243
243
+
<button
244
244
+
onclick={() => onsetsize?.(8, 4)}
245
245
+
class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2 text-xs font-bold"
246
246
+
>
247
247
+
M
248
248
+
</button>
249
249
+
{/if}
250
250
+
{#if canSetSize(2, 4)}
251
251
+
<button
252
252
+
onclick={() => onsetsize?.(4, 8)}
253
253
+
class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2 text-xs font-bold"
254
254
+
>
255
255
+
L
256
256
+
</button>
257
257
+
{/if}
258
258
+
{#if canSetSize(4, 4)}
259
259
+
<button
260
260
+
onclick={() => onsetsize?.(8, 8)}
261
261
+
class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2 text-xs font-bold"
262
262
+
>
263
263
+
XL
264
264
+
</button>
265
265
+
{/if}
266
266
+
267
267
+
{#if cardDef?.settingsComponent && selectedCard}
268
268
+
<Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900">
269
269
+
{#snippet child({ props })}
270
270
+
<button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2">
271
271
+
<svg
272
272
+
xmlns="http://www.w3.org/2000/svg"
273
273
+
fill="none"
274
274
+
viewBox="0 0 24 24"
275
275
+
stroke-width="2"
276
276
+
stroke="currentColor"
277
277
+
class="size-5"
278
278
+
>
279
279
+
<path
280
280
+
stroke-linecap="round"
281
281
+
stroke-linejoin="round"
282
282
+
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
283
283
+
/>
284
284
+
<path
285
285
+
stroke-linecap="round"
286
286
+
stroke-linejoin="round"
287
287
+
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
288
288
+
/>
289
289
+
</svg>
290
290
+
</button>
291
291
+
{/snippet}
292
292
+
<cardDef.settingsComponent
293
293
+
bind:item={selectedCard}
294
294
+
onclose={() => {
295
295
+
settingsPopoverOpen = false;
296
296
+
}}
297
297
+
/>
298
298
+
</Popover>
299
299
+
{/if}
300
300
+
</div>
301
301
+
{:else}
302
302
+
<!-- Normal add-card controls -->
303
303
+
<div class="flex items-center gap-2">
304
304
+
<Button
305
305
+
size="iconLg"
306
306
+
variant="ghost"
307
307
+
class="backdrop-blur-none"
308
308
+
onclick={() => {
309
309
+
newCard('section');
310
310
+
}}
311
311
+
>
312
312
+
<svg
313
313
+
xmlns="http://www.w3.org/2000/svg"
314
314
+
viewBox="0 0 24 24"
113
315
fill="none"
114
316
stroke="currentColor"
317
317
+
stroke-width="2"
115
318
stroke-linecap="round"
116
319
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
320
320
+
><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg
321
321
+
>
322
322
+
</Button>
323
323
+
324
324
+
<Button
325
325
+
size="iconLg"
326
326
+
variant="ghost"
327
327
+
class="backdrop-blur-none"
328
328
+
onclick={() => {
329
329
+
newCard('text');
330
330
+
}}
120
331
>
121
121
-
</Button>
332
332
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
333
333
+
><path
334
334
+
fill="none"
335
335
+
stroke="currentColor"
336
336
+
stroke-linecap="round"
337
337
+
stroke-linejoin="round"
338
338
+
stroke-width="2"
339
339
+
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"
340
340
+
/></svg
341
341
+
>
342
342
+
</Button>
122
343
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');
344
344
+
<Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900">
345
345
+
{#snippet child({ props })}
346
346
+
<Button
347
347
+
size="iconLg"
348
348
+
variant="ghost"
349
349
+
class="backdrop-blur-none"
350
350
+
onclick={() => {
351
351
+
newCard('link');
352
352
+
}}
353
353
+
{...props}
354
354
+
>
355
355
+
<svg
356
356
+
xmlns="http://www.w3.org/2000/svg"
357
357
+
fill="none"
358
358
+
viewBox="-2 -2 28 28"
359
359
+
stroke-width="2"
360
360
+
stroke="currentColor"
361
361
+
>
362
362
+
<path
363
363
+
stroke-linecap="round"
364
364
+
stroke-linejoin="round"
365
365
+
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"
366
366
+
/>
367
367
+
</svg>
368
368
+
</Button>
369
369
+
{/snippet}
370
370
+
<Input
371
371
+
spellcheck={false}
372
372
+
type="url"
373
373
+
bind:value={linkValue}
374
374
+
onkeydown={(event) => {
375
375
+
if (event.code === 'Enter') {
376
376
+
addLink(linkValue);
377
377
+
event.preventDefault();
378
378
+
}
131
379
}}
132
132
-
{...props}
133
133
-
>
134
134
-
<svg
380
380
+
placeholder="Enter link"
381
381
+
/>
382
382
+
<Button onclick={() => addLink(linkValue)} size="icon"
383
383
+
><svg
135
384
xmlns="http://www.w3.org/2000/svg"
136
385
fill="none"
137
137
-
viewBox="-2 -2 28 28"
386
386
+
viewBox="0 0 24 24"
138
387
stroke-width="2"
139
388
stroke="currentColor"
389
389
+
class="size-6"
140
390
>
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
-
/>
391
391
+
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
146
392
</svg>
147
393
</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
-
}
394
394
+
</Popover>
395
395
+
396
396
+
<Button
397
397
+
size="iconLg"
398
398
+
variant="ghost"
399
399
+
class="backdrop-blur-none"
400
400
+
onclick={() => {
401
401
+
imageInputRef?.click();
158
402
}}
159
159
-
placeholder="Enter link"
160
160
-
/>
161
161
-
<Button onclick={() => addLink(linkValue)} size="icon"
162
162
-
><svg
403
403
+
>
404
404
+
<svg
163
405
xmlns="http://www.w3.org/2000/svg"
164
406
fill="none"
165
407
viewBox="0 0 24 24"
166
408
stroke-width="2"
167
409
stroke="currentColor"
168
168
-
class="size-6"
169
410
>
170
170
-
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
411
411
+
<path
412
412
+
stroke-linecap="round"
413
413
+
stroke-linejoin="round"
414
414
+
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"
415
415
+
/>
171
416
</svg>
172
417
</Button>
173
173
-
</Popover>
174
418
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>
419
419
+
{#if dev}
420
420
+
<Button
421
421
+
size="iconLg"
422
422
+
variant="ghost"
423
423
+
class="backdrop-blur-none"
424
424
+
onclick={() => {
425
425
+
videoInputRef?.click();
426
426
+
}}
427
427
+
>
428
428
+
<svg
429
429
+
xmlns="http://www.w3.org/2000/svg"
430
430
+
fill="none"
431
431
+
viewBox="0 0 24 24"
432
432
+
stroke-width="1.5"
433
433
+
stroke="currentColor"
434
434
+
>
435
435
+
<path
436
436
+
stroke-linecap="round"
437
437
+
stroke-linejoin="round"
438
438
+
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"
439
439
+
/>
440
440
+
</svg>
441
441
+
</Button>
442
442
+
{/if}
197
443
198
198
-
{#if dev}
199
444
<Button
200
445
size="iconLg"
201
446
variant="ghost"
202
447
class="backdrop-blur-none"
203
203
-
onclick={() => {
204
204
-
videoInputRef?.click();
205
205
-
}}
448
448
+
popovertarget="mobile-menu"
206
449
>
207
450
<svg
208
451
xmlns="http://www.w3.org/2000/svg"
···
211
454
stroke-width="1.5"
212
455
stroke="currentColor"
213
456
>
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
-
/>
457
457
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
219
458
</svg>
220
459
</Button>
221
221
-
{/if}
222
222
-
223
223
-
<Button size="iconLg" variant="ghost" class="backdrop-blur-none" popovertarget="mobile-menu">
224
224
-
<svg
225
225
-
xmlns="http://www.w3.org/2000/svg"
226
226
-
fill="none"
227
227
-
viewBox="0 0 24 24"
228
228
-
stroke-width="1.5"
229
229
-
stroke="currentColor"
230
230
-
>
231
231
-
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
232
232
-
</svg>
233
233
-
</Button>
234
234
-
</div>
235
235
-
<div class="flex items-center gap-2">
460
460
+
</div>
461
461
+
{/if}
462
462
+
<div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}>
236
463
<Toggle
237
464
class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent"
238
465
bind:pressed={showingMobileView}
+199
-16
src/lib/website/EditableWebsite.svelte
···
26
26
import { tick, type Component } from 'svelte';
27
27
import type { CreationModalComponentProps } from '../cards/types';
28
28
import { dev } from '$app/environment';
29
29
-
import { setIsMobile } from './context';
29
29
+
import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context';
30
30
import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte';
31
31
import Context from './Context.svelte';
32
32
import Head from './Head.svelte';
···
124
124
125
125
setIsMobile(() => isMobile);
126
126
127
127
+
const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
128
128
+
setIsCoarse(() => isCoarse);
129
129
+
130
130
+
let selectedCardId: string | null = $state(null);
131
131
+
let selectedCard = $derived(
132
132
+
selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null
133
133
+
);
134
134
+
135
135
+
setSelectedCardId(() => selectedCardId);
136
136
+
setSelectCard((id: string | null) => {
137
137
+
selectedCardId = id;
138
138
+
});
139
139
+
127
140
const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y);
128
141
const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h);
129
142
···
140
153
}
141
154
142
155
function newCard(type: string = 'link', cardData?: any) {
156
156
+
selectedCardId = null;
157
157
+
143
158
// close sidebar if open
144
159
const popover = document.getElementById('mobile-menu');
145
160
if (popover) {
···
229
244
230
245
let debugPoint = $state({ x: 0, y: 0 });
231
246
232
232
-
function getDragXY(
233
233
-
e: DragEvent & {
234
234
-
currentTarget: EventTarget & HTMLDivElement;
235
235
-
}
247
247
+
function getGridPosition(
248
248
+
clientX: number,
249
249
+
clientY: number
236
250
):
237
251
| { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null }
238
252
| undefined {
239
253
if (!container || !activeDragElement.item) return;
240
254
241
255
// x, y represent the top-left corner of the dragged card
242
242
-
const x = e.clientX + activeDragElement.mouseDeltaX;
243
243
-
const y = e.clientY + activeDragElement.mouseDeltaY;
256
256
+
const x = clientX + activeDragElement.mouseDeltaX;
257
257
+
const y = clientY + activeDragElement.mouseDeltaY;
244
258
245
259
const rect = container.getBoundingClientRect();
246
260
const currentMargin = isMobile ? mobileMargin : margin;
···
362
376
return { x: gridX, y: gridY, swapWithId, placement };
363
377
}
364
378
379
379
+
function getDragXY(
380
380
+
e: DragEvent & {
381
381
+
currentTarget: EventTarget & HTMLDivElement;
382
382
+
}
383
383
+
) {
384
384
+
return getGridPosition(e.clientX, e.clientY);
385
385
+
}
386
386
+
387
387
+
// Touch drag system (instant drag on selected card)
388
388
+
let touchDragActive = $state(false);
389
389
+
390
390
+
function touchStart(e: TouchEvent) {
391
391
+
if (!selectedCardId || !container) return;
392
392
+
const touch = e.touches[0];
393
393
+
if (!touch) return;
394
394
+
395
395
+
// Check if the touch is on the selected card element
396
396
+
const target = (e.target as HTMLElement)?.closest?.('.card');
397
397
+
if (!target || target.id !== selectedCardId) return;
398
398
+
399
399
+
const item = items.find((i) => i.id === selectedCardId);
400
400
+
if (!item || item.cardData?.locked) return;
401
401
+
402
402
+
// Start dragging immediately
403
403
+
touchDragActive = true;
404
404
+
405
405
+
const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement;
406
406
+
if (!cardEl) return;
407
407
+
408
408
+
activeDragElement.element = cardEl;
409
409
+
activeDragElement.w = item.w;
410
410
+
activeDragElement.h = item.h;
411
411
+
activeDragElement.item = item;
412
412
+
413
413
+
// Store original positions of all items
414
414
+
activeDragElement.originalPositions = new Map();
415
415
+
for (const it of items) {
416
416
+
activeDragElement.originalPositions.set(it.id, {
417
417
+
x: it.x,
418
418
+
y: it.y,
419
419
+
mobileX: it.mobileX,
420
420
+
mobileY: it.mobileY
421
421
+
});
422
422
+
}
423
423
+
424
424
+
const rect = cardEl.getBoundingClientRect();
425
425
+
activeDragElement.mouseDeltaX = rect.left - touch.clientX;
426
426
+
activeDragElement.mouseDeltaY = rect.top - touch.clientY;
427
427
+
}
428
428
+
429
429
+
function touchMove(e: TouchEvent) {
430
430
+
if (!touchDragActive) return;
431
431
+
432
432
+
const touch = e.touches[0];
433
433
+
if (!touch) return;
434
434
+
435
435
+
e.preventDefault();
436
436
+
437
437
+
const result = getGridPosition(touch.clientX, touch.clientY);
438
438
+
if (!result || !activeDragElement.item) return;
439
439
+
440
440
+
const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
441
441
+
442
442
+
// Reset all items to original positions first
443
443
+
for (const it of items) {
444
444
+
const origPos = activeDragElement.originalPositions.get(it.id);
445
445
+
if (origPos && it !== activeDragElement.item) {
446
446
+
if (isMobile) {
447
447
+
it.mobileX = origPos.mobileX;
448
448
+
it.mobileY = origPos.mobileY;
449
449
+
} else {
450
450
+
it.x = origPos.x;
451
451
+
it.y = origPos.y;
452
452
+
}
453
453
+
}
454
454
+
}
455
455
+
456
456
+
// Update dragged item position
457
457
+
if (isMobile) {
458
458
+
activeDragElement.item.mobileX = result.x;
459
459
+
activeDragElement.item.mobileY = result.y;
460
460
+
} else {
461
461
+
activeDragElement.item.x = result.x;
462
462
+
activeDragElement.item.y = result.y;
463
463
+
}
464
464
+
465
465
+
// Handle horizontal swap
466
466
+
if (result.swapWithId && draggedOrigPos) {
467
467
+
const swapTarget = items.find((it) => it.id === result.swapWithId);
468
468
+
if (swapTarget) {
469
469
+
if (isMobile) {
470
470
+
swapTarget.mobileX = draggedOrigPos.mobileX;
471
471
+
swapTarget.mobileY = draggedOrigPos.mobileY;
472
472
+
} else {
473
473
+
swapTarget.x = draggedOrigPos.x;
474
474
+
swapTarget.y = draggedOrigPos.y;
475
475
+
}
476
476
+
}
477
477
+
}
478
478
+
479
479
+
fixCollisions(items, activeDragElement.item, isMobile);
480
480
+
481
481
+
// Auto-scroll near edges
482
482
+
const scrollZone = 100;
483
483
+
const scrollSpeed = 10;
484
484
+
const viewportHeight = window.innerHeight;
485
485
+
486
486
+
if (touch.clientY < scrollZone) {
487
487
+
const intensity = 1 - touch.clientY / scrollZone;
488
488
+
window.scrollBy(0, -scrollSpeed * intensity);
489
489
+
} else if (touch.clientY > viewportHeight - scrollZone) {
490
490
+
const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone;
491
491
+
window.scrollBy(0, scrollSpeed * intensity);
492
492
+
}
493
493
+
}
494
494
+
495
495
+
function touchEnd() {
496
496
+
if (touchDragActive && activeDragElement.item) {
497
497
+
// Finalize position
498
498
+
fixCollisions(items, activeDragElement.item, isMobile);
499
499
+
500
500
+
activeDragElement.x = -1;
501
501
+
activeDragElement.y = -1;
502
502
+
activeDragElement.element = null;
503
503
+
activeDragElement.item = null;
504
504
+
activeDragElement.lastTargetId = null;
505
505
+
activeDragElement.lastPlacement = null;
506
506
+
}
507
507
+
508
508
+
touchDragActive = false;
509
509
+
}
510
510
+
511
511
+
// Only register non-passive touchmove when actively dragging
512
512
+
$effect(() => {
513
513
+
const el = container;
514
514
+
if (!touchDragActive || !el) return;
515
515
+
516
516
+
el.addEventListener('touchmove', touchMove, { passive: false });
517
517
+
return () => {
518
518
+
el.removeEventListener('touchmove', touchMove);
519
519
+
};
520
520
+
});
521
521
+
365
522
let linkValue = $state('');
366
523
367
524
function addLink(url: string) {
···
676
833
<Account {data} />
677
834
678
835
<Context {data}>
679
679
-
{#if !dev}
680
680
-
<div
681
681
-
class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden"
682
682
-
>
683
683
-
Editing on mobile is not supported yet. Please use a desktop browser.
684
684
-
</div>
685
685
-
{/if}
686
686
-
687
836
<Controls bind:data />
688
837
689
838
{#if showingMobileView}
···
732
881
]}
733
882
>
734
883
<div class="pointer-events-none"></div>
735
735
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
884
884
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
736
885
<div
737
886
bind:this={container}
887
887
+
onclick={(e) => {
888
888
+
// Deselect when tapping empty grid space
889
889
+
if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) {
890
890
+
selectedCardId = null;
891
891
+
}
892
892
+
}}
893
893
+
ontouchstart={touchStart}
894
894
+
ontouchend={touchEnd}
738
895
ondragover={(e) => {
739
896
e.preventDefault();
740
897
···
911
1068
{save}
912
1069
{handleImageInputChange}
913
1070
{handleVideoInputChange}
1071
1071
+
{selectedCard}
1072
1072
+
{isMobile}
1073
1073
+
{isCoarse}
1074
1074
+
ondeselect={() => {
1075
1075
+
selectedCardId = null;
1076
1076
+
}}
1077
1077
+
ondelete={() => {
1078
1078
+
if (selectedCard) {
1079
1079
+
items = items.filter((it) => it.id !== selectedCardId);
1080
1080
+
compactItems(items, false);
1081
1081
+
compactItems(items, true);
1082
1082
+
selectedCardId = null;
1083
1083
+
}
1084
1084
+
}}
1085
1085
+
onsetsize={(w: number, h: number) => {
1086
1086
+
if (selectedCard) {
1087
1087
+
if (isMobile) {
1088
1088
+
selectedCard.mobileW = w;
1089
1089
+
selectedCard.mobileH = h;
1090
1090
+
} else {
1091
1091
+
selectedCard.w = w;
1092
1092
+
selectedCard.h = h;
1093
1093
+
}
1094
1094
+
fixCollisions(items, selectedCard, isMobile);
1095
1095
+
}
1096
1096
+
}}
914
1097
/>
915
1098
916
1099
<Toaster />
+3
src/lib/website/context.ts
···
7
7
export const [getCanEdit, setCanEdit] = createContext<() => boolean>();
8
8
export const [getAdditionalUserData, setAdditionalUserData] =
9
9
createContext<Record<string, unknown>>();
10
10
+
export const [getIsCoarse, setIsCoarse] = createContext<() => boolean>();
11
11
+
export const [getSelectedCardId, setSelectedCardId] = createContext<() => string | null>();
12
12
+
export const [getSelectCard, setSelectCard] = createContext<(id: string | null) => void>();