feat(10-01): serve the embedded admin SPA at backend.uri with cookie login

- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
  and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
  cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
  and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
  with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
  through the openapi-fetch client typed by the generated schema
This commit is contained in:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

15
admin/src/App.vue Normal file
View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import AppShell from './components/shell/AppShell.vue'
const route = useRoute()
const inShell = computed(() => route.meta.shell === true)
</script>
<template>
<AppShell v-if="inShell">
<RouterView />
</AppShell>
<RouterView v-else />
</template>

44
admin/src/api/client.ts Normal file
View File

@@ -0,0 +1,44 @@
// The only HTTP client of the SPA: openapi-fetch typed by the generated
// paths (D-15). Every request carries X-Requested-With (the CSRF header the
// admin API requires on state-changing cookie requests, D-19) and same-origin
// credentials; the JWT lives in an HttpOnly cookie the SPA never reads.
import createClient, { type Middleware } from 'openapi-fetch'
import type { paths } from './schema'
import { runtime } from '../app/runtime'
export const REQUESTED_WITH = 'XMLHttpRequest'
type UnauthorizedHandler = () => void
let unauthorizedHandler: UnauthorizedHandler | null = null
/** Registers what happens when an API call other than login returns 401. */
export function onUnauthorized(handler: UnauthorizedHandler | null): void {
unauthorizedHandler = handler
}
function isLoginRequest(request: Request): boolean {
return new URL(request.url, 'http://local').pathname.endsWith('/auth/login')
}
export const transport: Middleware = {
onRequest({ request }) {
request.headers.set('X-Requested-With', REQUESTED_WITH)
return request
},
onResponse({ request, response }) {
if (response.status === 401 && !isLoginRequest(request)) {
unauthorizedHandler?.()
}
return response
},
}
export const api = createClient<paths>({
baseUrl: runtime.api,
credentials: 'same-origin',
// Resolve fetch per call so tests can replace globalThis.fetch.
fetch: (request: Request) => globalThis.fetch(request),
})
api.use(transport)

1532
admin/src/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

16
admin/src/api/types.ts Normal file
View File

@@ -0,0 +1,16 @@
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
// written by hand: every type here points at components['schemas'].
import type { components } from './schema'
type Schemas = components['schemas']
export type AdminLoginData = Schemas['cabana.AdminLoginData']
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
export type AdminProfile = Schemas['cabana.AdminProfile']
export type NavigationEntry = Schemas['cabana.NavigationEntry']
export type ListSchema = Schemas['cabana.ListSchema']
export type ListColumn = Schemas['cabana.ListColumn']
export type ListMeta = Schemas['cabana.ListMeta']
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
/** One record: a string-keyed map read through its list or form schema. */
export type AdminRecord = Schemas['cabana.ListEnvelope-array_cabana_AdminRecord']['data'][number]

View File

@@ -0,0 +1,42 @@
// Controller IDs map one to one onto SPA paths (D-10):
// vendor.plugin.controller <-> /vendor/plugin/controller.
export interface ControllerParams {
vendor: string
plugin: string
controller: string
}
const SEGMENT = /^[A-Za-z0-9_-]+$/
export function parseControllerId(id: string): ControllerParams | null {
const parts = id.split('.')
if (parts.length !== 3 || !parts.every((part) => SEGMENT.test(part))) {
return null
}
const [vendor, plugin, controller] = parts as [string, string, string]
return { vendor, plugin, controller }
}
export function controllerPath(id: string): string | null {
const params = parseControllerId(id)
return params ? `/${params.vendor}/${params.plugin}/${params.controller}` : null
}
export function controllerIdFromParams(params: ControllerParams): string {
return `${params.vendor}.${params.plugin}.${params.controller}`
}
export function controllerIdFromPath(path: string): string | null {
const parts = path.replace(/^\/+|\/+$/g, '').split('/')
if (parts.length < 3) {
return null
}
const id = parts.slice(0, 3).join('.')
return parseControllerId(id) ? id : null
}
/** vendor.plugin prefix of a controller ID, used to find the owning plugin. */
export function pluginKey(id: string): string | null {
const params = parseControllerId(id)
return params ? `${params.vendor}.${params.plugin}` : null
}

42
admin/src/app/i18n.ts Normal file
View File

@@ -0,0 +1,42 @@
// UI strings (D-20). The resolved backend::lang bundle is loaded from the
// server in a later plan; until a key is loaded, t() returns the key itself,
// mirroring phrasebook's missing-key fallback. Placeholders use phrasebook's
// :name syntax with :Name and :NAME casing variants (D-24).
import { ref } from 'vue'
type Forms = Record<string, string>
const bundle = ref<Record<string, Forms>>({})
export function setBundle(next: Record<string, Forms>): void {
bundle.value = next
}
export function interpolate(text: string, params: Record<string, string | number> = {}): string {
const replacements: Array<[string, string]> = []
for (const [rawName, raw] of Object.entries(params)) {
const name = rawName.replace(/^:/, '')
if (name === '') {
continue
}
const value = String(raw)
replacements.push([`:${name.charAt(0).toUpperCase()}${name.slice(1)}`, value.charAt(0).toUpperCase() + value.slice(1)])
replacements.push([`:${name.toUpperCase()}`, value.toUpperCase()])
replacements.push([`:${name}`, value])
}
replacements.sort((a, b) => b[0].length - a[0].length)
let out = text
for (const [placeholder, value] of replacements) {
out = out.split(placeholder).join(value)
}
return out
}
export function t(key: string, params: Record<string, string | number> = {}): string {
const forms = bundle.value[key]
const text = forms?.other
if (text === undefined) {
return key
}
return interpolate(text, params)
}

111
admin/src/app/icons.ts Normal file
View File

@@ -0,0 +1,111 @@
// Navigation icons (D-11). The registry stores lucide names; ported Winter
// plugins may still send icon-* names, which map onto lucide equivalents.
// Named imports only: a namespace import would bundle every lucide icon.
import type { Component } from 'vue'
import {
Archive,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpRight,
CassetteTape,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
CircleAlert,
Disc,
Disc3,
Image,
Library,
List,
LogOut,
MicVocal,
Minus,
Palette,
PanelLeftClose,
PanelLeftOpen,
Plus,
Puzzle,
Search,
SearchX,
Settings,
ShieldCheck,
Square,
Sun,
Tags,
Trash2,
User,
UserMinus,
UserPlus,
Users,
UsersRound,
X,
} from '@lucide/vue'
export const icons: Readonly<Record<string, Component>> = {
archive: Archive,
'arrow-down': ArrowDown,
'arrow-left': ArrowLeft,
'arrow-right': ArrowRight,
'arrow-up': ArrowUp,
'arrow-up-right': ArrowUpRight,
'cassette-tape': CassetteTape,
check: Check,
'chevron-down': ChevronDown,
'chevron-left': ChevronLeft,
'chevron-right': ChevronRight,
circle: Circle,
'circle-alert': CircleAlert,
disc: Disc,
'disc-3': Disc3,
image: Image,
library: Library,
list: List,
'log-out': LogOut,
'mic-vocal': MicVocal,
minus: Minus,
palette: Palette,
'panel-left-close': PanelLeftClose,
'panel-left-open': PanelLeftOpen,
plus: Plus,
puzzle: Puzzle,
search: Search,
'search-x': SearchX,
settings: Settings,
'shield-check': ShieldCheck,
sun: Sun,
tags: Tags,
'trash-2': Trash2,
user: User,
'user-minus': UserMinus,
'user-plus': UserPlus,
users: Users,
'users-round': UsersRound,
x: X,
}
/** Winter backend icon classes mapped to lucide names. */
export const winterIcons: Readonly<Record<string, string>> = {
'icon-archive': 'archive',
'icon-circle': 'circle',
'icon-list-ul': 'list',
'icon-tags': 'tags',
'icon-user': 'user',
'icon-search': 'search',
'icon-cog': 'settings',
'icon-users': 'users',
}
/** Neutral icon for names neither lucide map knows. */
export const fallbackIcon: Component = Square
export function iconFor(name: string | null | undefined): Component {
if (!name) {
return fallbackIcon
}
const key = name.trim()
return icons[key] ?? icons[winterIcons[key] ?? ''] ?? fallbackIcon
}

63
admin/src/app/router.ts Normal file
View File

@@ -0,0 +1,63 @@
import { createRouter, createWebHistory, type RouterHistory } from 'vue-router'
import { runtime } from './runtime'
import { currentUser } from '../state/useAuth'
import { homePath } from '../state/useNavigation'
import LoginView from '../views/LoginView.vue'
import ListView from '../views/ListView.vue'
import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' {
interface RouteMeta {
/** Reachable without a session. */
public?: boolean
/** Rendered inside the navigation shell. */
shell?: boolean
}
}
/**
* Accepts a post-login redirect only when it is an in-app path: it must start
* with exactly one slash, which rejects absolute and protocol-relative URLs.
*/
export function safeRedirect(value: unknown): string | null {
if (typeof value !== 'string' || value.length === 0) {
return null
}
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
return null
}
return value
}
export function createAdminRouter(history: RouterHistory = createWebHistory(runtime.base)) {
const router = createRouter({
history,
routes: [
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{
path: '/',
name: 'home',
component: NotFoundView,
props: { home: true },
meta: { shell: true },
beforeEnter: () => homePath() ?? true,
},
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
],
})
router.beforeEach((to) => {
const signedIn = currentUser.value !== null
if (to.meta.public) {
if (signedIn && to.name === 'login') {
return safeRedirect(to.query.redirect) ?? '/'
}
return true
}
if (!signedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
return true
})
return router
}

23
admin/src/app/runtime.ts Normal file
View File

@@ -0,0 +1,23 @@
// Runtime configuration read once from the served index.html. The Go server
// (boardwalk) writes the configured backend.uri into the summer-admin-base
// meta; the SPA derives its router base and API base from it (D-02, D-03).
export const DEFAULT_BASE = '/backend'
const TOKEN = '__SUMMER_ADMIN_BASE__'
export function readBase(doc: Document = document): string {
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-base"]')?.content.trim() ?? ''
if (content === '' || content === TOKEN || !content.startsWith('/')) {
return DEFAULT_BASE
}
const trimmed = content.replace(/\/+$/, '')
return trimmed === '' ? DEFAULT_BASE : trimmed
}
const base = readBase()
export const runtime = {
/** Admin mount path, for example /backend. */
base,
/** Admin API root, the mount path plus /api/v1. */
api: `${base}/api/v1`,
} as const

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { AdminRecord, ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
// Read-only, schema-driven table: columns come from the list schema and each
// record is read through its column keys (D-16). Selection, sorting and
// row links arrive with the list screens.
defineProps<{
columns: ListColumn[]
rows: AdminRecord[]
loading?: boolean
emptyText?: string
}>()
function cellText(row: AdminRecord, column: ListColumn): string {
return formatValue(row[column.key])
}
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
const parts = value.map(formatValue).filter((part) => part !== '—')
return parts.length === 0 ? '—' : parts.join(', ')
}
if (typeof value === 'boolean') {
return value ? t('backend::lang.list.column_switch_true') : t('backend::lang.list.column_switch_false')
}
if (typeof value === 'object') {
return '—'
}
return String(value)
}
function rowKey(row: AdminRecord, index: number): string {
const id = row.id
return typeof id === 'number' || typeof id === 'string' ? String(id) : `row-${index}`
}
</script>
<template>
<div class="overflow-x-auto">
<table class="w-full min-w-[640px] border-collapse text-left">
<thead>
<tr class="h-row-head border-y border-border bg-subtle">
<th
v-for="column in columns"
:key="column.key"
scope="col"
class="px-3.5 text-[12px] font-semibold text-muted first:pl-5 last:pr-5"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<tr v-for="n in 8" :key="`skeleton-${n}`" class="h-row border-b border-border">
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
<span class="block h-3 w-3/5 rounded-[6px] bg-skel" />
</td>
</tr>
</template>
<tr v-else-if="rows.length === 0">
<td :colspan="Math.max(columns.length, 1)" class="px-5 py-20 text-center text-muted">
{{ emptyText ?? t('backend::lang.list.no_records') }}
</td>
</tr>
<template v-else>
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" class="h-row border-b border-border">
<td
v-for="(column, columnIndex) in columns"
:key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
class="px-3.5 first:pl-5 last:pr-5"
>
{{ cellText(row, column) }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import PluginRail from './PluginRail.vue'
import SectionPanel from './SectionPanel.vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { currentUser } from '../../state/useAuth'
import { activeEntry } from '../../state/useNavigation'
const route = useRoute()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
const section = computed(() => {
const id = controllerIdFromPath(route.path)
return active.value?.sideMenu.find((item) => item.controller === id) ?? null
})
const displayName = computed(() => {
const user = currentUser.value
if (!user) {
return ''
}
const name = `${user.first_name} ${user.last_name}`.trim()
return name === '' ? user.login : name
})
const initials = computed(() =>
displayName.value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join(''),
)
</script>
<template>
<div class="flex min-h-screen bg-bg text-text">
<PluginRail />
<SectionPanel v-if="active" :entry="active" />
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-header shrink-0 items-center justify-between border-b border-border bg-surface pr-6 pl-8">
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="flex items-center gap-2">
<template v-if="active">
<span :class="section ? 'text-muted' : 'font-semibold'">{{ active.label }}</span>
<template v-if="section">
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />
<span class="font-semibold" aria-current="page">{{ section.label }}</span>
</template>
</template>
</nav>
<div v-if="currentUser" class="flex h-input items-center gap-2.5 rounded-inner px-2">
<span
class="flex size-[34px] items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
aria-hidden="true"
>{{ initials }}</span
>
<span class="flex flex-col leading-tight">
<span class="font-semibold">{{ displayName }}</span>
<span v-if="currentUser.role" class="text-[12px] text-muted">{{ currentUser.role.name }}</span>
</span>
</div>
</header>
<main class="min-w-0 flex-1 px-8 py-7">
<slot />
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { Sun } from '@lucide/vue'
import { t } from '../../app/i18n'
import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
const route = useRoute()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
const items = computed(() =>
railEntries.value.map((entry) => ({
entry,
to: firstControllerPath(entry) ?? '/',
icon: iconFor(entry.icon),
current: active.value?.code === entry.code,
})),
)
</script>
<template>
<nav
:aria-label="t('backend::lang.nav.plugins')"
class="flex w-rail shrink-0 flex-col items-center gap-1.5 border-r border-side-border bg-side pt-3.5 pb-3"
>
<span class="mb-3.5 flex size-10 items-center justify-center rounded-inner bg-accent-soft" title="SummerCMS">
<Sun :size="22" class="text-accent" aria-hidden="true" />
</span>
<RouterLink
v-for="item in items"
:key="item.entry.code"
:to="item.to"
:aria-current="item.current ? 'page' : undefined"
:class="item.current ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline"
>
<component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.entry.label }}</span>
</RouterLink>
</nav>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import type { NavigationEntry } from '../../api/types'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
import { t } from '../../app/i18n'
const props = defineProps<{ entry: NavigationEntry }>()
const route = useRoute()
const currentController = computed(() => controllerIdFromPath(route.path))
const items = computed(() =>
props.entry.sideMenu.flatMap((item) => {
const to = controllerPath(item.controller)
if (!to) {
return []
}
return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }]
}),
)
</script>
<template>
<aside
:aria-label="t('backend::lang.nav.sections')"
class="flex w-panel shrink-0 flex-col gap-0.5 border-r border-border bg-surface px-3 py-3.5"
>
<div class="mb-3.5 flex h-10 items-center px-2">
<span class="text-[16px] font-bold">{{ entry.label }}</span>
</div>
<RouterLink
v-for="link in items"
:key="link.item.code"
:to="link.to"
:aria-current="link.current ? 'page' : undefined"
:class="link.current ? 'bg-sel font-semibold text-text' : 'font-medium text-muted hover:bg-hover hover:text-text'"
class="flex h-nav items-center gap-3 rounded-control px-3 no-underline"
>
<component :is="link.icon" :size="18" aria-hidden="true" />
<span>{{ link.item.label }}</span>
</RouterLink>
</aside>
</template>

30
admin/src/main.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client'
import { clearUser, me } from './state/useAuth'
import { loadNavigation } from './state/useNavigation'
import './styles/main.css'
async function boot(): Promise<void> {
// A 401 here only means "not signed in"; the router guard sends the
// visitor to the login route with the requested path as redirect.
const user = await me().catch(() => null)
if (user) {
await loadNavigation().catch(() => [])
}
const router = createAdminRouter()
onUnauthorized(() => {
clearUser()
const current = router.currentRoute.value
if (current.name !== 'login') {
void router.push({ name: 'login', query: { redirect: current.fullPath } })
}
})
const app = createApp(App)
app.use(router)
await router.isReady()
app.mount('#app')
}
void boot()

View File

@@ -0,0 +1,44 @@
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
// keeps only the profile and the access lifetime, never a token.
import { readonly, ref } from 'vue'
import { api } from '../api/client'
import type { AdminProfile } from '../api/types'
const user = ref<AdminProfile | null>(null)
const expiresIn = ref<number | null>(null)
export const currentUser = readonly(user)
/** Logs in over cookie transport. Returns false on invalid credentials. */
export async function login(identifier: string, password: string): Promise<boolean> {
const { data, response } = await api.POST('/auth/login', {
body: { login: identifier, password },
})
if (!response.ok || !data) {
return false
}
expiresIn.value = typeof data.data.expires_in === 'number' ? data.data.expires_in : null
return true
}
/** Loads the signed-in admin; null when the session is missing or expired. */
export async function me(): Promise<AdminProfile | null> {
const { data, response } = await api.GET('/auth/me')
user.value = response.ok && data ? data.data : null
return user.value
}
export function clearUser(): void {
user.value = null
expiresIn.value = null
}
export function useAuth() {
return {
user: currentUser,
expiresIn: readonly(expiresIn),
login,
me,
clearUser,
}
}

View File

@@ -0,0 +1,65 @@
// Server-filtered navigation (D-10). The server removes items the admin may
// not open; the rail also omits a plugin whose side menu ends up empty.
import { computed, readonly, ref } from 'vue'
import { api } from '../api/client'
import type { NavigationEntry } from '../api/types'
import { controllerPath, pluginKey } from '../app/controllerRoutes'
const entries = ref<NavigationEntry[]>([])
export const navigation = readonly(entries)
export async function loadNavigation(): Promise<NavigationEntry[]> {
const { data, response } = await api.GET('/navigation')
entries.value = response.ok && data ? data.data : []
return entries.value
}
export function setNavigation(next: NavigationEntry[]): void {
entries.value = next
}
/** Rail entries: plugins with at least one permitted side-menu item, by order. */
export const railEntries = computed<NavigationEntry[]>(() =>
entries.value
.filter((entry) => entry.sideMenu.length > 0)
.map((entry, index) => ({ entry, index }))
.sort((a, b) => a.entry.order - b.entry.order || a.index - b.index)
.map(({ entry }) => entry),
)
/** Path of the plugin's first permitted side-menu controller. */
export function firstControllerPath(entry: NavigationEntry): string | null {
for (const item of entry.sideMenu) {
const path = controllerPath(item.controller)
if (path) {
return path
}
}
return controllerPath(entry.controller)
}
/** The rail entry owning a route's vendor and plugin segments. */
export function activeEntry(vendor: string, plugin: string): NavigationEntry | null {
const key = `${vendor}.${plugin}`
return (
railEntries.value.find(
(entry) => pluginKey(entry.controller) === key || entry.sideMenu.some((item) => pluginKey(item.controller) === key),
) ?? null
)
}
/** Where "/" lands: the first plugin's first controller. */
export function homePath(): string | null {
for (const entry of railEntries.value) {
const path = firstControllerPath(entry)
if (path) {
return path
}
}
return null
}
export function useNavigation() {
return { navigation, railEntries, loadNavigation, firstControllerPath, activeEntry, homePath }
}

160
admin/src/styles/main.css Normal file
View File

@@ -0,0 +1,160 @@
@import "tailwindcss";
/* Self-hosted fonts (D-07). Whole-weight files carry latin and latin-ext with
unicode-range, so Polish diacritics and basic Latin both resolve. */
@import "@fontsource/dm-sans/400.css";
@import "@fontsource/dm-sans/500.css";
@import "@fontsource/dm-sans/600.css";
@import "@fontsource/dm-sans/700.css";
@import "@fontsource/dm-mono/400.css";
@import "@fontsource/dm-mono/500.css";
@custom-variant dark (&:where(.dark, .dark *));
/* Design tokens from design/README.md (Direction C v2). Utilities read CSS
variables so light and dark mode swap values without new classes. */
@theme {
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "DM Mono", ui-monospace, monospace;
--color-bg: var(--c-bg);
--color-surface: var(--c-surface);
--color-subtle: var(--c-subtle);
--color-border: var(--c-border);
--color-border-strong: var(--c-border-strong);
--color-text: var(--c-text);
--color-muted: var(--c-muted);
--color-placeholder: var(--c-placeholder);
--color-primary: var(--c-primary);
--color-on-primary: var(--c-on-primary);
--color-danger: var(--c-danger);
--color-danger-soft: var(--c-danger-soft);
--color-ok-bg: var(--c-ok-bg);
--color-ok-text: var(--c-ok-text);
--color-ring: var(--c-ring);
--color-hover: var(--c-hover);
--color-sel: var(--c-sel);
--color-skel: var(--c-skel);
--color-overlay: var(--c-overlay);
--color-side: var(--c-side);
--color-side-border: var(--c-side-border);
--color-accent: #fcd34d;
--color-on-accent: #1b2540;
--color-accent-soft: rgba(252, 211, 77, 0.14);
--color-side-text: #c3cbda;
--color-side-label: #9aa6bd;
--color-side-hover: rgba(255, 255, 255, 0.08);
--radius-control: 10px;
--radius-card: 16px;
--radius-modal: 20px;
--radius-inner: 12px;
--radius-tab: 9px;
--radius-checkbox: 5px;
--radius-pager: 8px;
--radius-pill: 999px;
--spacing-button: 42px;
--spacing-input: 44px;
--spacing-header: 64px;
--spacing-nav: 40px;
--spacing-row: 54px;
--spacing-row-head: 44px;
--spacing-pager: 34px;
--spacing-rail: 80px;
--spacing-panel: 224px;
--shadow-card: var(--c-shadow-card);
--shadow-login: 0 24px 60px rgba(0, 0, 0, 0.35);
--shadow-pop: 0 16px 40px rgba(20, 27, 45, 0.18);
--shadow-menu: 0 16px 40px rgba(20, 27, 45, 0.16);
--shadow-toast: 0 16px 40px rgba(0, 0, 0, 0.25);
--shadow-tab: 0 1px 3px rgba(20, 27, 45, 0.12);
}
:root {
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-subtle: #f3f5f8;
--c-border: #e6e9ef;
--c-border-strong: #d2d8e2;
--c-text: #141b2d;
--c-muted: #566175;
--c-placeholder: #6b7588;
--c-primary: #22304d;
--c-on-primary: #ffffff;
--c-danger: #c62828;
--c-danger-soft: #fdf0f0;
--c-ok-bg: #e3f4e8;
--c-ok-text: #1c6b35;
--c-ring: rgba(252, 196, 40, 0.55);
--c-hover: #f1f3f7;
--c-sel: #fdf3cf;
--c-skel: #eceff4;
--c-overlay: rgba(20, 27, 45, 0.5);
--c-side: #1d2740;
--c-side-border: transparent;
--c-shadow-card: 0 1px 2px rgba(20, 27, 45, 0.04), 0 4px 16px rgba(20, 27, 45, 0.05);
}
.dark {
--c-bg: #111726;
--c-surface: #182033;
--c-subtle: #1f283d;
--c-border: #29334b;
--c-border-strong: #3a4661;
--c-text: #eef1f6;
--c-muted: #a9b3c6;
--c-placeholder: #8a95ab;
--c-primary: #fcd34d;
--c-on-primary: #1b2540;
--c-danger: #f58a8a;
--c-danger-soft: #3a1d24;
--c-ok-bg: #173826;
--c-ok-text: #8fdfa8;
--c-ring: rgba(252, 211, 77, 0.45);
--c-hover: #212b42;
--c-sel: #3a3622;
--c-skel: #263049;
--c-overlay: rgba(5, 8, 16, 0.7);
--c-side: #0d1320;
--c-side-border: #222b40;
--c-shadow-card: none;
}
@layer base {
html {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: var(--c-text);
background: var(--c-bg);
}
body {
margin: 0;
min-height: 100vh;
}
/* Every interactive element shows the 3px ring; never remove it. */
a:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid var(--c-ring);
outline-offset: 2px;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px var(--c-ring);
}
input::placeholder,
textarea::placeholder {
color: var(--c-placeholder);
}
}

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import type { AdminRecord, ListMeta, ListSchema } from '../api/types'
import { controllerIdFromParams } from '../app/controllerRoutes'
import { t } from '../app/i18n'
import DataTable from '../components/list/DataTable.vue'
import { activeEntry } from '../state/useNavigation'
const route = useRoute()
const path = computed(() => ({
vendor: String(route.params.vendor ?? ''),
plugin: String(route.params.plugin ?? ''),
controller: String(route.params.controller ?? ''),
}))
const schema = ref<ListSchema | null>(null)
const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(true)
const failed = ref(false)
const title = computed(() => {
if (schema.value?.title) {
return schema.value.title
}
const id = controllerIdFromParams(path.value)
const entry = activeEntry(path.value.vendor, path.value.plugin)
return entry?.sideMenu.find((item) => item.controller === id)?.label ?? id
})
const range = computed(() => {
const m = meta.value
if (!m || m.total === 0) {
return t('backend::lang.list.no_results')
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
})
let generation = 0
async function load(): Promise<void> {
const current = ++generation
loading.value = true
failed.value = false
const params = { path: path.value }
const [schemaResult, listResult] = await Promise.all([
api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params }),
api.GET('/{vendor}/{plugin}/{controller}', { params }),
])
if (current !== generation) {
return
}
schema.value = schemaResult.data?.data ?? null
rows.value = listResult.data?.data ?? []
meta.value = listResult.data?.meta ?? null
failed.value = !schemaResult.data || !listResult.data
loading.value = false
}
watch(path, load, { immediate: true, deep: true })
</script>
<template>
<section class="flex flex-col gap-5">
<header>
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
</header>
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
<p v-if="failed && !loading" role="alert" class="px-5 py-4 text-danger">
{{ t('backend::lang.list.load_failed') }}
</p>
<DataTable
v-else
:columns="schema?.columns ?? []"
:rows="rows"
:loading="loading"
:empty-text="schema?.noRecordsMessage"
/>
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
{{ loading ? t('backend::lang.list.loading') : range }}
</footer>
</div>
</section>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CircleAlert, Sun } from '@lucide/vue'
import { t } from '../app/i18n'
import { safeRedirect } from '../app/router'
import { login, me } from '../state/useAuth'
import { loadNavigation } from '../state/useNavigation'
const route = useRoute()
const router = useRouter()
const identifier = ref('')
const password = ref('')
const failed = ref(false)
const busy = ref(false)
async function submit(): Promise<void> {
if (busy.value) {
return
}
busy.value = true
failed.value = false
try {
const ok = await login(identifier.value.trim(), password.value)
if (!ok || !(await me())) {
failed.value = true
return
}
password.value = ''
await loadNavigation()
await router.replace(safeRedirect(route.query.redirect) ?? '/')
} finally {
busy.value = false
}
}
</script>
<template>
<main
class="flex min-h-screen items-center justify-center px-4"
style="background: radial-gradient(ellipse at 50% 35%, #2b3a5c 0%, #1d2740 60%)"
>
<div class="flex w-full max-w-[400px] flex-col gap-6">
<div class="flex items-center justify-center gap-3">
<span class="flex size-[34px] items-center justify-center rounded-full bg-accent-soft">
<Sun :size="20" class="text-accent" aria-hidden="true" />
</span>
<span class="text-[18px] font-bold text-white">Summer<span class="text-accent">CMS</span></span>
</div>
<form
class="flex flex-col gap-[18px] rounded-card bg-white p-8 text-[#141b2d] shadow-login"
novalidate
@submit.prevent="submit"
>
<h1 class="text-[22px] font-bold tracking-[-0.02em]">{{ t('backend::lang.auth.title') }}</h1>
<label class="flex flex-col gap-1.5">
<span class="font-semibold">{{ t('backend::lang.auth.login') }}</span>
<input
v-model="identifier"
name="login"
type="text"
autocomplete="username"
required
class="h-input rounded-control border border-[#d2d8e2] bg-white px-3.5"
/>
</label>
<label class="flex flex-col gap-1.5">
<span class="font-semibold">{{ t('backend::lang.auth.password') }}</span>
<input
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required
:aria-invalid="failed ? 'true' : undefined"
:class="failed ? 'border-[#c62828]' : 'border-[#d2d8e2]'"
class="h-input rounded-control border bg-white px-3.5"
/>
</label>
<div
v-if="failed"
role="alert"
class="flex items-center gap-2.5 rounded-control bg-[#fdf0f0] px-3.5 py-3 text-[#c62828]"
>
<CircleAlert :size="18" aria-hidden="true" />
<span>{{ t('backend::lang.auth.invalid') }}</span>
</div>
<button
type="submit"
:disabled="busy"
class="h-input rounded-control bg-[#22304d] font-semibold text-white disabled:opacity-60"
>
{{ t('backend::lang.auth.submit') }}
</button>
</form>
</div>
</main>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { t } from '../app/i18n'
defineProps<{ home?: boolean }>()
</script>
<template>
<section class="flex flex-col items-center gap-2 py-20 text-center">
<h1 class="text-[17px] font-bold">
{{ home ? t('backend::lang.nav.empty') : t('backend::lang.page.not_found') }}
</h1>
</section>
</template>