Skip to content

Commit c221680

Browse files
committed
feat: replace sessions table with responsive card layout
Replaces the data table on the sessions page with a card-based layout that works well on both mobile and desktop. Each card shows device type icon, parsed user agent, IP, and relative timestamps. Current session is visually distinguished with a green tint and "This device" badge. Also tightens mobile padding in Container from px-4 to px-2 globally.
1 parent a33c967 commit c221680

5 files changed

Lines changed: 137 additions & 87 deletions

File tree

src/lib/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export {
5656
publicGetOnlineDevicesStatistics,
5757
publicGetPublicShare,
5858
sessionsDeleteSession,
59+
sessionsGetSelfSession,
5960
sessionsListSessions,
6061
shareLinksAddShocker,
6162
shareLinksCreatePublicShare,

src/lib/components/Container.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
<div
1414
class={cn(
15-
'container mx-auto flex h-full flex-col items-start justify-start gap-4 px-4 pt-4 pb-1 sm:px-12 sm:pt-12 sm:pb-2',
15+
'container mx-auto flex h-full flex-col items-start justify-start gap-4 px-2 pt-4 pb-1 sm:px-12 sm:pt-12 sm:pb-2',
1616
className
1717
)}
1818
>
Lines changed: 26 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,51 @@
11
<script lang="ts">
2-
import { sessionsListSessions } from '$lib/api';
2+
import { sessionsListSessions, sessionsGetSelfSession } from '$lib/api';
33
import type { LoginSessionResponse } from '$lib/api';
4-
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
5-
import type { ColumnDef, SortingState } from '@tanstack/table-core';
64
import Container from '$lib/components/Container.svelte';
7-
import {
8-
CreateActionsColumnDef,
9-
CreateColumnDef,
10-
CreateSortableColumnDef,
11-
RenderCell,
12-
TimeSinceRelativeOrNeverRenderer,
13-
TimeSinceRelativeRenderer,
14-
UserAgentRenderer,
15-
} from '$lib/components/Table/ColumnUtils';
16-
import DataTable from '$lib/components/Table/DataTableTemplate.svelte';
17-
import Button from '$lib/components/ui/button/button.svelte';
185
import PageHeader from '$lib/components/PageHeader.svelte';
196
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
207
import { onMount } from 'svelte';
21-
import { toast } from 'svelte-sonner';
228
import { registerBreadcrumbs } from '$lib/state/breadcrumbs-state.svelte';
23-
import DataTableActions from './data-table-actions.svelte';
9+
import SessionCard from './session-card.svelte';
2410
2511
registerBreadcrumbs(() => [
2612
{ label: 'Settings', href: '/settings/account' },
2713
{ label: 'Sessions' },
2814
]);
2915
30-
let data = $state<LoginSessionResponse[]>([]);
31-
let sorting = $state<SortingState>([]);
16+
let sessions = $state<LoginSessionResponse[]>([]);
17+
let currentSessionId = $state<string | null>(null);
3218
3319
function onRevoked(sessionId: string) {
34-
const idx = data.findIndex((session) => session.id === sessionId);
35-
if (idx === -1) return;
36-
37-
data.splice(idx, 1);
20+
const idx = sessions.findIndex((s) => s.id === sessionId);
21+
if (idx !== -1) sessions.splice(idx, 1);
3822
}
3923
40-
const columns: ColumnDef<LoginSessionResponse>[] = [
41-
CreateColumnDef('ip', 'Ip', RenderCell),
42-
CreateSortableColumnDef('userAgent', 'User Agent', UserAgentRenderer),
43-
CreateSortableColumnDef('created', 'Created', TimeSinceRelativeRenderer),
44-
CreateSortableColumnDef('expires', 'Expires', TimeSinceRelativeRenderer),
45-
CreateSortableColumnDef('lastUsed', 'Last seen', TimeSinceRelativeOrNeverRenderer),
46-
CreateActionsColumnDef(DataTableActions, (session) => ({ session, onRevoked })),
47-
];
24+
// Sort so current session is always first
25+
const sortedSessions = $derived(
26+
[...sessions].sort((a, b) => {
27+
if (a.id === currentSessionId) return -1;
28+
if (b.id === currentSessionId) return 1;
29+
return 0;
30+
})
31+
);
4832
4933
async function fetchSessions() {
5034
try {
51-
data = await sessionsListSessions();
35+
[sessions, { id: currentSessionId }] = await Promise.all([
36+
sessionsListSessions(),
37+
sessionsGetSelfSession(),
38+
]);
5239
} catch (error) {
5340
await handleApiError(error);
5441
}
5542
}
5643
57-
async function onRefreshClicked() {
58-
await fetchSessions();
59-
toast.success('Sessions refreshed successfully');
60-
}
61-
6244
onMount(() => {
6345
fetchSessions();
6446
65-
// Update timestamps every 5 seconds
6647
const interval = setInterval(() => {
67-
if (data) {
68-
data = Object.assign([], data);
69-
}
48+
if (sessions.length) sessions = [...sessions];
7049
}, 5000);
7150
7251
return () => clearInterval(interval);
@@ -77,11 +56,11 @@
7756
<PageHeader
7857
title="Sessions"
7958
subtitle="This is a list of all active sessions of your account. Revoke any sessions you do not recognize."
80-
>
81-
<Button onclick={onRefreshClicked}>
82-
<RotateCcw />
83-
Refresh
84-
</Button>
85-
</PageHeader>
86-
<DataTable {data} {columns} {sorting} />
59+
/>
60+
61+
<div class="flex w-full flex-col gap-3">
62+
{#each sortedSessions as session (session.id)}
63+
<SessionCard {session} isCurrent={session.id === currentSessionId} {onRevoked} />
64+
{/each}
65+
</div>
8766
</Container>

src/routes/(app)/settings/sessions/data-table-actions.svelte

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<script lang="ts">
2+
import type { LoginSessionResponse } from '$lib/api';
3+
import { Badge } from '$lib/components/ui/badge';
4+
import { Button } from '$lib/components/ui/button';
5+
import { formatElapsed, durationBetween } from '$lib/utils/temporal';
6+
import { getReadableUserAgentName } from '$lib/utils/userAgent';
7+
import { UAParser } from 'ua-parser-js';
8+
import Monitor from '@lucide/svelte/icons/monitor';
9+
import Smartphone from '@lucide/svelte/icons/smartphone';
10+
import Tablet from '@lucide/svelte/icons/tablet';
11+
import Cpu from '@lucide/svelte/icons/cpu';
12+
import Tv from '@lucide/svelte/icons/tv';
13+
import Gamepad2 from '@lucide/svelte/icons/gamepad-2';
14+
import X from '@lucide/svelte/icons/x';
15+
import SessionRevokeDialog from './dialog-session-revoke.svelte';
16+
17+
interface Props {
18+
session: LoginSessionResponse;
19+
isCurrent: boolean;
20+
onRevoked: (sessionId: string) => void;
21+
}
22+
23+
let { session, isCurrent, onRevoked }: Props = $props();
24+
25+
let revokeDialogOpen = $state(false);
26+
27+
const deviceIconMap: Record<string, typeof Monitor> = {
28+
mobile: Smartphone,
29+
tablet: Tablet,
30+
embedded: Cpu,
31+
smarttv: Tv,
32+
console: Gamepad2,
33+
};
34+
35+
const DeviceIcon = $derived.by(() => {
36+
const ua = new UAParser(session.userAgent);
37+
const type = ua.getDevice().type ?? 'desktop';
38+
return deviceIconMap[type] ?? Monitor;
39+
});
40+
41+
const readableName = $derived(
42+
getReadableUserAgentName(session.userAgent) ?? session.userAgent ?? 'Unknown device'
43+
);
44+
45+
const now = $derived(Temporal.Now.instant());
46+
47+
// Past events: durationBetween(now, pastInstant) → negative → "X ago"
48+
const lastSeenText = $derived(
49+
session.lastUsed ? formatElapsed(durationBetween(now, session.lastUsed)) : 'Never'
50+
);
51+
const createdText = $derived(formatElapsed(durationBetween(now, session.created)));
52+
// Future event: durationBetween(now, futureInstant) → positive → "in X"
53+
const expiresText = $derived(formatElapsed(durationBetween(now, session.expires)));
54+
</script>
55+
56+
<SessionRevokeDialog bind:open={revokeDialogOpen} {session} {onRevoked} />
57+
58+
<div
59+
class="flex flex-col gap-3 rounded-lg border p-4 transition-colors {isCurrent
60+
? 'border-green-600/50 bg-green-950/20'
61+
: 'hover:bg-muted/30'}"
62+
>
63+
<!-- Header row: icon + name + badge + revoke -->
64+
<div class="flex items-start gap-3">
65+
<div
66+
class="mt-0.5 shrink-0 rounded-md p-1.5 {isCurrent
67+
? 'bg-green-900/40 text-green-400'
68+
: 'bg-muted text-muted-foreground'}"
69+
>
70+
<DeviceIcon class="size-5" />
71+
</div>
72+
73+
<div class="min-w-0 flex-1">
74+
<div class="flex items-center gap-2 overflow-hidden">
75+
<span class="min-w-0 truncate font-semibold leading-tight">{readableName}</span>
76+
{#if isCurrent}
77+
<Badge variant="outline" class="shrink-0 border-green-600 text-xs text-green-400">
78+
This device
79+
</Badge>
80+
{/if}
81+
</div>
82+
<div class="text-muted-foreground mt-0.5 font-mono text-xs">{session.ip}</div>
83+
</div>
84+
85+
{#if !isCurrent}
86+
<Button
87+
variant="ghost"
88+
size="icon"
89+
class="text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
90+
onclick={() => (revokeDialogOpen = true)}
91+
title="Revoke session"
92+
>
93+
<X class="size-4" />
94+
</Button>
95+
{/if}
96+
</div>
97+
98+
<!-- Timestamps -->
99+
<div class="text-muted-foreground flex flex-col gap-0.5 pl-10 text-sm">
100+
{#if !isCurrent}
101+
<span>Last seen {lastSeenText}</span>
102+
{/if}
103+
<span>
104+
Created {createdText}
105+
{#if !isCurrent}
106+
· Expires {expiresText}{/if}
107+
</span>
108+
</div>
109+
</div>

0 commit comments

Comments
 (0)