drop/server/components/PlaytimeRail.vue
wdunn001 c21e69aa5f
Some checks failed
Server CI / Lint (push) Failing after 3m38s
Server CI / Typecheck (push) Successful in 5m36s
community: M2 -- device enrollment, wrapper ingest, playtime, played-only listing
Adds the M2 slice of the community layer: Device/DeviceToken/EnrollmentCode/
PlaySession/AchievementUnlock (migration m2_devices_playtime), the
device-token-authenticated ingest surface (POST /api/v1/ingest/enroll,
session-start, session-stop, achievements -- every success acks 2xx +
{"ok":true}, idempotent on sessionId), the /community/devices enrollment UI,
a PowerShell launch wrapper (homelab-compose/drop-stack/wrapper), and unified
playtime rails across Drop + RomM on the profile page.

Also implements the community titles listing change: default view is now
played-only (PlaySession + native Playtime, both platforms), sorted by most
recently played, with a friendly empty state and a "browse all" toggle back
to the full catalog. Adds a live-activity panel (now playing / recently
played) sourced from PlaySession, and a clearly-labeled server-status slot
for M4 to fill in.

PlaySession is the join table for M3/M4/M5: userId, deviceId, titlePlatform
('drop'|'romm'), gameId, communityTitleId, steamAppId, sessionId (unique),
startedAt, endedAt, durationSeconds, endReason, exitCode. No stale-session
sweeper in this pass -- "currently playing" is endedAt IS NULL, filtered to
sessions started within the last 12h for the live panel.
2026-08-03 01:08:39 -04:00

69 lines
2.1 KiB
Vue

<template>
<div class="flex flex-col gap-y-4">
<h2 class="text-xl font-bold font-display text-zinc-100">{{ title }}</h2>
<div class="flex flex-row flex-wrap justify-left gap-4">
<component
:is="entry.platform === 'drop' ? resolveComponent('NuxtLink') : 'a'"
v-for="entry in entries"
:key="`${entry.platform}-${entry.id}`"
v-bind="
entry.platform === 'drop'
? { to: entry.deepLink }
: { href: entry.deepLink, target: '_blank', rel: 'noopener' }
"
class="group relative flex-1 min-w-42 max-w-48 h-64 rounded-lg overflow-hidden transition-all duration-300 hover:scale-[1.02] hover:shadow-lg"
>
<div class="absolute inset-0">
<img
v-if="entry.coverUrl"
:src="entry.coverUrl"
class="w-full h-full object-cover brightness-[90%]"
:alt="entry.name"
/>
<div
v-else
class="w-full h-full bg-zinc-800 flex items-center justify-center text-zinc-600 text-xs px-2 text-center"
>
{{ entry.name }}
</div>
<div
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/0 to-transparent"
/>
</div>
<div class="absolute bottom-0 left-0 w-full p-3">
<h3
class="text-zinc-100 text-sm font-bold font-display line-clamp-2 group-hover:text-white transition-colors"
>
{{ entry.name }}
</h3>
<p class="text-zinc-400 text-xs">
{{ formatSeconds(entry.seconds) }}
</p>
</div>
</component>
</div>
</div>
</template>
<script setup lang="ts">
interface PlaytimeRailEntry {
id: string;
platform: "drop" | "romm";
name: string;
coverUrl: string | null;
deepLink: string;
seconds: number;
lastPlayedAt: string;
}
defineProps<{
title: string;
entries: PlaytimeRailEntry[];
}>();
function formatSeconds(seconds: number): string {
const hours = seconds / 3600;
if (hours < 1) return `${Math.round(seconds / 60)}m`;
return `${Math.round(hours * 10) / 10}h`;
}
</script>