Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@
@import '@fontsource/ibm-plex-sans/600.css';
@import '@fontsource/ibm-plex-mono/400.css';
@import '@fontsource/ibm-plex-mono/500.css';
/* Fonts an admin can pick (shared/utils/appearance.ts): declared here, only
downloaded by browsers when the theme uses them */
@import '@fontsource/inter/400.css';
@import '@fontsource/inter/500.css';
@import '@fontsource/inter/600.css';
@import '@fontsource/space-grotesk/400.css';
@import '@fontsource/space-grotesk/500.css';
@import '@fontsource/space-grotesk/600.css';
@import '@fontsource/nunito/400.css';
@import '@fontsource/nunito/500.css';
@import '@fontsource/nunito/600.css';
@import '@fontsource/source-serif-4/400.css';
@import '@fontsource/source-serif-4/500.css';
@import '@fontsource/source-serif-4/600.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';

@tailwind base;
@tailwind components;
Expand All @@ -17,6 +33,11 @@
@layer base {
:root {
color-scheme: light;
/* Overridden by the admin appearance (app/plugins/appearance.ts) */
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-heading: var(--font-sans);
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
--radius: 0.3125rem;
--bg: 243 244 246;
--surface: 255 255 255;
--raised: 247 248 250;
Expand Down Expand Up @@ -83,6 +104,12 @@
@apply bg-bg-primary text-text-primary font-sans text-sm leading-6;
}

h1,
h2,
h3 {
@apply font-heading;
}

::selection {
@apply bg-free/25 text-text-primary;
}
Expand Down
92 changes: 92 additions & 0 deletions app/components/AuthShell.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<template>
<!-- Split: picture or accent panel on the left, form on the right -->
<div v-if="layout === 'split'" class="bg-bg-primary lg:grid lg:grid-cols-2" :class="frame">
<div class="relative hidden lg:block overflow-hidden" :class="{ 'bg-free': !image }">
<img v-if="image" :src="image" alt="" class="absolute inset-0 w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-scrim/70 via-scrim/20 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-10 text-paper">
<h2 class="text-3xl font-semibold tracking-tight" v-html="title"></h2>
<div class="mt-2 text-paper/80 [&>p]:m-0" v-html="subtitle"></div>
</div>
</div>
<div class="relative flex items-center justify-center px-4 py-12">
<div class="absolute top-4 right-4"><LanguageSwitcher /></div>
<div class="w-full max-w-md">
<AuthShellHeader :branding="branding" :title="title" :subtitle="subtitle" class="lg:hidden" />
<AuthShellLogo :branding="branding" class="hidden lg:flex mb-8" />
<slot />
</div>
</div>
</div>

<!-- Background: full-page picture, form on a translucent card -->
<div
v-else-if="layout === 'background'"
class="relative flex items-center justify-center px-4 py-12 overflow-hidden"
:class="[frame, image ? 'bg-scrim' : 'bg-gradient-to-br from-free/40 via-bg-primary to-bg-primary']"
>
<img v-if="image" :src="image" alt="" class="absolute inset-0 w-full h-full object-cover" />
<div v-if="image" class="absolute inset-0 bg-scrim/45"></div>
<div class="absolute top-4 right-4 z-10"><LanguageSwitcher /></div>
<div class="relative w-full max-w-md bg-bg-secondary/90 backdrop-blur-md border border-border rounded-lg shadow-2xl shadow-scrim/30 p-8">
<AuthShellHeader :branding="branding" :title="title" :subtitle="subtitle" />
<slot />
</div>
</div>

<!-- Minimal: no logo tile, left-aligned, narrow column -->
<div v-else-if="layout === 'minimal'" class="relative bg-bg-primary flex flex-col px-6 py-6" :class="frame">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-text-primary" v-html="branding?.siteName || 'Trackarr'"></span>
<LanguageSwitcher />
</div>
<div class="flex-1 flex items-center justify-center py-12">
<div class="w-full max-w-sm">
<h1 class="text-3xl font-semibold tracking-tight" v-html="title"></h1>
<div class="text-text-muted text-sm mt-1 mb-8 [&>p]:m-0" v-html="subtitle"></div>
<slot />
</div>
</div>
</div>

<!-- Centered (default) -->
<div v-else class="relative bg-bg-primary flex items-center justify-center px-4" :class="frame">
<div class="absolute top-4 right-4"><LanguageSwitcher /></div>
<div class="w-full max-w-md">
<AuthShellHeader :branding="branding" :title="title" :subtitle="subtitle" />
<slot />
</div>
</div>
</template>

<script setup lang="ts">
import type { LoginLayout } from '~~/shared/utils/appearance';
import type { AuthBranding } from '~/utils/authBranding';

/**
* Frame of the sign-in and sign-up pages, in the design chosen by the admin
* (Admin → Appearance → Login page). Pages put their form in the slot.
*/
const props = defineProps<{
layout?: LoginLayout;
image?: string | null;
/** Fill the parent instead of the viewport (admin preview) */
contained?: boolean;
}>();

const { t } = useI18n();
const appearance = useAppearance();
const { data: branding } = await useFetch<AuthBranding>('/api/branding');

// Props let the admin page preview a design before saving it
const layout = computed(() => props.layout ?? appearance.value.login.layout);
const image = computed(() => (props.image !== undefined ? props.image : appearance.value.login.image));
const frame = computed(() => (props.contained ? 'h-full' : 'min-h-screen'));
// Rendered with v-html: branding fields are sanitized rich text from the
// server; the translated fallback is plain text an admin can override, so
// it is escaped
const escapeHtml = (text: string) =>
text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
const title = computed(() => branding.value?.authTitle || branding.value?.siteName || 'TRACKARR');
const subtitle = computed(() => branding.value?.authSubtitle || escapeHtml(t('auth.defaultSubtitle')));
</script>
14 changes: 14 additions & 0 deletions app/components/AuthShellHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<div class="text-center mb-8">
<AuthShellLogo :branding="branding" class="mx-auto mb-4" />
<h1 class="text-2xl font-bold tracking-tighter" v-html="title"></h1>
<div class="text-text-muted text-sm mt-1 [&>p]:m-0" v-html="subtitle"></div>
</div>
</template>

<script setup lang="ts">
import type { AuthBranding } from '~/utils/authBranding';

// title and subtitle are sanitized rich text from /api/branding
defineProps<{ branding: AuthBranding | null | undefined; title: string; subtitle: string }>();
</script>
12 changes: 12 additions & 0 deletions app/components/AuthShellLogo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<template>
<div class="w-16 h-16 bg-white rounded-lg flex items-center justify-center overflow-hidden">
<img v-if="branding?.siteLogoImage" :src="branding.siteLogoImage" alt="Logo" class="w-full h-full object-contain" />
<Icon v-else :name="branding?.siteLogo || 'ph:broadcast-bold'" class="text-black text-4xl" />
</div>
</template>

<script setup lang="ts">
import type { AuthBranding } from '~/utils/authBranding';

defineProps<{ branding: AuthBranding | null | undefined }>();
</script>
100 changes: 100 additions & 0 deletions app/components/admin/Appearance.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<template>
<div class="space-y-6">
<div class="card">
<div class="card-header flex flex-wrap items-center justify-between gap-3">
<div class="flex gap-1 flex-wrap" role="tablist">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
role="tab"
:aria-selected="current === tab.id"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded"
:class="current === tab.id ? 'bg-bg-tertiary text-text-primary font-medium' : 'text-text-muted hover:text-text-primary'"
@click="current = tab.id"
>
<Icon :name="tab.icon" />{{ t(`appearance.tabs.${tab.id}`) }}
</button>
</div>
<div class="flex items-center gap-2">
<span v-if="dirty" class="text-xs text-leech">{{ t('appearance.unsaved') }}</span>
<button type="button" class="btn btn-secondary" :disabled="!dirty || saving" @click="discard">
{{ t('appearance.discard') }}
</button>
<button type="button" class="btn btn-primary" :disabled="!dirty || saving" @click="save">
<Icon :name="saving ? 'ph:circle-notch' : 'ph:floppy-disk'" :class="{ 'animate-spin': saving }" />
{{ t('common.save') }}
</button>
</div>
</div>
<!-- Locked while saving: the response replaces the draft -->
<div class="card-body transition-opacity" :class="{ 'opacity-60': saving }" role="tabpanel" :inert="saving">
<p class="text-xs text-text-muted mb-4">{{ t('appearance.previewHint') }}</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<AdminAppearanceTheme v-if="current === 'theme'" v-model="draft" />
<AdminAppearanceTypography v-else-if="current === 'typography'" v-model="draft" />
<AdminAppearanceImages v-else-if="current === 'images'" :appearance="saved" @uploaded="onImage" />
<AdminAppearanceLogin v-else-if="current === 'login'" v-model="draft" />
<AdminAppearanceTexts v-else v-model="draft" />
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { useNotificationStore } from '~/stores/notifications';
import type { Appearance } from '~~/shared/utils/appearance';

const { t } = useI18n();
const notifications = useNotificationStore();
const appearance = useAppearance();

const tabs = [
{ id: 'theme', icon: 'ph:palette' },
{ id: 'typography', icon: 'ph:text-aa' },
{ id: 'images', icon: 'ph:image' },
{ id: 'login', icon: 'ph:sign-in' },
{ id: 'texts', icon: 'ph:translate' },
] as const;
const current = ref<(typeof tabs)[number]['id']>('theme');

// What the server holds, and the edited copy previewed on the whole site
const saved = ref<Appearance>(structuredClone(toRaw(appearance.value)));
const draft = ref<Appearance>(structuredClone(toRaw(appearance.value)));
const dirty = computed(() => JSON.stringify(draft.value) !== JSON.stringify(saved.value));

watch(draft, (value) => (appearance.value = structuredClone(toRaw(value))), { deep: true });

// Leaving without saving drops the preview
onBeforeUnmount(() => {
appearance.value = structuredClone(toRaw(saved.value));
});

function discard() {
draft.value = structuredClone(toRaw(saved.value));
}

const saving = ref(false);
async function save() {
saving.value = true;
const textsChanged = JSON.stringify(draft.value.texts) !== JSON.stringify(saved.value.texts);
try {
const result = await $fetch<Appearance>('/api/admin/appearance', { method: 'PUT', body: draft.value });
saved.value = result;
draft.value = structuredClone(result);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
notifications.success(t('appearance.saved'));
// Removed text overrides stay merged in vue-i18n until the app reloads
if (textsChanged) reloadNuxtApp({ force: true });
} catch (error) {
const message = (error as { data?: { message?: string } }).data?.message;
notifications.error(t('appearance.saveFailed'), message);
} finally {
saving.value = false;
}
}

// Images are stored on upload: update both copies, keep other edits pending
function onImage(next: Appearance) {
saved.value = { ...saved.value, login: { ...saved.value.login, image: next.login.image }, images: next.images };
draft.value = { ...draft.value, login: { ...draft.value.login, image: next.login.image }, images: next.images };
}
</script>
69 changes: 69 additions & 0 deletions app/components/admin/AppearanceImages.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<template>
<div>
<SettingsGroup
v-for="slot in APPEARANCE_IMAGE_SLOTS"
:key="slot"
:label="t(`appearance.images.${slot}`)"
:description="t(`appearance.images.${slot}Hint`)"
>
<div
class="relative rounded-lg border border-dashed border-border bg-bg-tertiary/50 overflow-hidden flex items-center justify-center"
:class="slot === 'homeBanner' ? 'h-32' : 'h-48'"
>
<img v-if="current(slot)" :src="current(slot)!" alt="" class="absolute inset-0 w-full h-full object-cover" />
<span v-else class="text-sm text-text-muted flex items-center gap-2">
<Icon name="ph:image" />{{ t('appearance.images.none') }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2 mt-3">
<label class="btn btn-secondary cursor-pointer" :class="{ 'opacity-50 pointer-events-none': busy === slot }">
<Icon :name="busy === slot ? 'ph:circle-notch' : 'ph:upload-simple'" :class="{ 'animate-spin': busy === slot }" />
{{ current(slot) ? t('appearance.images.replace') : t('appearance.images.upload') }}
<input type="file" accept="image/png,image/jpeg,image/webp" class="sr-only" @change="upload(slot, $event)" />
</label>
<button v-if="current(slot)" type="button" class="btn btn-ghost text-danger" :disabled="busy === slot" @click="send(slot, null)">
<Icon name="ph:trash" />{{ t('appearance.images.remove') }}
</button>
<span class="text-xs text-text-muted">{{ t('appearance.images.formats') }}</span>
</div>
</SettingsGroup>
</div>
</template>

<script setup lang="ts">
import { useNotificationStore } from '~/stores/notifications';
import { APPEARANCE_IMAGE_SLOTS, type Appearance, type AppearanceImageSlot } from '~~/shared/utils/appearance';

const props = defineProps<{ appearance: Appearance }>();
const emit = defineEmits<{ uploaded: [appearance: Appearance] }>();
const { t } = useI18n();
const notifications = useNotificationStore();

const current = (slot: AppearanceImageSlot) =>
slot === 'loginImage' ? props.appearance.login.image : props.appearance.images.homeBanner;

const busy = ref<AppearanceImageSlot | null>(null);

function upload(slot: AppearanceImageSlot, event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) send(slot, file);
}

// Stored right away, not with the Save button
async function send(slot: AppearanceImageSlot, file: File | null) {
const form = new FormData();
form.append('slot', slot);
if (file) form.append('image', file);
busy.value = slot;
try {
emit('uploaded', await $fetch<Appearance>('/api/admin/appearance/image', { method: 'POST', body: form }));
} catch (error) {
const message = (error as { data?: { message?: string } }).data?.message;
notifications.error(t('appearance.images.failed'), message);
} finally {
busy.value = null;
}
}
</script>
Loading