feat: production-harden app and finalize merge-ready UX/theme baseline
- complete tokenized theme preset system and admin style guide visibility - refine dashboard layout/scroll behavior and shared async/request UI states - add user nav-visibility settings across API, migration, and frontend stores - harden security headers/CSP handling and related test coverage - enforce CI repository hygiene + frontend contract/theme/build quality gates - update docs/env references and make migration smoke test head-aware
This commit is contained in:
parent
ab1d29b203
commit
ae2ca8f149
66 changed files with 5655 additions and 853 deletions
|
|
@ -2,7 +2,9 @@
|
|||
import { computed } from "vue";
|
||||
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
import type { ThemePresetId } from "@/theme/presets";
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
|
|
@ -10,6 +12,12 @@ const isDarkTheme = computed(() => theme.active === "dark");
|
|||
const toggleThemeLabel = computed(() =>
|
||||
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
|
||||
);
|
||||
const selectedPreset = computed<ThemePresetId>({
|
||||
get: () => theme.preset,
|
||||
set: (value) => theme.setPreset(value),
|
||||
});
|
||||
const presetOptions = computed(() => theme.availablePresets);
|
||||
const themePresetLabel = computed(() => theme.presetLabel);
|
||||
|
||||
function onToggleTheme(): void {
|
||||
theme.setPreference(isDarkTheme.value ? "light" : "dark");
|
||||
|
|
@ -17,18 +25,32 @@ function onToggleTheme(): void {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-30 border-b border-zinc-200/70 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90">
|
||||
<header class="sticky top-0 z-30 border-b border-stroke-subtle bg-surface-nav/90 backdrop-blur">
|
||||
<div class="flex min-h-[4.5rem] w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<RouterLink
|
||||
to="/dashboard"
|
||||
class="rounded-sm font-display text-lg tracking-tight text-zinc-900 transition hover:text-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-100 dark:hover:text-zinc-300 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
class="rounded-sm font-display text-lg tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
>
|
||||
scholarr
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div class="w-36 sm:w-44">
|
||||
<label for="theme-preset-select" class="sr-only">Theme preset</label>
|
||||
<AppSelect
|
||||
id="theme-preset-select"
|
||||
v-model="selectedPreset"
|
||||
:disabled="presetOptions.length <= 1"
|
||||
:title="`Theme preset: ${themePresetLabel}`"
|
||||
class="py-1.5"
|
||||
>
|
||||
<option v-for="preset in presetOptions" :key="preset.id" :value="preset.id">
|
||||
{{ preset.label }}
|
||||
</option>
|
||||
</AppSelect>
|
||||
</div>
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
class="h-10 w-10 rounded-full p-0"
|
||||
|
|
|
|||
|
|
@ -4,24 +4,29 @@ import { useRouter } from "vue-router";
|
|||
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
const router = useRouter();
|
||||
|
||||
const links = computed(() => {
|
||||
const base = [
|
||||
{ to: "/dashboard", label: "Dashboard" },
|
||||
{ to: "/scholars", label: "Scholars" },
|
||||
{ to: "/publications", label: "Publications" },
|
||||
{ to: "/settings", label: "Settings" },
|
||||
{ id: "dashboard", to: "/dashboard", label: "Dashboard", adminOnly: false },
|
||||
{ id: "scholars", to: "/scholars", label: "Scholars", adminOnly: false },
|
||||
{ id: "publications", to: "/publications", label: "Publications", adminOnly: false },
|
||||
{ id: "settings", to: "/settings", label: "Settings", adminOnly: false },
|
||||
{ id: "style-guide", to: "/admin/style-guide", label: "Style Guide", adminOnly: true },
|
||||
{ id: "runs", to: "/admin/runs", label: "Runs", adminOnly: true },
|
||||
{ id: "users", to: "/admin/users", label: "Users", adminOnly: true },
|
||||
];
|
||||
|
||||
if (auth.isAdmin) {
|
||||
base.push({ to: "/admin/runs", label: "Runs" });
|
||||
base.push({ to: "/admin/users", label: "Users" });
|
||||
}
|
||||
|
||||
return base;
|
||||
return base.filter((item) => {
|
||||
if (item.adminOnly && !auth.isAdmin) {
|
||||
return false;
|
||||
}
|
||||
return userSettings.isPageVisible(item.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
|
|
@ -32,7 +37,7 @@ async function onLogout(): Promise<void> {
|
|||
|
||||
<template>
|
||||
<aside
|
||||
class="min-w-0 border-b border-zinc-200 bg-white/70 px-4 py-4 dark:border-zinc-800 dark:bg-zinc-900/70 lg:h-full lg:min-h-0 lg:overflow-y-auto lg:border-b-0 lg:border-r lg:px-5 lg:py-6"
|
||||
class="min-h-0 min-w-0 overflow-x-hidden border-b border-stroke-subtle bg-surface-nav/70 px-4 py-4 lg:h-full lg:border-b-0 lg:border-r lg:px-5 lg:py-6"
|
||||
>
|
||||
<div class="flex h-full min-h-0 flex-col gap-4">
|
||||
<nav class="grid grid-cols-2 content-start gap-2 sm:grid-cols-3 lg:grid-cols-1" aria-label="Primary">
|
||||
|
|
@ -40,15 +45,15 @@ async function onLogout(): Promise<void> {
|
|||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-zinc-600 transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
active-class="border-zinc-900 bg-zinc-900 text-white hover:border-zinc-900 hover:bg-zinc-900 hover:text-white dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:border-brand-300 dark:hover:bg-brand-300"
|
||||
class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-ink-secondary transition hover:border-stroke-interactive hover:bg-surface-card-muted hover:text-ink-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
active-class="border-stroke-interactive bg-surface-nav-active font-semibold text-ink-primary shadow-sm hover:border-stroke-interactive hover:bg-surface-nav-active hover:text-ink-primary"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto grid gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">{{ auth.user?.email }}</p>
|
||||
<div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
|
||||
<p class="truncate text-xs text-ink-muted">{{ auth.user?.email }}</p>
|
||||
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
fill?: boolean;
|
||||
}>(),
|
||||
{
|
||||
fill: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack min-w-0">
|
||||
<section
|
||||
:class="
|
||||
props.fill
|
||||
? 'min-w-0 flex min-h-0 flex-col gap-6 lg:h-full'
|
||||
: 'page-stack min-w-0'
|
||||
"
|
||||
>
|
||||
<header class="space-y-2">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
<h1 class="page-title">{{ props.title }}</h1>
|
||||
<p v-if="props.subtitle" class="page-subtitle">{{ props.subtitle }}</p>
|
||||
</header>
|
||||
<slot />
|
||||
<div v-if="props.fill" class="min-h-0 flex-1">
|
||||
<slot />
|
||||
</div>
|
||||
<slot v-else />
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
|||
34
frontend/src/components/patterns/AsyncStateGate.vue
Normal file
34
frontend/src/components/patterns/AsyncStateGate.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading: boolean;
|
||||
loadingLines?: number;
|
||||
empty?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyBody?: string;
|
||||
showEmpty?: boolean;
|
||||
}>(),
|
||||
{
|
||||
loadingLines: 6,
|
||||
empty: false,
|
||||
emptyTitle: "No data available",
|
||||
emptyBody: "No records matched this request.",
|
||||
showEmpty: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSkeleton v-if="props.loading" :lines="props.loadingLines" />
|
||||
<AppEmptyState
|
||||
v-else-if="props.showEmpty && props.empty"
|
||||
:title="props.emptyTitle"
|
||||
:body="props.emptyBody"
|
||||
>
|
||||
<slot name="empty" />
|
||||
</AppEmptyState>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
|
@ -8,14 +8,19 @@ const props = defineProps<{
|
|||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex flex-wrap items-center gap-2 rounded-full border border-zinc-300 bg-zinc-100 px-3 py-1 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
class="inline-flex flex-wrap items-center gap-2 rounded-xl border border-stroke-default bg-surface-card-muted/90 px-3 py-1.5 text-xs text-ink-secondary"
|
||||
>
|
||||
<span class="font-medium">Queue</span>
|
||||
<span class="rounded-full bg-zinc-200 px-2 py-0.5 dark:bg-zinc-700">{{ props.queued }} queued</span>
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
|
||||
<span class="font-semibold tracking-wide text-ink-primary">Queue</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-ink-muted" />
|
||||
{{ props.queued }} queued
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-state-warning-bg px-2 py-0.5 text-state-warning-text">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-warning-500" />
|
||||
{{ props.retrying }} retrying
|
||||
</span>
|
||||
<span class="rounded-full bg-rose-100 px-2 py-0.5 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-state-danger-bg px-2 py-0.5 text-state-danger-text">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-danger-500" />
|
||||
{{ props.dropped }} dropped
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
|||
44
frontend/src/components/patterns/RequestStateAlerts.vue
Normal file
44
frontend/src/components/patterns/RequestStateAlerts.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script setup lang="ts">
|
||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
successMessage?: string | null;
|
||||
successTitle?: string;
|
||||
errorMessage?: string | null;
|
||||
errorRequestId?: string | null;
|
||||
errorTitle?: string;
|
||||
}>(),
|
||||
{
|
||||
successMessage: null,
|
||||
successTitle: "Operation complete",
|
||||
errorMessage: null,
|
||||
errorRequestId: null,
|
||||
errorTitle: "Request failed",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismissSuccess: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<AppAlert
|
||||
v-if="props.successMessage"
|
||||
tone="success"
|
||||
dismissible
|
||||
@dismiss="emit('dismissSuccess')"
|
||||
>
|
||||
<template #title>{{ props.successTitle }}</template>
|
||||
<p>{{ props.successMessage }}</p>
|
||||
</AppAlert>
|
||||
|
||||
<AppAlert v-if="props.errorMessage" tone="danger">
|
||||
<template #title>{{ props.errorTitle }}</template>
|
||||
<p>{{ props.errorMessage }}</p>
|
||||
<p class="text-secondary">Request ID: {{ props.errorRequestId || "n/a" }}</p>
|
||||
</AppAlert>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -9,18 +9,18 @@ const label = computed(() => props.status.replaceAll("_", " "));
|
|||
|
||||
const toneClass = computed(() => {
|
||||
if (props.status === "success") {
|
||||
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300";
|
||||
return "border-state-success-border bg-state-success-bg text-state-success-text";
|
||||
}
|
||||
if (props.status === "partial_failure") {
|
||||
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300";
|
||||
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||
}
|
||||
if (props.status === "failed") {
|
||||
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300";
|
||||
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
|
||||
}
|
||||
if (props.status === "running") {
|
||||
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300";
|
||||
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||
}
|
||||
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
|
||||
return "border-stroke-default bg-surface-card-muted text-ink-secondary";
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,15 +18,15 @@ const emit = defineEmits<{ dismiss: [] }>();
|
|||
|
||||
const toneClass = computed(() => {
|
||||
if (props.tone === "danger") {
|
||||
return "border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-800 dark:bg-rose-950/45 dark:text-rose-200";
|
||||
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
|
||||
}
|
||||
if (props.tone === "warning") {
|
||||
return "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/45 dark:text-amber-200";
|
||||
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||
}
|
||||
if (props.tone === "success") {
|
||||
return "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/45 dark:text-emerald-200";
|
||||
return "border-state-success-border bg-state-success-bg text-state-success-text";
|
||||
}
|
||||
return "border-brand-300 bg-brand-50 text-brand-900 dark:border-brand-800 dark:bg-brand-950/45 dark:text-brand-200";
|
||||
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||
});
|
||||
|
||||
const alertRole = computed(() => (props.tone === "danger" || props.tone === "warning" ? "alert" : "status"));
|
||||
|
|
|
|||
|
|
@ -7,22 +7,22 @@ const props = defineProps<{
|
|||
|
||||
const toneClass = computed(() => {
|
||||
if (props.tone === "success") {
|
||||
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300";
|
||||
return "border-state-success-border bg-state-success-bg text-state-success-text";
|
||||
}
|
||||
|
||||
if (props.tone === "warning") {
|
||||
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300";
|
||||
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||
}
|
||||
|
||||
if (props.tone === "danger") {
|
||||
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300";
|
||||
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
|
||||
}
|
||||
|
||||
if (props.tone === "info") {
|
||||
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300";
|
||||
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||
}
|
||||
|
||||
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
|
||||
return "border-stroke-default bg-surface-card-muted text-ink-secondary";
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,24 +16,24 @@ const props = withDefaults(
|
|||
|
||||
const variantClass = computed(() => {
|
||||
if (props.variant === "secondary") {
|
||||
return "border-zinc-300 bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700";
|
||||
return "border-action-secondary-border bg-action-secondary-bg text-action-secondary-text shadow-sm hover:border-action-secondary-hover-border hover:bg-action-secondary-hover-bg hover:text-action-secondary-hover-text";
|
||||
}
|
||||
|
||||
if (props.variant === "ghost") {
|
||||
return "border-zinc-300 bg-transparent text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800";
|
||||
return "border-action-ghost-border bg-action-ghost-bg text-action-ghost-text hover:border-action-ghost-hover-border hover:bg-action-ghost-hover-bg hover:text-action-ghost-hover-text";
|
||||
}
|
||||
|
||||
if (props.variant === "danger") {
|
||||
return "border-rose-300 bg-rose-100 text-rose-800 hover:bg-rose-200 dark:border-rose-800 dark:bg-rose-950/60 dark:text-rose-200 dark:hover:bg-rose-900/70";
|
||||
return "border-action-danger-border bg-action-danger-bg text-action-danger-text shadow-sm hover:border-action-danger-hover-border hover:bg-action-danger-hover-bg hover:text-action-danger-hover-text";
|
||||
}
|
||||
|
||||
return "border-brand-700 bg-brand-700 text-white hover:bg-brand-600 dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:bg-brand-300";
|
||||
return "border-action-primary-border bg-action-primary-bg text-action-primary-text shadow-sm hover:border-action-primary-hover-border hover:bg-action-primary-hover-bg hover:text-action-primary-hover-text";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="variantClass"
|
||||
:type="props.type"
|
||||
:disabled="props.disabled"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<article class="min-w-0 rounded-2xl border border-zinc-200 bg-white p-5 shadow-panel dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<article class="min-w-0 rounded-2xl border border-stroke-default bg-surface-card p-5 shadow-panel">
|
||||
<slot />
|
||||
</article>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<label class="inline-flex min-h-10 items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200" :for="id">
|
||||
<label class="inline-flex min-h-10 items-center gap-2 text-sm text-ink-primary" :for="id">
|
||||
<input
|
||||
:id="id"
|
||||
v-model="model"
|
||||
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
type="checkbox"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ defineProps<{
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-xl border border-dashed border-zinc-300 bg-zinc-50 p-5 dark:border-zinc-700 dark:bg-zinc-900/60">
|
||||
<h3 class="font-display text-lg font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</h3>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{{ body }}</p>
|
||||
<section class="rounded-xl border border-dashed border-stroke-interactive bg-surface-card-muted p-5">
|
||||
<h3 class="font-display text-lg font-semibold text-ink-primary">{{ title }}</h3>
|
||||
<p class="mt-1 text-sm text-ink-secondary">{{ body }}</p>
|
||||
<div v-if="$slots.default" class="mt-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
209
frontend/src/components/ui/AppHelpHint.vue
Normal file
209
frontend/src/components/ui/AppHelpHint.vue
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watch } from "vue";
|
||||
|
||||
type TooltipSide = "right" | "left" | "top" | "bottom";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
text: string;
|
||||
side?: TooltipSide | "auto";
|
||||
}>(),
|
||||
{
|
||||
side: "auto",
|
||||
},
|
||||
);
|
||||
const slots = useSlots();
|
||||
|
||||
const GAP = 8;
|
||||
const EDGE_PADDING = 8;
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const tooltipRef = ref<HTMLElement | null>(null);
|
||||
const isOpen = ref(false);
|
||||
const resolvedSide = ref<TooltipSide>("top");
|
||||
const tooltipStyle = ref<Record<string, string>>({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
|
||||
const sideClass = computed(() => {
|
||||
if (resolvedSide.value === "left") {
|
||||
return "origin-right";
|
||||
}
|
||||
if (resolvedSide.value === "right") {
|
||||
return "origin-left";
|
||||
}
|
||||
if (resolvedSide.value === "bottom") {
|
||||
return "origin-top";
|
||||
}
|
||||
return "origin-bottom";
|
||||
});
|
||||
const hasTriggerSlot = computed(() => Boolean(slots.trigger));
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
function spaceBySide(rect: DOMRect): Record<TooltipSide, number> {
|
||||
return {
|
||||
top: rect.top,
|
||||
right: window.innerWidth - rect.right,
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
left: rect.left,
|
||||
};
|
||||
}
|
||||
|
||||
function preferredSides(): TooltipSide[] {
|
||||
const allSides: TooltipSide[] = ["top", "right", "bottom", "left"];
|
||||
if (!props.side || props.side === "auto") {
|
||||
return allSides;
|
||||
}
|
||||
return [props.side, ...allSides.filter((item) => item !== props.side)];
|
||||
}
|
||||
|
||||
function chooseSide(rect: DOMRect, tooltipRect: DOMRect): TooltipSide {
|
||||
const spaces = spaceBySide(rect);
|
||||
const order = preferredSides();
|
||||
|
||||
for (const side of order) {
|
||||
if (side === "top" || side === "bottom") {
|
||||
if (spaces[side] >= tooltipRect.height + GAP) {
|
||||
return side;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (spaces[side] >= tooltipRect.width + GAP) {
|
||||
return side;
|
||||
}
|
||||
}
|
||||
|
||||
return order.sort((left, right) => spaces[right] - spaces[left])[0] ?? "top";
|
||||
}
|
||||
|
||||
function updatePosition(): void {
|
||||
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = triggerRef.value.getBoundingClientRect();
|
||||
const tooltip = tooltipRef.value.getBoundingClientRect();
|
||||
const side = chooseSide(trigger, tooltip);
|
||||
resolvedSide.value = side;
|
||||
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
if (side === "top") {
|
||||
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
|
||||
top = trigger.top - tooltip.height - GAP;
|
||||
} else if (side === "bottom") {
|
||||
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
|
||||
top = trigger.bottom + GAP;
|
||||
} else if (side === "left") {
|
||||
left = trigger.left - tooltip.width - GAP;
|
||||
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
|
||||
} else {
|
||||
left = trigger.right + GAP;
|
||||
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
|
||||
}
|
||||
|
||||
left = clamp(left, EDGE_PADDING, window.innerWidth - tooltip.width - EDGE_PADDING);
|
||||
top = clamp(top, EDGE_PADDING, window.innerHeight - tooltip.height - EDGE_PADDING);
|
||||
|
||||
tooltipStyle.value = {
|
||||
left: `${Math.round(left)}px`,
|
||||
top: `${Math.round(top)}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function closeTooltip(): void {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function openTooltip(): void {
|
||||
isOpen.value = true;
|
||||
void nextTick(() => {
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape") {
|
||||
closeTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function onViewportChange(): void {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
updatePosition();
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
window.addEventListener("resize", onViewportChange);
|
||||
window.addEventListener("scroll", onViewportChange, true);
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
window.removeEventListener("scroll", onViewportChange, true);
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.text,
|
||||
() => {
|
||||
if (isOpen.value) {
|
||||
void nextTick(() => {
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
window.removeEventListener("scroll", onViewportChange, true);
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-flex items-center align-middle">
|
||||
<component
|
||||
:is="hasTriggerSlot ? 'span' : 'button'"
|
||||
ref="triggerRef"
|
||||
:type="hasTriggerSlot ? undefined : 'button'"
|
||||
:tabindex="hasTriggerSlot ? 0 : undefined"
|
||||
class="inline-flex items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
:class="
|
||||
hasTriggerSlot
|
||||
? 'cursor-help rounded-sm'
|
||||
: 'h-5 w-5 justify-center rounded-full border border-stroke-interactive/80 bg-surface-card-muted/80 text-[11px] font-semibold text-ink-secondary transition hover:bg-action-secondary-hover-bg'
|
||||
"
|
||||
:aria-label="text"
|
||||
@mouseenter="openTooltip"
|
||||
@mouseleave="closeTooltip"
|
||||
@focus="openTooltip"
|
||||
@blur="closeTooltip"
|
||||
>
|
||||
<slot v-if="hasTriggerSlot" name="trigger" />
|
||||
<template v-else>?</template>
|
||||
</component>
|
||||
<Teleport to="body">
|
||||
<span
|
||||
v-if="isOpen"
|
||||
ref="tooltipRef"
|
||||
role="tooltip"
|
||||
:style="tooltipStyle"
|
||||
:class="sideClass"
|
||||
class="pointer-events-none fixed z-[90] w-64 max-w-[calc(100vw-2rem)] rounded-lg border border-stroke-default bg-surface-card/95 px-2.5 py-2 text-xs leading-relaxed text-ink-secondary shadow-lg"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</Teleport>
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -12,7 +12,7 @@ defineProps<{
|
|||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950 dark:placeholder:text-zinc-500"
|
||||
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:id="id"
|
||||
:type="type || 'text'"
|
||||
:placeholder="placeholder"
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ const emit = defineEmits<{ close: [] }>();
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-zinc-950/60 p-4" @click.self="emit('close')">
|
||||
<div class="w-full max-w-lg rounded-2xl border border-zinc-200 bg-white p-6 shadow-panel dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<h3 class="mb-4 font-display text-xl font-semibold text-zinc-900 dark:text-zinc-100">{{ props.title }}</h3>
|
||||
<div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-surface-overlay/60 p-4" @click.self="emit('close')">
|
||||
<div class="w-full max-w-lg rounded-2xl border border-stroke-default bg-surface-card p-6 shadow-panel">
|
||||
<h3 class="mb-4 font-display text-xl font-semibold text-ink-primary">{{ props.title }}</h3>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ defineProps<{
|
|||
<template>
|
||||
<select
|
||||
v-model="model"
|
||||
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none transition focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:id="id"
|
||||
:disabled="disabled"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defineProps<{
|
|||
<span
|
||||
v-for="line in lines || 3"
|
||||
:key="line"
|
||||
class="block h-3 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-800"
|
||||
class="block h-3 animate-pulse rounded-full bg-surface-card-muted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ defineProps<{
|
|||
|
||||
<template>
|
||||
<div
|
||||
class="max-w-full overflow-x-auto rounded-xl border border-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-800 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
class="max-w-full overflow-x-auto rounded-xl border border-stroke-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
tabindex="0"
|
||||
>
|
||||
<table class="min-w-full border-collapse bg-white text-sm dark:bg-zinc-900" :aria-label="label || 'Data table'">
|
||||
<table class="min-w-full border-collapse bg-surface-table text-sm" :aria-label="label || 'Data table'">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -18,14 +18,14 @@ defineProps<{
|
|||
<style scoped>
|
||||
:deep(th),
|
||||
:deep(td) {
|
||||
@apply border-b border-zinc-200 px-3 py-2 text-left align-top dark:border-zinc-800;
|
||||
@apply border-b border-stroke-default px-3 py-2 text-left align-top;
|
||||
}
|
||||
|
||||
:deep(th) {
|
||||
@apply sticky top-0 z-10 bg-zinc-100 font-semibold text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200;
|
||||
@apply sticky top-0 z-10 bg-surface-table-header font-semibold text-ink-primary;
|
||||
}
|
||||
|
||||
:deep(td) {
|
||||
@apply text-zinc-700 dark:text-zinc-300;
|
||||
@apply text-ink-secondary;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue