Skip to content

Commit 5ec9af4

Browse files
committed
feat: redesign share pages
1 parent a37a488 commit 5ec9af4

9 files changed

Lines changed: 472 additions & 287 deletions

File tree

src/lib/components/CopyInput.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
{@render icon()}
2828
{/if}
2929
<input
30-
class="mx-3 grow outline-none!"
30+
class="mx-3 w-0 min-w-0 grow outline-none!"
3131
type="text"
3232
value={displayValue ?? value}
3333
readonly

src/lib/components/shares/permission-switch.svelte

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<script lang="ts">
2-
import { ChartNoAxesGantt, Volume2, Waves, Zap } from '@lucide/svelte';
3-
import Label from '$lib/components/ui/label/label.svelte';
2+
import { Volume2, Waves, Zap } from '@lucide/svelte';
3+
import { Label } from '$lib/components/ui/label';
44
import { Slider } from '$lib/components/ui/slider';
5-
import PermissionSwitch from './permission-switch.svelte';
5+
import { Switch } from '$lib/components/ui/switch';
6+
import LiveControlIcon from '../svg/LiveControlIcon.svelte';
7+
import type { Component } from 'svelte';
68
79
interface Props {
810
permissions: {
@@ -18,25 +20,52 @@
1820
}
1921
2022
let { permissions = $bindable(), limits = $bindable() }: Props = $props();
21-
</script>
2223
23-
<div class="flex flex-col gap-2">
24-
<div>
25-
<Label class="mb-3 text-sm">Intensity: {limits.intensity}%</Label>
26-
<Slider type="single" bind:value={limits.intensity} min={0} max={100} step={1} />
27-
</div>
24+
const features = [
25+
{ key: 'shock', label: 'Shock', icon: Zap },
26+
{ key: 'vibrate', label: 'Vibrate', icon: Waves },
27+
{ key: 'sound', label: 'Sound', icon: Volume2 },
28+
{ key: 'live', label: 'Live Control', icon: LiveControlIcon },
29+
] satisfies { key: keyof Props['permissions']; label: string; icon: Component }[];
30+
</script>
2831

29-
<div>
30-
<Label class="mb-3 text-sm">Duration: {limits.duration / 1000}s</Label>
31-
<Slider type="single" bind:value={limits.duration} min={0} max={30_000} step={100} />
32+
<div class="flex flex-col gap-4">
33+
<div class="grid grid-cols-2 gap-2">
34+
{#each features as { key, label, icon: Icon } (key)}
35+
{@const enabled = permissions[key]}
36+
<div
37+
class="border-border/60 flex items-center justify-between gap-2 rounded-md border px-2.5 py-2 transition-colors"
38+
class:opacity-60={!enabled}
39+
>
40+
<span class="flex min-w-0 items-center gap-2">
41+
<Icon size={14} class="shrink-0" />
42+
<span class="truncate text-xs font-medium">{label}</span>
43+
</span>
44+
<Switch
45+
checked={enabled}
46+
onCheckedChange={(checked) => (permissions = { ...permissions, [key]: checked })}
47+
/>
48+
</div>
49+
{/each}
3250
</div>
3351

34-
<br />
52+
<div class="flex flex-col gap-3">
53+
<div class="space-y-1.5">
54+
<div class="flex items-center justify-between">
55+
<Label class="text-xs">Max Intensity</Label>
56+
<span class="text-muted-foreground font-mono text-xs">{limits.intensity}%</span>
57+
</div>
58+
<Slider type="single" bind:value={limits.intensity} min={0} max={100} step={1} />
59+
</div>
3560

36-
<div class="flex gap-3">
37-
<PermissionSwitch icon={Zap} bind:enabled={permissions.shock} />
38-
<PermissionSwitch icon={Waves} bind:enabled={permissions.vibrate} />
39-
<PermissionSwitch icon={Volume2} bind:enabled={permissions.sound} />
40-
<PermissionSwitch icon={ChartNoAxesGantt} bind:enabled={permissions.live} />
61+
<div class="space-y-1.5">
62+
<div class="flex items-center justify-between">
63+
<Label class="text-xs">Max Duration</Label>
64+
<span class="text-muted-foreground font-mono text-xs"
65+
>{(limits.duration / 1000).toFixed(1)}s</span
66+
>
67+
</div>
68+
<Slider type="single" bind:value={limits.duration} min={0} max={30_000} step={100} />
69+
</div>
4170
</div>
4271
</div>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script lang="ts">
2+
import { cn } from '$lib/utils';
3+
4+
interface Props {
5+
class?: string;
6+
size?: number | string;
7+
}
8+
9+
let { class: className, size = 24 }: Props = $props();
10+
</script>
11+
12+
<svg
13+
role="img"
14+
class={cn('fill-black dark:fill-white', className)}
15+
width={size}
16+
height={size}
17+
viewBox="0 0 50 50"
18+
fill="none"
19+
xmlns="http://www.w3.org/2000/svg"
20+
>
21+
<title>Live Control</title>
22+
<path
23+
d="M 8.03125 8.4570312 C 7.770375 8.4589063 7.5103125 8.5625312 7.3203125 8.7695312 C 3.3953125 13.041531 1 18.741 1 25 C 1 31.259 3.3953125 36.958469 7.3203125 41.230469 C 7.7003125 41.644469 8.3569063 41.643094 8.7539062 41.246094 L 10.882812 39.117188 C 11.265812 38.734187 11.263391 38.124656 10.900391 37.722656 C 7.8553906 34.352656 6 29.889 6 25 C 6 20.111 7.8553906 15.647344 10.900391 12.277344 C 11.263391 11.875344 11.265813 11.266812 10.882812 10.882812 L 8.7539062 8.7539062 C 8.5554063 8.5554063 8.292125 8.4551562 8.03125 8.4570312 z M 41.96875 8.4570312 C 41.707625 8.4554062 41.444594 8.5554062 41.246094 8.7539062 L 39.115234 10.884766 C 38.732234 11.267766 38.734656 11.875344 39.097656 12.277344 C 42.143656 15.646344 44 20.111 44 25 C 44 29.889 42.144609 34.352656 39.099609 37.722656 C 38.736609 38.124656 38.734188 38.733187 39.117188 39.117188 L 41.246094 41.246094 C 41.643094 41.643094 42.299687 41.643469 42.679688 41.230469 C 46.604687 36.958469 49 31.259 49 25 C 49 18.741 46.604687 13.041531 42.679688 8.7695312 C 42.489688 8.5625312 42.229875 8.4586563 41.96875 8.4570312 z M 35.625 14.837891 C 35.355125 14.824516 35.079594 14.920406 34.871094 15.128906 L 32.740234 17.259766 C 32.381234 17.618766 32.341969 18.196938 32.667969 18.585938 C 34.123969 20.323937 35 22.561 35 25 C 35 27.439 34.123969 29.675109 32.667969 31.412109 C 32.341969 31.801109 32.381234 32.379281 32.740234 32.738281 L 34.871094 34.871094 C 35.288094 35.288094 35.967516 35.250687 36.353516 34.804688 C 38.625516 32.175687 40 28.748 40 25 C 40 21.252 38.625516 17.824312 36.353516 15.195312 C 36.160516 14.972313 35.894875 14.851266 35.625 14.837891 z M 14.375 14.839844 C 14.105125 14.853219 13.839484 14.974266 13.646484 15.197266 C 11.374484 17.825266 10 21.252 10 25 C 10 28.748 11.374484 32.175688 13.646484 34.804688 C 14.032484 35.250687 14.711906 35.288094 15.128906 34.871094 L 17.259766 32.740234 C 17.618766 32.381234 17.658031 31.803062 17.332031 31.414062 C 15.876031 29.676062 15 27.439 15 25 C 15 22.561 15.876031 20.324891 17.332031 18.587891 C 17.658031 18.198891 17.618766 17.620719 17.259766 17.261719 L 15.128906 15.128906 C 14.920406 14.920406 14.644875 14.826469 14.375 14.839844 z M 25 19 C 21.686 19 19 21.686 19 25 C 19 28.314 21.686 31 25 31 C 28.314 31 31 28.314 31 25 C 31 21.686 28.314 19 25 19 z"
24+
/></svg
25+
>
Lines changed: 128 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
<script lang="ts">
22
import Plus from '@lucide/svelte/icons/plus';
33
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
4-
import type { SortingState } from '@tanstack/table-core';
5-
import type { ColumnDef } from '@tanstack/table-core';
4+
import Link2 from '@lucide/svelte/icons/link-2';
5+
import Clock from '@lucide/svelte/icons/clock';
6+
import CircleAlert from '@lucide/svelte/icons/circle-alert';
7+
import Infinity_ from '@lucide/svelte/icons/infinity';
68
import { goto } from '$app/navigation';
79
import { resolve } from '$app/paths';
810
import { shareLinksList } from '$lib/api';
911
import type { OwnPublicShareResponse } from '$lib/api';
1012
import Container from '$lib/components/Container.svelte';
11-
import {
12-
CreateActionsColumnDef,
13-
CreateSortableColumnDef,
14-
LocaleDateTimeRenderer,
15-
RenderCell,
16-
TimeSinceRelativeOrNeverRenderer,
17-
} from '$lib/components/Table/ColumnUtils';
18-
import DataTable from '$lib/components/Table/DataTableTemplate.svelte';
13+
import CopyInput from '$lib/components/CopyInput.svelte';
14+
import LoadingCircle from '$lib/components/svg/LoadingCircle.svelte';
1915
import Button from '$lib/components/ui/button/button.svelte';
2016
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
17+
import { durationBetween, formatDuration, formatElapsed } from '$lib/utils';
18+
import { getSiteShortURL } from '$lib/utils/url';
2119
import { onMount } from 'svelte';
2220
import DataTableActions from './data-table-actions.svelte';
2321
import { registerBreadcrumbs } from '$lib/state/breadcrumbs-state.svelte';
@@ -26,22 +24,16 @@
2624
2725
registerBreadcrumbs(() => [{ label: 'Public Shares' }]);
2826
29-
const columns: ColumnDef<OwnPublicShareResponse>[] = [
30-
CreateSortableColumnDef('name', 'Name', RenderCell),
31-
CreateSortableColumnDef('createdOn', 'Created at', LocaleDateTimeRenderer),
32-
CreateSortableColumnDef('expiresOn', 'Expires', TimeSinceRelativeOrNeverRenderer),
33-
CreateActionsColumnDef(DataTableActions, (publicShare) => ({
34-
publicShare,
35-
onChange: refreshPublicShares,
36-
})),
37-
];
38-
3927
let data = $state<OwnPublicShareResponse[]>([]);
40-
let sorting = $state<SortingState>([]);
41-
28+
let loading = $state(true);
4229
let showAddShareModal = $state<boolean>(false);
4330
31+
const sortedShares = $derived(
32+
[...data].sort((a, b) => b.createdOn.epochMilliseconds - a.createdOn.epochMilliseconds)
33+
);
34+
4435
function refreshPublicShares() {
36+
loading = true;
4537
shareLinksList()
4638
.then((publicShares) => {
4739
if (publicShares.data === null) {
@@ -50,7 +42,31 @@
5042
}
5143
data = publicShares.data;
5244
})
53-
.catch(handleApiError);
45+
.catch(handleApiError)
46+
.finally(() => {
47+
loading = false;
48+
});
49+
}
50+
51+
const expiryToneClasses = {
52+
neutral: 'text-muted-foreground ring-border',
53+
warning: 'text-amber-600 dark:text-amber-400 ring-amber-500/30',
54+
danger: 'text-red-600 dark:text-red-400 ring-red-500/30',
55+
} as const;
56+
57+
function expiryInfo(expiresOn: Temporal.Instant | null | undefined) {
58+
if (!expiresOn) {
59+
return { label: 'Never expires', tone: 'neutral' as const };
60+
}
61+
const now = Temporal.Now.instant();
62+
const isExpired = expiresOn.epochMilliseconds <= now.epochMilliseconds;
63+
if (isExpired) {
64+
return { label: 'Expired', tone: 'danger' as const };
65+
}
66+
return {
67+
label: 'Expires ' + formatDuration(durationBetween(now, expiresOn)),
68+
tone: 'warning' as const,
69+
};
5470
}
5571
5672
onMount(() => {
@@ -61,25 +77,97 @@
6177
<CreatePublicShareDialog bind:open={showAddShareModal} onCreated={refreshPublicShares} />
6278

6379
<Container>
64-
<PageHeader
65-
title="Public Shares"
66-
subtitle="Think of them like a link that
67-
anyone can access"
68-
>
80+
<PageHeader title="Public Shares" subtitle="A link anyone can use — no account required.">
81+
<Button size="icon" variant="outline" onclick={refreshPublicShares} title="Refresh">
82+
<RotateCcw />
83+
</Button>
6984
<Button onclick={() => (showAddShareModal = true)}>
7085
<Plus />
71-
Add Share
72-
</Button>
73-
<Button size="icon" variant="outline" onclick={refreshPublicShares}>
74-
<RotateCcw />
86+
New Share
7587
</Button>
7688
</PageHeader>
77-
<div class="w-full overflow-auto">
78-
<DataTable
79-
{data}
80-
{columns}
81-
{sorting}
82-
onRowClick={(clicked) => goto(resolve(`/shares/public/${clicked.id}`))}
83-
/>
84-
</div>
89+
90+
{#if loading && data.length === 0}
91+
<div class="flex h-64 w-full items-center justify-center">
92+
<LoadingCircle />
93+
</div>
94+
{:else if sortedShares.length === 0}
95+
<div
96+
class="border-border/60 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed py-16"
97+
>
98+
<Link2 class="size-10 opacity-50" />
99+
<div class="text-center">
100+
<p class="text-foreground text-sm font-medium">No public shares yet</p>
101+
<p class="mt-1 text-xs">Create a link that anyone can use to control your shockers.</p>
102+
</div>
103+
<Button onclick={() => (showAddShareModal = true)} size="sm">
104+
<Plus />
105+
New Share
106+
</Button>
107+
</div>
108+
{:else}
109+
<div
110+
class="grid w-full grid-cols-1 gap-4 sm:[grid-template-columns:repeat(auto-fill,minmax(18rem,1fr))]"
111+
>
112+
{#each sortedShares as share (share.id)}
113+
{@const url = getSiteShortURL(`/s/${share.id}`)}
114+
{@const exp = expiryInfo(share.expiresOn)}
115+
<div
116+
role="button"
117+
tabindex="0"
118+
onclick={() => goto(resolve(`/shares/public/${share.id}`))}
119+
onkeydown={(e) => {
120+
if (e.key === 'Enter' || e.key === ' ') {
121+
e.preventDefault();
122+
goto(resolve(`/shares/public/${share.id}`));
123+
}
124+
}}
125+
class="border-border/60 bg-card hover:border-primary/40 hover:bg-accent/30 group mx-auto flex w-full max-w-md cursor-pointer flex-col gap-3 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:mx-0 sm:max-w-none"
126+
>
127+
<div class="flex items-start justify-between gap-2">
128+
<h2 class="min-w-0 flex-1 truncate text-base font-semibold" title={share.name}>
129+
{share.name}
130+
</h2>
131+
<!-- Stop click bubbling so menu items don't trigger row navigate -->
132+
<div
133+
role="presentation"
134+
onclick={(e) => e.stopPropagation()}
135+
onkeydown={(e) => e.stopPropagation()}
136+
class="shrink-0"
137+
>
138+
<DataTableActions publicShare={share} onChange={refreshPublicShares} />
139+
</div>
140+
</div>
141+
142+
<div role="presentation" onclick={(e) => e.stopPropagation()}>
143+
<CopyInput value={url.href} displayValue={url.host + url.pathname} />
144+
</div>
145+
146+
<div
147+
class="text-muted-foreground mt-auto flex items-center justify-between gap-2 pt-1 text-xs"
148+
>
149+
<span class="flex items-center gap-1.5" title={share.createdOn.toString()}>
150+
<Clock class="size-3.5" />
151+
{formatElapsed(durationBetween(Temporal.Now.instant(), share.createdOn))}
152+
</span>
153+
<span
154+
class="flex items-center gap-1.5 rounded-full px-2 py-0.5 ring-1 ring-inset {expiryToneClasses[
155+
exp.tone
156+
]}"
157+
title={share.expiresOn?.toString() ?? 'No expiry'}
158+
>
159+
{#if exp.tone === 'neutral'}
160+
<Infinity_ class="size-3.5" />
161+
{:else if exp.tone === 'danger'}
162+
<CircleAlert class="size-3.5" />
163+
{:else}
164+
<Clock class="size-3.5" />
165+
{/if}
166+
{exp.label}
167+
</span>
168+
</div>
169+
</div>
170+
{/each}
171+
</div>
172+
{/if}
85173
</Container>

0 commit comments

Comments
 (0)