diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index e131319..b85fb19 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -1,6 +1,20 @@ { "components": { "schemas": { + "cabana.AdminIDsRequest": { + "properties": { + "ids": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, "cabana.AdminLoginData": { "properties": { "access_token": { @@ -1665,6 +1679,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminRecord" + } + } + }, + "description": "Setting values keyed by field name", + "required": true + }, "responses": { "200": { "content": { @@ -1863,6 +1888,19 @@ "schema": { "type": "integer" } + }, + { + "description": "Filter values keyed by filter name, sent as filter[\u003cname\u003e]=\u003cvalue\u003e", + "explode": true, + "in": "query", + "name": "filter", + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "style": "deepObject" } ], "responses": { @@ -1958,6 +1996,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminRecord" + } + } + }, + "description": "Field values keyed by field name; relation fields carry ids", + "required": true + }, "responses": { "201": { "content": { @@ -2052,6 +2101,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminIDsRequest" + } + } + }, + "description": "Record ids", + "required": true + }, "responses": { "200": { "content": { @@ -2840,6 +2900,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminRecord" + } + } + }, + "description": "Field values keyed by field name; relation fields carry ids", + "required": true + }, "responses": { "200": { "content": { @@ -3176,6 +3247,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminIDsRequest" + } + } + }, + "description": "Related record ids", + "required": true + }, "responses": { "200": { "content": { @@ -3288,6 +3370,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminIDsRequest" + } + } + }, + "description": "Related record ids", + "required": true + }, "responses": { "200": { "content": { diff --git a/admin/src/App.vue b/admin/src/App.vue index a63b032..d9f58b8 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -8,8 +8,12 @@ const inShell = computed(() => route.meta.shell === true) diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 0ad56d4..dae0e51 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -474,7 +474,12 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + /** @description Setting values keyed by field name */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminRecord"]; + }; + }; responses: { /** @description OK */ 200: { @@ -617,6 +622,10 @@ export interface paths { page?: number; /** @description Records per page */ per_page?: number; + /** @description Filter values keyed by filter name, sent as filter[]= */ + filter?: { + [key: string]: string; + }; }; header?: never; path: { @@ -697,7 +706,12 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + /** @description Field values keyed by field name; relation fields carry ids */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminRecord"]; + }; + }; responses: { /** @description Created */ 201: { @@ -776,7 +790,12 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + /** @description Record ids */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminIDsRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -1299,7 +1318,12 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + /** @description Field values keyed by field name; relation fields carry ids */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminRecord"]; + }; + }; responses: { /** @description OK */ 200: { @@ -1614,7 +1638,12 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + /** @description Related record ids */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminIDsRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -1697,7 +1726,12 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + /** @description Related record ids */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminIDsRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -1756,6 +1790,9 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + "cabana.AdminIDsRequest": { + ids: number[]; + }; "cabana.AdminLoginData": { access_token?: string; expires_in?: number; diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 89bcde4..26458f1 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -1,8 +1,10 @@ // 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' +import type { components, paths } from './schema' type Schemas = components['schemas'] +type ControllerPath = paths['/{vendor}/{plugin}/{controller}'] +type FieldOptionsPath = paths['/{vendor}/{plugin}/{controller}/fields/{field}/options'] export type AdminLoginData = Schemas['cabana.AdminLoginData'] export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest'] @@ -36,3 +38,13 @@ export type LangBundle = Schemas['cabana.LangBundle'] export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope'] /** One record: a string-keyed map read through its list or form schema. */ export type AdminRecord = Schemas['cabana.AdminRecord'] +export type ErrorBody = Schemas['cabana.ErrorBody'] +export type SuccessMeta = Schemas['cabana.SuccessMeta'] +/** Body of bulk delete and relation link/unlink. */ +export type IDsRequest = Schemas['cabana.AdminIDsRequest'] +/** Path parameters naming one controller: vendor, plugin, controller (D-10). */ +export type ControllerParams = ControllerPath['get']['parameters']['path'] +/** The list query: search, sort, dir, page, per_page and filter[]. */ +export type ListQuery = NonNullable +/** Relation field options query: search, page, per_page. */ +export type FieldOptionsQuery = NonNullable diff --git a/admin/src/app/i18n.ts b/admin/src/app/i18n.ts index 03ad1b3..b8a9b8b 100644 --- a/admin/src/app/i18n.ts +++ b/admin/src/app/i18n.ts @@ -1,18 +1,59 @@ -// 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' +// UI strings (D-20, D-24). The resolved backend::lang bundle is loaded from +// the public GET /lang before anything else at startup. A key that is not in +// the bundle renders as the key itself, mirroring phrasebook's missing-key +// fallback. Every message is a CLDR form map; plural messages pick their form +// with Intl.PluralRules for the bundle's locale and fall back to "other". +// Placeholders use phrasebook's :name syntax with :Name and :NAME variants, +// and interpolate() replaces them exactly like phrasebook.interpolate. +import { readonly, ref } from 'vue' +import { api } from '../api/client' +import type { LangBundle, MessageForms } from '../api/types' -type Forms = Record +export type MessageParams = Record -const bundle = ref>({}) +const bundle = ref({}) +const locale = ref(typeof document === 'undefined' ? 'pl' : document.documentElement.lang || 'pl') -export function setBundle(next: Record): void { +/** The locale the loaded bundle resolved to (meta.locale of GET /lang). */ +export const currentLocale = readonly(locale) + +export function setBundle(next: LangBundle, nextLocale?: string): void { bundle.value = next + if (nextLocale) { + locale.value = nextLocale + if (typeof document !== 'undefined') { + document.documentElement.lang = nextLocale + } + } } -export function interpolate(text: string, params: Record = {}): string { +/** Loads the backend::lang bundle for the admin's locale. Public endpoint. */ +export async function loadStrings(): Promise { + const { data, response } = await api.GET('/lang') + if (!response.ok || !data) { + return + } + setBundle(data.data, data.meta.locale) +} + +/** Upper-cases the first code point, like phrasebook's ucfirst. */ +function ucfirst(value: string): string { + const first = value.codePointAt(0) + if (first === undefined) { + return value + } + const head = String.fromCodePoint(first) + return head.toUpperCase() + value.slice(head.length) +} + +/** + * Replaces :name, :Name and :NAME placeholders. Longer placeholders are + * replaced first (stable for equal lengths), so :names never loses to :name. + */ +export function interpolate(text: string, params: MessageParams = {}): string { + if (text === '') { + return text + } const replacements: Array<[string, string]> = [] for (const [rawName, raw] of Object.entries(params)) { const name = rawName.replace(/^:/, '') @@ -20,7 +61,7 @@ export function interpolate(text: string, params: Record = {}): string { - const forms = bundle.value[key] - const text = forms?.other +const pluralRules = new Map() + +/** CLDR plural category of count in the bundle locale; "other" if unknown. */ +export function pluralCategory(count: number, forLocale: string = locale.value): string { + let rules = pluralRules.get(forLocale) + if (rules === undefined) { + try { + rules = new Intl.PluralRules(forLocale) + } catch { + rules = null + } + pluralRules.set(forLocale, rules) + } + return rules ? rules.select(count) : 'other' +} + +/** + * Renders one message (CLDR forms). With a count the plural form is chosen + * and :count is available to the text; params may override it. + */ +export function message(forms: MessageForms | null | undefined, count?: number, params: MessageParams = {}): string { + if (!forms) { + return '' + } + if (count === undefined) { + return interpolate(forms.other ?? '', params) + } + const text = forms[pluralCategory(count)] ?? forms.other ?? '' + return interpolate(text, { count, ...params }) +} + +/** A bundle string by key; the key itself when it is not loaded. */ +export function t(key: string, params: MessageParams = {}): string { + const text = bundle.value[key]?.other if (text === undefined) { return key } return interpolate(text, params) } + +/** A plural bundle string by key; the key itself when it is not loaded. */ +export function tc(key: string, count: number, params: MessageParams = {}): string { + const forms = bundle.value[key] + if (!forms) { + return key + } + return message(forms, count, params) +} diff --git a/admin/src/app/router.ts b/admin/src/app/router.ts index f8440cd..d547d69 100644 --- a/admin/src/app/router.ts +++ b/admin/src/app/router.ts @@ -4,6 +4,7 @@ import { currentUser } from '../state/useAuth' import { homePath } from '../state/useNavigation' import LoginView from '../views/LoginView.vue' import ListView from '../views/ListView.vue' +import FormView from '../views/FormView.vue' import NotFoundView from '../views/NotFoundView.vue' declare module 'vue-router' { @@ -43,6 +44,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt beforeEnter: () => homePath() ?? true, }, { path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } }, + { path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } }, + { path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } }, { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } }, ], }) diff --git a/admin/src/app/winterUrl.ts b/admin/src/app/winterUrl.ts new file mode 100644 index 0000000..8f78923 --- /dev/null +++ b/admin/src/app/winterUrl.ts @@ -0,0 +1,39 @@ +// Winter-shaped URLs (config_list recordUrl, config_form redirects) mapped +// onto the SPA's D-10 routes. The strings come from plugin YAML, so they are +// never used verbatim: only the current controller's list, create and record +// routes can come out (research Gap 8, T-10-20). +// +// // -> list +// ///create -> create +// ///update/:id -> record (:id substituted) +// anything else, or another controller -> list +import { controllerPath, parseControllerId } from './controllerRoutes' + +const DIGITS = /^\d+$/ + +export function mapWinterUrl(url: string | null | undefined, controllerId: string, id?: string | number | null): string { + const params = parseControllerId(controllerId) + const base = controllerPath(controllerId) + if (!params || !base) { + return '/' + } + const raw = (url ?? '').trim().split(/[?#]/, 1)[0] ?? '' + const segments = raw.split('/').filter((segment) => segment !== '') + const owner = [params.vendor, params.plugin, params.controller] + const ownsUrl = owner.every((segment, index) => segments[index]?.toLowerCase() === segment.toLowerCase()) + if (!ownsUrl) { + return base + } + const rest = segments.slice(owner.length) + if (rest.length === 0) { + return base + } + if (rest.length === 1 && rest[0] === 'create') { + return `${base}/create` + } + if (rest.length === 2 && rest[0] === 'update') { + const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '') + return DIGITS.test(target) ? `${base}/${target}` : base + } + return base +} diff --git a/admin/src/components/form/FieldRenderer.vue b/admin/src/components/form/FieldRenderer.vue new file mode 100644 index 0000000..1ba86c2 --- /dev/null +++ b/admin/src/components/form/FieldRenderer.vue @@ -0,0 +1,25 @@ + + + diff --git a/admin/src/components/form/FormErrorBanner.vue b/admin/src/components/form/FormErrorBanner.vue new file mode 100644 index 0000000..1b4371f --- /dev/null +++ b/admin/src/components/form/FormErrorBanner.vue @@ -0,0 +1,35 @@ + + + diff --git a/admin/src/components/form/FormField.vue b/admin/src/components/form/FormField.vue new file mode 100644 index 0000000..447c6a4 --- /dev/null +++ b/admin/src/components/form/FormField.vue @@ -0,0 +1,59 @@ + + + diff --git a/admin/src/components/form/FormGrid.vue b/admin/src/components/form/FormGrid.vue new file mode 100644 index 0000000..112fdfb --- /dev/null +++ b/admin/src/components/form/FormGrid.vue @@ -0,0 +1,48 @@ + + + diff --git a/admin/src/components/form/fields/DropdownField.vue b/admin/src/components/form/fields/DropdownField.vue new file mode 100644 index 0000000..233b02b --- /dev/null +++ b/admin/src/components/form/fields/DropdownField.vue @@ -0,0 +1,79 @@ + + + diff --git a/admin/src/components/form/fields/NumberField.vue b/admin/src/components/form/fields/NumberField.vue new file mode 100644 index 0000000..a0ff0b1 --- /dev/null +++ b/admin/src/components/form/fields/NumberField.vue @@ -0,0 +1,39 @@ + + + diff --git a/admin/src/components/form/fields/TextField.vue b/admin/src/components/form/fields/TextField.vue new file mode 100644 index 0000000..9ced756 --- /dev/null +++ b/admin/src/components/form/fields/TextField.vue @@ -0,0 +1,27 @@ + + + diff --git a/admin/src/components/form/fields/TextareaField.vue b/admin/src/components/form/fields/TextareaField.vue new file mode 100644 index 0000000..555fc70 --- /dev/null +++ b/admin/src/components/form/fields/TextareaField.vue @@ -0,0 +1,31 @@ + + +