Repository navigation
feat(admin): appearance editor (theme, fonts, images, login designs, texts) #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); | ||
| const title = computed(() => branding.value?.authTitle || branding.value?.siteName || 'TRACKARR'); | ||
| const subtitle = computed(() => branding.value?.authSubtitle || escapeHtml(t('auth.defaultSubtitle'))); | ||
| </script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| <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); | ||
|
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> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.