Big changes

This commit is contained in:
Justin Visser 2026-02-21 17:33:05 +01:00
parent 4240ad38e2
commit e3f0d63fec
99 changed files with 8804 additions and 1731 deletions

View file

@ -2,9 +2,8 @@
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";
import AppBrandMark from "@/components/ui/AppBrandMark.vue";
import AppThemePicker from "@/components/ui/AppThemePicker.vue";
const props = withDefaults(
defineProps<{
@ -20,25 +19,9 @@ const emit = defineEmits<{
(e: "toggle-menu"): void;
}>();
const theme = useThemeStore();
const isDarkTheme = computed(() => theme.active === "dark");
const toggleThemeLabel = computed(() =>
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
);
const menuButtonLabel = computed(() =>
props.menuOpen ? "Close navigation menu" : "Open navigation menu",
);
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");
}
function onToggleMenu(): void {
emit("toggle-menu");
@ -75,64 +58,14 @@ function onToggleMenu(): void {
</AppButton>
<RouterLink
to="/dashboard"
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"
class="inline-flex items-center gap-2.5 rounded-sm font-display text-xl 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
<AppBrandMark size="lg" />
<span>scholarr</span>
</RouterLink>
</div>
<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"
:aria-label="toggleThemeLabel"
:title="toggleThemeLabel"
@click="onToggleTheme"
>
<span class="sr-only">{{ toggleThemeLabel }}</span>
<svg
v-if="isDarkTheme"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="h-5 w-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2.5M12 19.5V22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M2 12h2.5M19.5 12H22M4.9 19.1l1.8-1.8M17.3 6.7l1.8-1.8"
/>
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="h-5 w-5"
aria-hidden="true"
>
<path d="M21 14.5A8.5 8.5 0 1 1 9.5 3 7 7 0 0 0 21 14.5z" />
</svg>
</AppButton>
</div>
<AppThemePicker id-prefix="header-theme" />
</div>
</header>
</template>

View file

@ -19,22 +19,12 @@ const emit = defineEmits<{
}>();
const links = computed(() => {
const base = [
{ 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 },
return [
{ id: "dashboard", to: "/dashboard", label: "Dashboard" },
{ id: "scholars", to: "/scholars", label: "Scholars" },
{ id: "publications", to: "/publications", label: "Publications" },
{ id: "settings", to: "/settings", label: "Settings" },
];
return base.filter((item) => {
if (item.adminOnly && !auth.isAdmin) {
return false;
}
return userSettings.isPageVisible(item.id);
});
});
const navSafetyText = computed(() => {
if (!userSettings.manualRunAllowed) {

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from "vue";
import brandLogo from "@/logo.png";
const props = withDefaults(
defineProps<{
size?: "sm" | "md" | "lg" | "xl";
}>(),
{
size: "md",
},
);
const sizeClass = computed(() => {
if (props.size === "sm") {
return "h-5 w-5";
}
if (props.size === "lg") {
return "h-8 w-8";
}
if (props.size === "xl") {
return "h-12 w-12";
}
return "h-6 w-6";
});
const logoMaskStyle: Record<string, string> = {
"--brand-logo-mask": `url(${brandLogo})`,
};
</script>
<template>
<span
:class="['brand-logo-mark', sizeClass]"
:style="logoMaskStyle"
aria-hidden="true"
/>
</template>
<style scoped>
.brand-logo-mark {
display: inline-block;
flex-shrink: 0;
background-color: rgb(var(--color-brand-700));
-webkit-mask-image: var(--brand-logo-mask);
mask-image: var(--brand-logo-mask);
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
</style>

View file

@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from "vue";
import AppButton from "@/components/ui/AppButton.vue";
const props = withDefaults(
defineProps<{
disabled?: boolean;
loading?: boolean;
title?: string;
loadingTitle?: string;
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "md" | "sm";
}>(),
{
disabled: false,
loading: false,
title: "Refresh",
loadingTitle: "Refreshing",
variant: "secondary",
size: "md",
},
);
const resolvedTitle = computed(() => (props.loading ? props.loadingTitle : props.title));
const isDisabled = computed(() => props.disabled || props.loading);
const buttonSizeClass = computed(() => {
if (props.size === "sm") {
return "min-h-8 h-8 w-8";
}
return "min-h-10 h-10 w-10";
});
const iconSizeClass = computed(() => (props.size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"));
</script>
<template>
<AppButton
:variant="props.variant"
:disabled="isDisabled"
class="rounded-full p-0"
:class="buttonSizeClass"
:title="resolvedTitle"
:aria-label="resolvedTitle"
>
<span class="sr-only">{{ resolvedTitle }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
:class="[iconSizeClass, props.loading ? 'animate-spin' : '']"
aria-hidden="true"
>
<path d="M20 4v6h-6" />
<path d="M4 20v-6h6" />
<path d="M6.5 9A7.5 7.5 0 0 1 19 7.5L20 10" />
<path d="M17.5 15A7.5 7.5 0 0 1 5 16.5L4 14" />
</svg>
</AppButton>
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
export interface AppTabItem {
id: string;
label: string;
disabled?: boolean;
}
const props = defineProps<{
modelValue: string;
items: AppTabItem[];
ariaLabel?: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
function onSelect(tabId: string, disabled: boolean | undefined): void {
if (disabled || tabId === props.modelValue) {
return;
}
emit("update:modelValue", tabId);
}
</script>
<template>
<div
class="flex flex-wrap gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-2"
role="tablist"
:aria-label="props.ariaLabel || 'Tabs'"
>
<button
v-for="item in props.items"
:key="item.id"
type="button"
role="tab"
:aria-selected="item.id === props.modelValue"
:tabindex="item.id === props.modelValue ? 0 : -1"
:disabled="item.disabled"
class="inline-flex min-h-9 items-center rounded-md border px-3 py-1.5 text-sm font-medium 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="
item.id === props.modelValue
? 'border-stroke-interactive bg-surface-nav-active text-ink-primary'
: 'border-stroke-default bg-surface-card text-ink-secondary hover:border-stroke-interactive hover:text-ink-primary'
"
@click="onSelect(item.id, item.disabled)"
>
{{ item.label }}
</button>
</div>
</template>

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
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 props = withDefaults(
defineProps<{
compact?: boolean;
idPrefix?: string;
}>(),
{
compact: false,
idPrefix: "theme",
},
);
const theme = useThemeStore();
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);
const selectId = computed(() => `${props.idPrefix}-preset-select`);
const selectWidthClass = computed(() => (props.compact ? "w-32 sm:w-36" : "w-36 sm:w-44"));
const toggleButtonClass = computed(() =>
props.compact ? "h-9 w-9 rounded-full p-0" : "h-10 w-10 rounded-full p-0",
);
function onToggleTheme(): void {
theme.setPreference(isDarkTheme.value ? "light" : "dark");
}
</script>
<template>
<div class="flex items-center justify-end gap-2">
<div :class="selectWidthClass">
<label :for="selectId" class="sr-only">Theme preset</label>
<AppSelect
:id="selectId"
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="toggleButtonClass"
:aria-label="toggleThemeLabel"
:title="toggleThemeLabel"
@click="onToggleTheme"
>
<span class="sr-only">{{ toggleThemeLabel }}</span>
<svg
v-if="isDarkTheme"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="h-5 w-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2.5M12 19.5V22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M2 12h2.5M19.5 12H22M4.9 19.1l1.8-1.8M17.3 6.7l1.8-1.8"
/>
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="h-5 w-5"
aria-hidden="true"
>
<path d="M21 14.5A8.5 8.5 0 1 1 9.5 3 7 7 0 0 0 21 14.5z" />
</svg>
</AppButton>
</div>
</template>