Big changes
This commit is contained in:
parent
4240ad38e2
commit
e3f0d63fec
99 changed files with 8804 additions and 1731 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
55
frontend/src/components/ui/AppBrandMark.vue
Normal file
55
frontend/src/components/ui/AppBrandMark.vue
Normal 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>
|
||||
61
frontend/src/components/ui/AppRefreshButton.vue
Normal file
61
frontend/src/components/ui/AppRefreshButton.vue
Normal 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>
|
||||
51
frontend/src/components/ui/AppTabs.vue
Normal file
51
frontend/src/components/ui/AppTabs.vue
Normal 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>
|
||||
95
frontend/src/components/ui/AppThemePicker.vue
Normal file
95
frontend/src/components/ui/AppThemePicker.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue