feat: enhance ClientTokenAdminSchema and related components with user avatar and updated timestamp

This commit is contained in:
zurdi
2026-05-26 08:29:17 +00:00
parent 2a35ca04e7
commit 4f7ac19248
6 changed files with 129 additions and 30 deletions

View File

@@ -21,6 +21,8 @@ class ClientTokenCreateSchema(ClientTokenSchema):
class ClientTokenAdminSchema(ClientTokenSchema):
username: str
user_avatar_path: str
user_updated_at: UTCDatetime
class ClientTokenPairSchema(BaseModel):

View File

@@ -105,6 +105,8 @@ def build_admin_schema(token: ClientToken) -> ClientTokenAdminSchema:
created_at=token.created_at,
user_id=token.user_id,
username=token.user.username,
user_avatar_path=token.user.avatar_path,
user_updated_at=token.user.updated_at,
)

View File

@@ -107,6 +107,7 @@
"mapping-types": "Mapping types",
"metadata-catalogs": "General metadata",
"metadata-get-key": "Get API key",
"metadata-proxies": "Match proxies",
"metadata-specialised": "Specialised sources",
"metadata-subtitle-achievements": "Achievements",
"metadata-subtitle-completion": "Completion times",

View File

@@ -16,6 +16,8 @@ export interface ClientTokenCreateSchema extends ClientTokenSchema {
export interface ClientTokenAdminSchema extends ClientTokenSchema {
username: string;
user_avatar_path: string;
user_updated_at: string;
}
export interface ClientTokenPairSchema {

View File

@@ -22,7 +22,7 @@ import { useI18n } from "vue-i18n";
import clientTokenApi, {
type ClientTokenAdminSchema,
} from "@/services/api/client-token";
import { formatTimestamp } from "@/utils";
import { defaultAvatarPath, formatTimestamp } from "@/utils";
import ScopeCell from "@/v2/components/Settings/ScopeCell.vue";
import { useConfirm } from "@/v2/composables/useConfirm";
import { useSnackbar } from "@/v2/composables/useSnackbar";
@@ -41,6 +41,12 @@ type SortKey = "username" | "name" | "expires_at" | "last_used_at";
const sortKey = ref<SortKey>("username");
const sortDir = ref<"asc" | "desc">("asc");
function avatarSrc(token: ClientTokenAdminSchema) {
return token.user_avatar_path
? `/assets/romm/assets/${token.user_avatar_path}?ts=${token.user_updated_at}`
: defaultAvatarPath;
}
function compareNullable(a: string | null, b: string | null, asc: boolean) {
if (!a && !b) return 0;
if (!a) return asc ? 1 : -1;
@@ -54,15 +60,11 @@ const sortedTokens = computed(() => {
list.sort((a, b) => {
if (sortKey.value === "username") {
return asc
? a.username.localeCompare(b.username) ||
a.name.localeCompare(b.name)
: b.username.localeCompare(a.username) ||
b.name.localeCompare(a.name);
? a.username.localeCompare(b.username) || a.name.localeCompare(b.name)
: b.username.localeCompare(a.username) || b.name.localeCompare(a.name);
}
if (sortKey.value === "name") {
return asc
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name);
return asc ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
}
return compareNullable(a[sortKey.value], b[sortKey.value], asc);
});
@@ -208,9 +210,14 @@ onMounted(fetchTokens);
@update:sort="onSort"
>
<template #cell.username="{ row }">
<span class="r-v2-admin-tokens__user">{{
(row as ClientTokenAdminSchema).username
}}</span>
<div class="r-v2-admin-tokens__user">
<img
:src="avatarSrc(row as ClientTokenAdminSchema)"
:alt="(row as ClientTokenAdminSchema).username"
class="r-v2-admin-tokens__avatar"
/>
<span>{{ (row as ClientTokenAdminSchema).username }}</span>
</div>
</template>
<template #cell.name="{ row }">
<span class="r-v2-admin-tokens__name">{{
@@ -272,11 +279,24 @@ onMounted(fetchTokens);
}
.r-v2-admin-tokens__user {
display: flex;
align-items: center;
gap: 10px;
font-weight: var(--r-font-weight-semibold);
min-width: 0;
}
.r-v2-admin-tokens__user span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.r-v2-admin-tokens__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.r-v2-admin-tokens__name {
font-weight: var(--r-font-weight-medium);

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
// MetadataSources — v2-native rewrite. Provider tiles grouped by
// category (game catalogs vs. specialised sources). Each tile shows:
// MetadataSources — v2-native rewrite. Provider tiles grouped into
// three categories (general catalogs, specialised sources, match
// proxies) matching the split used in the Setup wizard. Each tile
// shows:
// • A circular logo
// • Provider name + tone-coloured `RTag` status chip
// (missing key / invalid key / connected / checking)
@@ -30,6 +32,7 @@ const heartbeatStatus = ref<Record<string, boolean | undefined>>({
flashpoint: undefined,
hltb: undefined,
sgdb: undefined,
playmatch: undefined,
});
type SourceStatus = "missing" | "invalid" | "ok" | "pending";
@@ -58,15 +61,6 @@ const catalogs = computed<Source[]>(() => [
disabled: !heartbeat.value.METADATA_SOURCES?.IGDB_API_ENABLED,
heartbeat: heartbeatStatus.value.igdb,
},
{
name: "MobyGames",
value: "moby",
logo: "/assets/scrappers/moby.png",
website: "https://www.mobygames.com",
docsUrl: "https://www.mobygames.com/info/api/",
disabled: !heartbeat.value.METADATA_SOURCES?.MOBY_API_ENABLED,
heartbeat: heartbeatStatus.value.moby,
},
{
name: "ScreenScraper",
value: "ss",
@@ -77,13 +71,13 @@ const catalogs = computed<Source[]>(() => [
heartbeat: heartbeatStatus.value.ss,
},
{
name: "Hasheous",
value: "hasheous",
logo: "/assets/scrappers/hasheous.png",
website: "https://hasheous.org",
docsUrl: "https://hasheous.org/index.html?page=apidocs",
disabled: !heartbeat.value.METADATA_SOURCES?.HASHEOUS_API_ENABLED,
heartbeat: heartbeatStatus.value.hasheous,
name: "MobyGames",
value: "moby",
logo: "/assets/scrappers/moby.png",
website: "https://www.mobygames.com",
docsUrl: "https://www.mobygames.com/info/api/",
disabled: !heartbeat.value.METADATA_SOURCES?.MOBY_API_ENABLED,
heartbeat: heartbeatStatus.value.moby,
},
{
name: "LaunchBox",
@@ -138,6 +132,27 @@ const specialised = computed<Source[]>(() => [
},
]);
const proxies = computed<Source[]>(() => [
{
name: "Hasheous",
value: "hasheous",
logo: "/assets/scrappers/hasheous.png",
website: "https://hasheous.org",
docsUrl: "https://hasheous.org/index.html?page=apidocs",
disabled: !heartbeat.value.METADATA_SOURCES?.HASHEOUS_API_ENABLED,
heartbeat: heartbeatStatus.value.hasheous,
},
{
name: "PlayMatch",
value: "playmatch",
logo: "/assets/scrappers/playmatch.png",
website: "https://github.com/RetroRealm/playmatch",
docsUrl: "https://github.com/RetroRealm/playmatch",
disabled: !heartbeat.value.METADATA_SOURCES?.PLAYMATCH_API_ENABLED,
heartbeat: heartbeatStatus.value.playmatch,
},
]);
function statusOf(source: Source): SourceStatus {
if (source.disabled) return "missing";
if (source.heartbeat === true) return "ok";
@@ -168,7 +183,7 @@ function statusLabel(status: SourceStatus): string {
}
async function fetchAllHeartbeats() {
const all = [...catalogs.value, ...specialised.value];
const all = [...catalogs.value, ...specialised.value, ...proxies.value];
await Promise.all(
all
.filter((source) => !source.disabled)
@@ -300,6 +315,63 @@ onMounted(() => {
</article>
</div>
</SettingsSection>
<SettingsSection
:title="t('settings.metadata-proxies')"
icon="mdi-swap-horizontal-bold"
>
<div class="r-v2-meta__grid">
<article
v-for="source in proxies"
:key="source.value"
class="r-v2-meta__card"
:class="{
'r-v2-meta__card--missing': statusOf(source) === 'missing',
}"
>
<header class="r-v2-meta__header">
<div class="r-v2-meta__logo">
<img :src="source.logo" :alt="source.name" />
</div>
<div class="r-v2-meta__head-text">
<span class="r-v2-meta__name">{{ source.name }}</span>
<span v-if="source.subtitle" class="r-v2-meta__subtitle">
{{ source.subtitle }}
</span>
<RTag
:tone="statusTone(statusOf(source))"
:icon="statusIcon(statusOf(source))"
:text="statusLabel(statusOf(source))"
size="x-small"
/>
</div>
</header>
<div class="r-v2-meta__actions">
<RBtn
variant="translucent"
size="small"
prepend-icon="mdi-key-variant"
:href="source.docsUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ t("settings.metadata-get-key") }}
</RBtn>
<RBtn
variant="text"
size="small"
prepend-icon="mdi-open-in-new"
:href="source.website"
target="_blank"
rel="noopener noreferrer"
>
{{ t("settings.metadata-website") }}
</RBtn>
</div>
</article>
</div>
</SettingsSection>
</div>
</template>