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.
69 lines
2.1 KiB
Vue
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>
|