feat: add DeviceCard.svelte phone-frame component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
166
web/src/lib/components/DeviceCard.svelte
Normal file
166
web/src/lib/components/DeviceCard.svelte
Normal file
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
status: 'online' | 'offline';
|
||||
model: string | null;
|
||||
manufacturer: string | null;
|
||||
androidVersion: string | null;
|
||||
screenWidth: number | null;
|
||||
screenHeight: number | null;
|
||||
batteryLevel: number | null;
|
||||
isCharging: boolean;
|
||||
lastSeen: string;
|
||||
lastGoal: { goal: string; status: string; startedAt: string } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
deviceId,
|
||||
name,
|
||||
status,
|
||||
model,
|
||||
manufacturer,
|
||||
androidVersion,
|
||||
batteryLevel,
|
||||
isCharging,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
lastSeen,
|
||||
lastGoal
|
||||
}: Props = $props();
|
||||
|
||||
function relativeTime(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function batteryIcon(level: number | null, charging: boolean): string {
|
||||
if (level === null || level < 0) return '?';
|
||||
if (charging) return '⚡';
|
||||
if (level > 75) return '█';
|
||||
if (level > 50) return '▆';
|
||||
if (level > 25) return '▄';
|
||||
return '▂';
|
||||
}
|
||||
</script>
|
||||
|
||||
<a href="/dashboard/devices/{deviceId}" class="group block">
|
||||
<!-- Phone frame -->
|
||||
<div
|
||||
class="relative mx-auto w-48 overflow-hidden rounded-[2rem] border-2 bg-white transition-all
|
||||
{status === 'online'
|
||||
? 'border-green-400 shadow-[0_0_15px_rgba(74,222,128,0.2)]'
|
||||
: 'border-neutral-200 opacity-60'}
|
||||
group-hover:shadow-lg"
|
||||
style="aspect-ratio: 9 / 18;"
|
||||
>
|
||||
<!-- Notch -->
|
||||
<div
|
||||
class="absolute left-1/2 top-2 h-1.5 w-12 -translate-x-1/2 rounded-full bg-neutral-200"
|
||||
></div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="flex items-center justify-between px-4 pb-2 pt-5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-1.5 w-1.5 rounded-full {status === 'online'
|
||||
? 'bg-green-500'
|
||||
: 'bg-neutral-300'}"
|
||||
></span>
|
||||
<span class="text-[10px] text-neutral-400"
|
||||
>{status === 'online' ? 'Online' : 'Offline'}</span
|
||||
>
|
||||
</div>
|
||||
{#if batteryLevel !== null && batteryLevel >= 0}
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="text-[10px] {batteryLevel <= 20 ? 'text-red-500' : 'text-neutral-400'}">
|
||||
{batteryIcon(batteryLevel, isCharging)}
|
||||
</span>
|
||||
<span class="text-[10px] {batteryLevel <= 20 ? 'text-red-500' : 'text-neutral-400'}">
|
||||
{batteryLevel}%
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-1 flex-col items-center px-4 pt-4">
|
||||
<!-- Device icon -->
|
||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-neutral-100">
|
||||
<svg
|
||||
class="h-5 w-5 text-neutral-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Device name -->
|
||||
<p class="text-center text-xs font-semibold leading-tight text-neutral-800">
|
||||
{model ?? name}
|
||||
</p>
|
||||
{#if manufacturer}
|
||||
<p class="text-[10px] text-neutral-400">{manufacturer}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Specs -->
|
||||
<div class="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{#if androidVersion}
|
||||
<span class="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[9px] text-neutral-500">
|
||||
Android {androidVersion}
|
||||
</span>
|
||||
{/if}
|
||||
{#if screenWidth && screenHeight}
|
||||
<span class="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[9px] text-neutral-500">
|
||||
{screenWidth}x{screenHeight}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: last goal -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 border-t border-neutral-100 bg-neutral-50/80 px-3 py-2"
|
||||
>
|
||||
{#if lastGoal}
|
||||
<p class="truncate text-[10px] text-neutral-600">{lastGoal.goal}</p>
|
||||
<div class="mt-0.5 flex items-center justify-between">
|
||||
<span
|
||||
class="text-[9px] font-medium {lastGoal.status === 'completed'
|
||||
? 'text-green-600'
|
||||
: lastGoal.status === 'running'
|
||||
? 'text-amber-600'
|
||||
: 'text-red-500'}"
|
||||
>
|
||||
{lastGoal.status === 'completed'
|
||||
? 'Success'
|
||||
: lastGoal.status === 'running'
|
||||
? 'Running'
|
||||
: 'Failed'}
|
||||
</span>
|
||||
<span class="text-[9px] text-neutral-400">{relativeTime(lastGoal.startedAt)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-[10px] italic text-neutral-400">No goals yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Home indicator -->
|
||||
<div
|
||||
class="absolute bottom-8 left-1/2 h-1 w-8 -translate-x-1/2 rounded-full bg-neutral-200"
|
||||
></div>
|
||||
</div>
|
||||
</a>
|
||||
Reference in New Issue
Block a user