Add a language-switch system with per-language files

Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.

- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
  reactive to the signed-in profile locale. Every view, component, form and
  the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
  language (the panel has no user profile) and a header language picker.
  Chrome, cards, login and API section titles translated; endpoint reference
  descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
  driven by AppSettings.locale. Nav, login, lock, dashboard, the full
  Settings panel (incl. language picker) and format.dart status labels
  translated; remaining detail screens fall back to English.

Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.

Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-17 20:07:48 +02:00
1 parent ee28b522c7
commit b6bb6b1df0
54 files changed
+4191 -979

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();/**
* @vue/shared v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function ln(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const Y={},xt=[],He=()=>{},gi=()=>!1,ks=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Ss=e=>e.startsWith("onUpdate:"),he=Object.assign,an=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},$o=Object.prototype.hasOwnProperty,G=(e,t)=>$o.call(e,t),L=Array.isArray,kt=e=>es(e)==="[object Map]",Rt=e=>es(e)==="[object Set]",Rn=e=>es(e)==="[object Date]",H=e=>typeof e=="function",ne=e=>typeof e=="string",Ve=e=>typeof e=="symbol",Z=e=>e!==null&&typeof e=="object",mi=e=>(Z(e)||H(e))&&H(e.then)&&H(e.catch),bi=Object.prototype.toString,es=e=>bi.call(e),Ao=e=>es(e).slice(8,-1),vi=e=>es(e)==="[object Object]",cn=e=>ne(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Ft=ln(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ts=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},Oo=/-\w/g,Ce=Ts(e=>e.replace(Oo,t=>t.slice(1).toUpperCase())),Ro=/\B([A-Z])/g,ct=Ts(e=>e.replace(Ro,"-$1").toLowerCase()),_i=Ts(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ms=Ts(e=>e?`on${_i(e)}`:""),Be=(e,t)=>!Object.is(e,t),fs=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},yi=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Es=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let In;const Cs=()=>In||(In=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function un(e){if(L(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],i=ne(n)?Mo(n):un(n);if(i)for(const o in i)t[o]=i[o]}return t}else if(ne(e)||Z(e))return e}const Io=/;(?![^(]*\))/g,Uo=/:([^]+)/,jo=/\/\*[^]*?\*\//g;function Mo(e){const t={};return e.replace(jo,"").split(Io).forEach(s=>{if(s){const n=s.split(Uo);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function $e(e){let t="";if(ne(e))t=e;else if(L(e))for(let s=0;s<e.length;s++){const n=$e(e[s]);n&&(t+=n+" ")}else if(Z(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const No="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Do=ln(No);function wi(e){return!!e||e===""}function zo(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=rt(e[n],t[n]);return s}function rt(e,t){if(e===t)return!0;let s=Rn(e),n=Rn(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=Ve(e),n=Ve(t),s||n)return e===t;if(s=L(e),n=L(t),s||n)return s&&n?zo(e,t):!1;if(s=Z(e),n=Z(t),s||n){if(!s||!n)return!1;const i=Object.keys(e).length,o=Object.keys(t).length;if(i!==o)return!1;for(const r in e){const l=e.hasOwnProperty(r),a=t.hasOwnProperty(r);if(l&&!a||!l&&a||!rt(e[r],t[r]))return!1}}return String(e)===String(t)}function dn(e,t){return e.findIndex(s=>rt(s,t))}const xi=e=>!!(e&&e.__v_isRef===!0),b=e=>ne(e)?e:e==null?"":L(e)||Z(e)&&(e.toString===bi||!H(e.toString))?xi(e)?b(e.value):JSON.stringify(e,ki,2):String(e),ki=(e,t)=>xi(t)?ki(e,t.value):kt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,i],o)=>(s[Ns(n,o)+" =>"]=i,s),{})}:Rt(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ns(s))}:Ve(t)?Ns(t):Z(t)&&!L(t)&&!vi(t)?String(t):t,Ns=(e,t="")=>{var s;return Ve(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
* @vue/reactivity v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let ae;class Lo{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&ae&&(ae.active?(this.parent=ae,this.index=(ae.scopes||(ae.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=ae;try{return ae=this,t()}finally{ae=s}}}on(){++this._on===1&&(this.prevScope=ae,ae=this)}off(){if(this._on>0&&--this._on===0){if(ae===this)ae=this.prevScope;else{let t=ae;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const i=this.parent.scopes.pop();i&&i!==this&&(this.parent.scopes[this.index]=i,i.index=this.index)}this.parent=void 0}}}function Fo(){return ae}let Q;const Ds=new WeakSet;class Si{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,ae&&(ae.active?ae.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Ds.has(this)&&(Ds.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||Ei(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,Un(this),Ci(this);const t=Q,s=Pe;Q=this,Pe=!0;try{return this.fn()}finally{Pi(this),Q=t,Pe=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)hn(t);this.deps=this.depsTail=void 0,Un(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Ds.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){qs(this)&&this.run()}get dirty(){return qs(this)}}let Ti=0,Bt,Ht;function Ei(e,t=!1){if(e.flags|=8,t){e.next=Ht,Ht=e;return}e.next=Bt,Bt=e}function fn(){Ti++}function pn(){if(--Ti>0)return;if(Ht){let t=Ht;for(Ht=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Bt;){let t=Bt;for(Bt=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function Ci(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Pi(e){let t,s=e.depsTail,n=s;for(;n;){const i=n.prevDep;n.version===-1?(n===s&&(s=i),hn(n),Bo(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=s}function qs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&($i(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function $i(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===qt)||(e.globalVersion=qt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!qs(e))))return;e.flags|=2;const t=e.dep,s=Q,n=Pe;Q=e,Pe=!0;try{Ci(e);const i=e.fn(e._value);(t.version===0||Be(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{Q=s,Pe=n,Pi(e),e.flags&=-3}}function hn(e,t=!1){const{dep:s,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let o=s.computed.deps;o;o=o.nextDep)hn(o,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Bo(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let Pe=!0;const Ai=[];function Ke(){Ai.push(Pe),Pe=!1}function We(){const e=Ai.pop();Pe=e===void 0?!0:e}function Un(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=Q;Q=void 0;try{t()}finally{Q=s}}}let qt=0;class Ho{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class gn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!Q||!Pe||Q===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==Q)s=this.activeLink=new Ho(Q,this),Q.deps?(s.prevDep=Q.depsTail,Q.depsTail.nextDep=s,Q.depsTail=s):Q.deps=Q.depsTail=s,Oi(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=Q.depsTail,s.nextDep=void 0,Q.depsTail.nextDep=s,Q.depsTail=s,Q.deps===s&&(Q.deps=n)}return s}trigger(t){this.version++,qt++,this.notify(t)}Line truncated
* @vue/runtime-core v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function ts(e,t,s,n){try{return n?e(...n):e()}catch(i){$s(i,t,s)}}function Oe(e,t,s,n){if(H(e)){const i=ts(e,t,s,n);return i&&mi(i)&&i.catch(o=>{$s(o,t,s)}),i}if(L(e)){const i=[];for(let o=0;o<e.length;o++)i.push(Oe(e[o],t,s,n));return i}}function $s(e,t,s,n=!0){const i=t?t.vnode:null,{errorHandler:o,throwUnhandledErrorInProduction:r}=t&&t.appContext.config||Y;if(t){let l=t.parent;const a=t.proxy,p=`https://vuejs.org/error-reference/#runtime-${s}`;for(;l;){const u=l.ec;if(u){for(let m=0;m<u.length;m++)if(u[m](e,a,p)===!1)return}l=l.parent}if(o){Ke(),ts(o,null,10,[e,a,p]),We();return}}hr(e,s,i,n,r)}function hr(e,t,s,n=!0,i=!1){if(i)throw e;console.error(e)}const be=[];let ze=-1;const St=[];let ot=null,wt=0;const zi=Promise.resolve();let ms=null;function Li(e){const t=ms||zi;return e?t.then(this?e.bind(this):e):t}function gr(e){let t=ze+1,s=be.length;for(;t<s;){const n=t+s>>>1,i=be[n],o=Yt(i);o<e||o===e&&i.flags&2?t=n+1:s=n}return t}function _n(e){if(!(e.flags&1)){const t=Yt(e),s=be[be.length-1];!s||!(e.flags&2)&&t>=Yt(s)?be.push(e):be.splice(gr(t),0,e),e.flags|=1,Fi()}}function Fi(){ms||(ms=zi.then(Hi))}function mr(e){L(e)?St.push(...e):ot&&e.id===-1?ot.splice(wt+1,0,e):e.flags&1||(St.push(e),e.flags|=1),Fi()}function Mn(e,t,s=ze+1){for(;s<be.length;s++){const n=be[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;be.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function Bi(e){if(St.length){const t=[...new Set(St)].sort((s,n)=>Yt(s)-Yt(n));if(St.length=0,ot){ot.push(...t);return}for(ot=t,wt=0;wt<ot.length;wt++){const s=ot[wt];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}ot=null,wt=0}}const Yt=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Hi(e){try{for(ze=0;ze<be.length;ze++){const t=be[ze];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),ts(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;ze<be.length;ze++){const t=be[ze];t&&(t.flags&=-2)}ze=-1,be.length=0,Bi(),ms=null,(be.length||St.length)&&Hi()}}let xe=null,Vi=null;function bs(e){const t=xe;return xe=e,Vi=e&&e.type.__scopeId||null,t}function br(e,t=xe,s){if(!t||e._n)return e;const n=(...i)=>{n._d&&Gn(-1);const o=bs(t);let r;try{r=e(...i)}finally{bs(o),n._d&&Gn(1)}return r};return n._n=!0,n._c=!0,n._d=!0,n}function re(e,t){if(xe===null)return e;const s=Is(xe),n=e.dirs||(e.dirs=[]);for(let i=0;i<t.length;i++){let[o,r,l,a=Y]=t[i];o&&(H(o)&&(o={mounted:o,updated:o}),o.deep&&Ye(r),n.push({dir:o,instance:s,value:r,oldValue:void 0,arg:l,modifiers:a}))}return e}function ht(e,t,s,n){const i=e.dirs,o=t&&t.dirs;for(let r=0;r<i.length;r++){const l=i[r];o&&(l.oldValue=o[r].value);let a=l.dir[n];a&&(Ke(),Oe(a,s,8,[e.el,l,e,t]),We())}}function vr(e,t){if(ve){let s=ve.provides;const n=ve.parent&&ve.parent.provides;n===s&&(s=ve.provides=Object.create(n)),s[e]=t}}function ps(e,t,s=!1){const n=gl();if(n||Tt){let i=Tt?Tt._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return s&&H(t)?t.call(n&&n.proxy):t}}const _r=Symbol.for("v-scx"),yr=()=>ps(_r);function Fs(e,t,s){return Ki(e,t,s)}function Ki(e,t,s=Y){const{immediate:n,deep:i,flush:o,once:r}=s,l=he({},s),a=t&&n||!t&&o!=="post";let p;if(Qt){if(o==="sync"){const A=yr();p=A.__watcherHandles||(A.__watcherHandles=[])}else if(!a){const A=()=>{};return A.stop=He,A.resume=He,A.pause=He,A}}const u=ve;l.call=(A,D,C)=>Oe(A,u,D,C);let m=!1;o==="post"?l.scheduler=A=>{_e(A,u&&u.suspense)}:o!=="sync"&&(m=!0,l.scheduler=(A,D)=>{D?A():_n(A)}),l.augmentJob=A=>{t&&(A.flags|=4),m&&(A.flags|=2,u&&(A.id=u.uid,A.i=u))};const y=pr(e,t,l);return Qt&&(p?p.push(y):a&&y()),y}function wr(e,t,s){const n=this.proxy,i=ne(e)?e.includes(".")?Wi(n,e):()=>n[e]:e.bind(n,n);let o;H(t)?o=t:(o=t.handler,s=t);const r=ss(this),l=Ki(i,o.bind(n),s);return r(),l}function Wi(e,t){const s=t.split(".");return()=>{let n=e;for(let i=0;i<s.length&&n;i++)n=n[s[i]];return n}}const xr=Symbol("_vte"),kr=e=>e.__isTeleport,Bs=Symbol("_leaveCb");function yn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,yn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Gi(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Nn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const vs=new WeakMap;function Vt(e,t,s,n,i=!1){if(L(e)){e.forEach((C,P)=>Vt(C,t&&(L(t)?t[P]:t),s,n,i));return}if(Kt(n)&&!i){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Vt(e,t,s,n.component.subTree);return}const o=n.shapeFlag&4?Is(n.component):n.el,r=i?null:o,{i:l,r:a}=e,p=t&&t.r,u=l.refs===Y?l.refs={}:l.refs,m=l.setupState,y=W(m),A=m===Y?gi:C=>Nn(u,C)?!1:G(y,C),D=(C,P)=>!(P&&Nn(u,P));if(p!=null&&p!==a){if(Dn(t),ne(p))u[p]=null,A(p)&&(m[p]=null);else if(fe(p)){const C=t;D(p,C.k)&&(p.value=null),C.k&&(u[C.k]=null)}}if(H(a)){Ke();try{ts(a,l,12,[r,u])}finally{We()}}else{const C=ne(a),P=fe(a);if(C||P){const T=()=>{if(e.f){con Line truncated
* @vue/runtime-dom v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let on;const Jn=typeof window<"u"&&window.trustedTypes;if(Jn)try{on=Jn.createPolicy("vue",{createHTML:e=>e})}catch{}const _o=on?e=>on.createHTML(e):e=>e,xl="http://www.w3.org/2000/svg",kl="http://www.w3.org/1998/Math/MathML",qe=typeof document<"u"?document:null,Yn=qe&&qe.createElement("template"),Sl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?qe.createElementNS(xl,e):t==="mathml"?qe.createElementNS(kl,e):s?qe.createElement(e,{is:s}):qe.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>qe.createTextNode(e),createComment:e=>qe.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>qe.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{Yn.innerHTML=_o(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const l=Yn.content;if(n==="svg"||n==="mathml"){const a=l.firstChild;for(;a.firstChild;)l.appendChild(a.firstChild);l.removeChild(a)}t.insertBefore(l,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Tl=Symbol("_vtc");function El(e,t,s){const n=e[Tl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const Xn=Symbol("_vod"),Cl=Symbol("_vsh"),Pl=Symbol(""),$l=/(?:^|;)\s*display\s*:/;function Al(e,t,s){const n=e.style,i=ne(s);let o=!1;if(s&&!i){if(t)if(ne(t))for(const r of t.split(";")){const l=r.slice(0,r.indexOf(":")).trim();s[l]==null&&Lt(n,l,"")}else for(const r in t)s[r]==null&&Lt(n,r,"");for(const r in s){r==="display"&&(o=!0);const l=s[r];l!=null?Rl(e,r,!ne(t)&&t?t[r]:void 0,l)||Lt(n,r,l):Lt(n,r,"")}}else if(i){if(t!==s){const r=n[Pl];r&&(s+=";"+r),n.cssText=s,o=$l.test(s)}}else t&&e.removeAttribute("style");Xn in e&&(e[Xn]=o?n.display:"",e[Cl]&&(n.display="none"))}const Qn=/\s*!important$/;function Lt(e,t,s){if(L(s))s.forEach(n=>Lt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Ol(e,t);Qn.test(s)?e.setProperty(ct(n),s.replace(Qn,""),"important"):e[n]=s}}const ei=["Webkit","Moz","ms"],Ws={};function Ol(e,t){const s=Ws[t];if(s)return s;let n=Ce(t);if(n!=="filter"&&n in e)return Ws[t]=n;n=_i(n);for(let i=0;i<ei.length;i++){const o=ei[i]+n;if(o in e)return Ws[t]=o}return t}function Rl(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&ne(n)&&s===n}const ti="http://www.w3.org/1999/xlink";function si(e,t,s,n,i,o=Do(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(ti,t.slice(6,t.length)):e.setAttributeNS(ti,t,s):s==null||o&&!wi(s)?e.removeAttribute(t):e.setAttribute(t,o?"":Ve(s)?String(s):s)}function ni(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?_o(s):s);return}const o=e.tagName;if(t==="value"&&o!=="PROGRESS"&&!o.includes("-")){const l=o==="OPTION"?e.getAttribute("value")||"":e.value,a=s==null?e.type==="checkbox"?"on":"":String(s);(l!==a||!("_value"in e))&&(e.value=a),s==null&&e.removeAttribute(t),e._value=s;return}let r=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=wi(s):s==null&&l==="string"?(s="",r=!0):l==="number"&&(s=0,r=!0)}try{e[t]=s}catch{}r&&e.removeAttribute(i||t)}function Xe(e,t,s,n){e.addEventListener(t,s,n)}function Il(e,t,s,n){e.removeEventListener(t,s,n)}const ii=Symbol("_vei");function Ul(e,t,s,n,i=null){const o=e[ii]||(e[ii]={}),r=o[t];if(n&&r)r.value=n;else{const[l,a]=Nl(t);if(n){const p=o[t]=Ll(n,i);Xe(e,l,p,a)}else r&&(Il(e,l,r,a),o[t]=void 0)}}const jl=/(Once|Passive|Capture)$/,Ml=/^on:?(?:Once|Passive|Capture)$/;function Nl(e){let t,s;for(;(s=e.match(jl))&&!Ml.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):ct(e.slice(2)),t]}let Gs=0;const Dl=Promise.resolve(),zl=()=>Gs||(Dl.then(()=>Gs=0),Gs=Date.now());function Ll(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(L(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),l=[n];for(let a=0;a<r.length&&!n._stopped;a++){const p=r[a];p&&Oe(p,t,5,l)}}else Oe(i,t,5,[n])};return s.value=e,s.attached=zl(),s}const oi=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Fl=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?El(e,n,r):t==="style"?Al(e,s,n):ks(t)?Ss(t)||Ul(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Bl(e,t,n,r))?(ni(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&si(e,t,n,r,o,t!=="value")):e._isVueCE&&(Hl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ne(n)))?ni(e,Ce(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._fals Line truncated
@@ -1 +1 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:.25rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--shadow-xs:var(--shadow-xs);--shadow-sm:var(--shadow-sm);--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-control:12px;--radius-card:18px;--radius-pill:999px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-Line truncated
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:.25rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--shadow-xs:var(--shadow-xs);--shadow-sm:var(--shadow-sm);--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-control:12px;--radius-card:18px;--radius-pill:999px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-Line truncated
-17
View File
@@ -1,17 +0,0 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const l of i)if(l.type==="childList")for(const o of l.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function s(i){const l={};return i.integrity&&(l.integrity=i.integrity),i.referrerPolicy&&(l.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?l.credentials="include":i.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function n(i){if(i.ep)return;i.ep=!0;const l=s(i);fetch(i.href,l)}})();/**
* @vue/shared v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function ln(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const J={},xt=[],He=()=>{},ui=()=>!1,xs=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ws=e=>e.startsWith("onUpdate:"),pe=Object.assign,on=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},xl=Object.prototype.hasOwnProperty,W=(e,t)=>xl.call(e,t),U=Array.isArray,wt=e=>Zt(e)==="[object Map]",At=e=>Zt(e)==="[object Set]",Pn=e=>Zt(e)==="[object Date]",V=e=>typeof e=="function",te=e=>typeof e=="string",Be=e=>typeof e=="symbol",z=e=>e!==null&&typeof e=="object",fi=e=>(z(e)||V(e))&&V(e.then)&&V(e.catch),di=Object.prototype.toString,Zt=e=>di.call(e),wl=e=>Zt(e).slice(8,-1),pi=e=>Zt(e)==="[object Object]",rn=e=>te(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Nt=ln(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ss=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},Sl=/-\w/g,Ee=Ss(e=>e.replace(Sl,t=>t.slice(1).toUpperCase())),Tl=/\B([A-Z])/g,rt=Ss(e=>e.replace(Tl,"-$1").toLowerCase()),hi=Ss(e=>e.charAt(0).toUpperCase()+e.slice(1)),Is=Ss(e=>e?`on${hi(e)}`:""),Ve=(e,t)=>!Object.is(e,t),us=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},gi=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Ts=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let An;const Cs=()=>An||(An=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function an(e){if(U(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],i=te(n)?$l(n):an(n);if(i)for(const l in i)t[l]=i[l]}return t}else if(te(e)||z(e))return e}const Cl=/;(?![^(]*\))/g,El=/:([^]+)/,kl=/\/\*[^]*?\*\//g;function $l(e){const t={};return e.replace(kl,"").split(Cl).forEach(s=>{if(s){const n=s.split(El);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function $e(e){let t="";if(te(e))t=e;else if(U(e))for(let s=0;s<e.length;s++){const n=$e(e[s]);n&&(t+=n+" ")}else if(z(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const Pl="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Al=ln(Pl);function mi(e){return!!e||e===""}function Ol(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=it(e[n],t[n]);return s}function it(e,t){if(e===t)return!0;let s=Pn(e),n=Pn(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=Be(e),n=Be(t),s||n)return e===t;if(s=U(e),n=U(t),s||n)return s&&n?Ol(e,t):!1;if(s=z(e),n=z(t),s||n){if(!s||!n)return!1;const i=Object.keys(e).length,l=Object.keys(t).length;if(i!==l)return!1;for(const o in e){const r=e.hasOwnProperty(o),a=t.hasOwnProperty(o);if(r&&!a||!r&&a||!it(e[o],t[o]))return!1}}return String(e)===String(t)}function cn(e,t){return e.findIndex(s=>it(s,t))}const bi=e=>!!(e&&e.__v_isRef===!0),I=e=>te(e)?e:e==null?"":U(e)||z(e)&&(e.toString===di||!V(e.toString))?bi(e)?I(e.value):JSON.stringify(e,vi,2):String(e),vi=(e,t)=>bi(t)?vi(e,t.value):wt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,i],l)=>(s[Ds(n,l)+" =>"]=i,s),{})}:At(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ds(s))}:Be(t)?Ds(t):z(t)&&!U(t)&&!pi(t)?String(t):t,Ds=(e,t="")=>{var s;return Be(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
* @vue/reactivity v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let re;class Rl{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&re&&(re.active?(this.parent=re,this.index=(re.scopes||(re.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=re;try{return re=this,t()}finally{re=s}}}on(){++this._on===1&&(this.prevScope=re,re=this)}off(){if(this._on>0&&--this._on===0){if(re===this)re=this.prevScope;else{let t=re;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const i=this.parent.scopes.pop();i&&i!==this&&(this.parent.scopes[this.index]=i,i.index=this.index)}this.parent=void 0}}}function Ml(){return re}let X;const Fs=new WeakSet;class _i{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,re&&(re.active?re.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Fs.has(this)&&(Fs.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||xi(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,On(this),wi(this);const t=X,s=ke;X=this,ke=!0;try{return this.fn()}finally{Si(this),X=t,ke=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)dn(t);this.deps=this.depsTail=void 0,On(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Fs.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Gs(this)&&this.run()}get dirty(){return Gs(this)}}let yi=0,jt,Vt;function xi(e,t=!1){if(e.flags|=8,t){e.next=Vt,Vt=e;return}e.next=jt,jt=e}function un(){yi++}function fn(){if(--yi>0)return;if(Vt){let t=Vt;for(Vt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;jt;){let t=jt;for(jt=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function wi(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Si(e){let t,s=e.depsTail,n=s;for(;n;){const i=n.prevDep;n.version===-1?(n===s&&(s=i),dn(n),Il(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=s}function Gs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Ti(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Ti(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Gt)||(e.globalVersion=Gt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Gs(e))))return;e.flags|=2;const t=e.dep,s=X,n=ke;X=e,ke=!0;try{wi(e);const i=e.fn(e._value);(t.version===0||Ve(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{X=s,ke=n,Si(e),e.flags&=-3}}function dn(e,t=!1){const{dep:s,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let l=s.computed.deps;l;l=l.nextDep)dn(l,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Il(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let ke=!0;const Ci=[];function Ke(){Ci.push(ke),ke=!1}function We(){const e=Ci.pop();ke=e===void 0?!0:e}function On(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=X;X=void 0;try{t()}finally{X=s}}}let Gt=0;class Dl{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class pn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!X||!ke||X===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==X)s=this.activeLink=new Dl(X,this),X.deps?(s.prevDep=X.depsTail,X.depsTail.nextDep=s,X.depsTail=s):X.deps=X.depsTail=s,Ei(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=X.depsTail,s.nextDep=void 0,X.depsTail.nextDep=s,X.depsTail=s,X.deps===s&&(X.deps=n)}return s}trigger(t){this.version++,Gt++,this.notify(t)}Line truncated
* @vue/runtime-core v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function Qt(e,t,s,n){try{return n?e(...n):e()}catch(i){ks(i,t,s)}}function Ae(e,t,s,n){if(V(e)){const i=Qt(e,t,s,n);return i&&fi(i)&&i.catch(l=>{ks(l,t,s)}),i}if(U(e)){const i=[];for(let l=0;l<e.length;l++)i.push(Ae(e[l],t,s,n));return i}}function ks(e,t,s,n=!0){const i=t?t.vnode:null,{errorHandler:l,throwUnhandledErrorInProduction:o}=t&&t.appContext.config||J;if(t){let r=t.parent;const a=t.proxy,p=`https://vuejs.org/error-reference/#runtime-${s}`;for(;r;){const u=r.ec;if(u){for(let h=0;h<u.length;h++)if(u[h](e,a,p)===!1)return}r=r.parent}if(l){Ke(),Qt(l,null,10,[e,a,p]),We();return}}oo(e,s,i,n,o)}function oo(e,t,s,n=!0,i=!1){if(i)throw e;console.error(e)}const me=[];let Ue=-1;const St=[];let nt=null,yt=0;const Ii=Promise.resolve();let hs=null;function Di(e){const t=hs||Ii;return e?t.then(this?e.bind(this):e):t}function ro(e){let t=Ue+1,s=me.length;for(;t<s;){const n=t+s>>>1,i=me[n],l=Jt(i);l<e||l===e&&i.flags&2?t=n+1:s=n}return t}function bn(e){if(!(e.flags&1)){const t=Jt(e),s=me[me.length-1];!s||!(e.flags&2)&&t>=Jt(s)?me.push(e):me.splice(ro(t),0,e),e.flags|=1,Fi()}}function Fi(){hs||(hs=Ii.then(Ui))}function ao(e){U(e)?St.push(...e):nt&&e.id===-1?nt.splice(yt+1,0,e):e.flags&1||(St.push(e),e.flags|=1),Fi()}function Mn(e,t,s=Ue+1){for(;s<me.length;s++){const n=me[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;me.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function Li(e){if(St.length){const t=[...new Set(St)].sort((s,n)=>Jt(s)-Jt(n));if(St.length=0,nt){nt.push(...t);return}for(nt=t,yt=0;yt<nt.length;yt++){const s=nt[yt];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}nt=null,yt=0}}const Jt=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Ui(e){try{for(Ue=0;Ue<me.length;Ue++){const t=me[Ue];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),Qt(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Ue<me.length;Ue++){const t=me[Ue];t&&(t.flags&=-2)}Ue=-1,me.length=0,Li(),hs=null,(me.length||St.length)&&Ui()}}let xe=null,Ni=null;function gs(e){const t=xe;return xe=e,Ni=e&&e.type.__scopeId||null,t}function co(e,t=xe,s){if(!t||e._n)return e;const n=(...i)=>{n._d&&Kn(-1);const l=gs(t);let o;try{o=e(...i)}finally{gs(l),n._d&&Kn(1)}return o};return n._n=!0,n._c=!0,n._d=!0,n}function le(e,t){if(xe===null)return e;const s=Os(xe),n=e.dirs||(e.dirs=[]);for(let i=0;i<t.length;i++){let[l,o,r,a=J]=t[i];l&&(V(l)&&(l={mounted:l,updated:l}),l.deep&&Ye(o),n.push({dir:l,instance:s,value:o,oldValue:void 0,arg:r,modifiers:a}))}return e}function dt(e,t,s,n){const i=e.dirs,l=t&&t.dirs;for(let o=0;o<i.length;o++){const r=i[o];l&&(r.oldValue=l[o].value);let a=r.dir[n];a&&(Ke(),Ae(a,s,8,[e.el,r,e,t]),We())}}function uo(e,t){if(be){let s=be.provides;const n=be.parent&&be.parent.provides;n===s&&(s=be.provides=Object.create(n)),s[e]=t}}function fs(e,t,s=!1){const n=ar();if(n||Tt){let i=Tt?Tt._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return s&&V(t)?t.call(n&&n.proxy):t}}const fo=Symbol.for("v-scx"),po=()=>fs(fo);function Ns(e,t,s){return ji(e,t,s)}function ji(e,t,s=J){const{immediate:n,deep:i,flush:l,once:o}=s,r=pe({},s),a=t&&n||!t&&l!=="post";let p;if(Xt){if(l==="sync"){const x=po();p=x.__watcherHandles||(x.__watcherHandles=[])}else if(!a){const x=()=>{};return x.stop=He,x.resume=He,x.pause=He,x}}const u=be;r.call=(x,D,S)=>Ae(x,u,D,S);let h=!1;l==="post"?r.scheduler=x=>{ve(x,u&&u.suspense)}:l!=="sync"&&(h=!0,r.scheduler=(x,D)=>{D?x():bn(x)}),r.augmentJob=x=>{t&&(x.flags|=4),h&&(x.flags|=2,u&&(x.id=u.uid,x.i=u))};const b=lo(e,t,r);return Xt&&(p?p.push(b):a&&b()),b}function ho(e,t,s){const n=this.proxy,i=te(e)?e.includes(".")?Vi(n,e):()=>n[e]:e.bind(n,n);let l;V(t)?l=t:(l=t.handler,s=t);const o=es(this),r=ji(i,l.bind(n),s);return o(),r}function Vi(e,t){const s=t.split(".");return()=>{let n=e;for(let i=0;i<s.length&&n;i++)n=n[s[i]];return n}}const go=Symbol("_vte"),mo=e=>e.__isTeleport,js=Symbol("_leaveCb");function vn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,vn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Hi(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function In(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ms=new WeakMap;function Ht(e,t,s,n,i=!1){if(U(e)){e.forEach((S,C)=>Ht(S,t&&(U(t)?t[C]:t),s,n,i));return}if(Bt(n)&&!i){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Ht(e,t,s,n.component.subTree);return}const l=n.shapeFlag&4?Os(n.component):n.el,o=i?null:l,{i:r,r:a}=e,p=t&&t.r,u=r.refs===J?r.refs={}:r.refs,h=r.setupState,b=K(h),x=h===J?ui:S=>In(u,S)?!1:W(b,S),D=(S,C)=>!(C&&In(u,C));if(p!=null&&p!==a){if(Dn(t),te(p))u[p]=null,x(p)&&(h[p]=null);else if(fe(p)){const S=t;D(p,S.k)&&(p.value=null),S.k&&(u[S.k]=null)}}if(V(a)){Ke();try{Qt(a,r,12,[o,u])}finally{We()}}else{const S=te(a),C=fe(a);if(S||C){const T=()=>{if(e.f){con Line truncated
* @vue/runtime-dom v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let sn;const zn=typeof window<"u"&&window.trustedTypes;if(zn)try{sn=zn.createPolicy("vue",{createHTML:e=>e})}catch{}const pl=sn?e=>sn.createHTML(e):e=>e,gr="http://www.w3.org/2000/svg",mr="http://www.w3.org/1998/Math/MathML",ze=typeof document<"u"?document:null,qn=ze&&ze.createElement("template"),br={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?ze.createElementNS(gr,e):t==="mathml"?ze.createElementNS(mr,e):s?ze.createElement(e,{is:s}):ze.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>ze.createTextNode(e),createComment:e=>ze.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>ze.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,l){const o=s?s.previousSibling:t.lastChild;if(i&&(i===l||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===l||!(i=i.nextSibling)););else{qn.innerHTML=pl(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const r=qn.content;if(n==="svg"||n==="mathml"){const a=r.firstChild;for(;a.firstChild;)r.appendChild(a.firstChild);r.removeChild(a)}t.insertBefore(r,s)}return[o?o.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},vr=Symbol("_vtc");function _r(e,t,s){const n=e[vr];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const Jn=Symbol("_vod"),yr=Symbol("_vsh"),xr=Symbol(""),wr=/(?:^|;)\s*display\s*:/;function Sr(e,t,s){const n=e.style,i=te(s);let l=!1;if(s&&!i){if(t)if(te(t))for(const o of t.split(";")){const r=o.slice(0,o.indexOf(":")).trim();s[r]==null&&Ut(n,r,"")}else for(const o in t)s[o]==null&&Ut(n,o,"");for(const o in s){o==="display"&&(l=!0);const r=s[o];r!=null?Cr(e,o,!te(t)&&t?t[o]:void 0,r)||Ut(n,o,r):Ut(n,o,"")}}else if(i){if(t!==s){const o=n[xr];o&&(s+=";"+o),n.cssText=s,l=wr.test(s)}}else t&&e.removeAttribute("style");Jn in e&&(e[Jn]=l?n.display:"",e[yr]&&(n.display="none"))}const Yn=/\s*!important$/;function Ut(e,t,s){if(U(s))s.forEach(n=>Ut(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Tr(e,t);Yn.test(s)?e.setProperty(rt(n),s.replace(Yn,""),"important"):e[n]=s}}const Xn=["Webkit","Moz","ms"],Ks={};function Tr(e,t){const s=Ks[t];if(s)return s;let n=Ee(t);if(n!=="filter"&&n in e)return Ks[t]=n;n=hi(n);for(let i=0;i<Xn.length;i++){const l=Xn[i]+n;if(l in e)return Ks[t]=l}return t}function Cr(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&te(n)&&s===n}const Zn="http://www.w3.org/1999/xlink";function Qn(e,t,s,n,i,l=Al(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(Zn,t.slice(6,t.length)):e.setAttributeNS(Zn,t,s):s==null||l&&!mi(s)?e.removeAttribute(t):e.setAttribute(t,l?"":Be(s)?String(s):s)}function ei(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?pl(s):s);return}const l=e.tagName;if(t==="value"&&l!=="PROGRESS"&&!l.includes("-")){const r=l==="OPTION"?e.getAttribute("value")||"":e.value,a=s==null?e.type==="checkbox"?"on":"":String(s);(r!==a||!("_value"in e))&&(e.value=a),s==null&&e.removeAttribute(t),e._value=s;return}let o=!1;if(s===""||s==null){const r=typeof e[t];r==="boolean"?s=mi(s):s==null&&r==="string"?(s="",o=!0):r==="number"&&(s=0,o=!0)}try{e[t]=s}catch{}o&&e.removeAttribute(i||t)}function Xe(e,t,s,n){e.addEventListener(t,s,n)}function Er(e,t,s,n){e.removeEventListener(t,s,n)}const ti=Symbol("_vei");function kr(e,t,s,n,i=null){const l=e[ti]||(e[ti]={}),o=l[t];if(n&&o)o.value=n;else{const[r,a]=Ar(t);if(n){const p=l[t]=Mr(n,i);Xe(e,r,p,a)}else o&&(Er(e,r,o,a),l[t]=void 0)}}const $r=/(Once|Passive|Capture)$/,Pr=/^on:?(?:Once|Passive|Capture)$/;function Ar(e){let t,s;for(;(s=e.match($r))&&!Pr.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):rt(e.slice(2)),t]}let Ws=0;const Or=Promise.resolve(),Rr=()=>Ws||(Or.then(()=>Ws=0),Ws=Date.now());function Mr(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(U(i)){const l=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{l.call(n),n._stopped=!0};const o=i.slice(),r=[n];for(let a=0;a<o.length&&!n._stopped;a++){const p=o[a];p&&Ae(p,t,5,r)}}else Ae(i,t,5,[n])};return s.value=e,s.attached=Rr(),s}const si=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ir=(e,t,s,n,i,l)=>{const o=i==="svg";t==="class"?_r(e,n,o):t==="style"?Sr(e,s,n):xs(t)?ws(t)||kr(e,t,s,n,l):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Dr(e,t,n,o))?(ei(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Qn(e,t,n,o,l,t!=="value")):e._isVueCE&&(Fr(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!te(n)))?ei(e,Ee(t),n,l,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._fals Line truncated
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-OENR1RVj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i1JZk1ZM.css">
<script type="module" crossorigin src="/assets/index-CL6eq5qV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3MeNcl7.css">
</head>
<body>
<div id="app"></div>
+45 -21
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { theme, toggleTheme } from "./theme";
import { t, lang, setLang, TRANSLATED_LANGUAGES } from "./i18n";
import { me, token, restore, logout, isManager, isSuperadmin } from "./api";
import LoginView from "./components/LoginView.vue";
import StatusCard from "./components/StatusCard.vue";
@@ -23,21 +24,35 @@ onMounted(async () => {
// plugins need a superadmin. The server enforces the same rules — this only
// hides what the caller could not use anyway.
const sections = computed(() => {
const out = [{ id: "overview", label: "Overview" }];
const out = [{ id: "overview", label: t("sections.overview") }];
if (isManager.value) {
out.push({ id: "users", label: "Users" }, { id: "orgs", label: "Organizations" });
out.push({ id: "users", label: t("sections.users") }, { id: "orgs", label: t("sections.orgs") });
}
if (isSuperadmin.value) {
out.push(
{ id: "pocketbase", label: "PocketBase" },
{ id: "webapp", label: "Web App" },
{ id: "plugins", label: "Plugins" },
{ id: "pocketbase", label: t("sections.pocketbase") },
{ id: "webapp", label: t("sections.webapp") },
{ id: "plugins", label: t("sections.plugins") },
);
}
out.push({ id: "api", label: "API" });
out.push({ id: "api", label: t("sections.api") });
return out;
});
// The language picker offers only the languages that have a real file. English
// names ("Polski", "Dansk") come from Intl so each reads in its own language.
const languageOptions = computed(() =>
TRANSLATED_LANGUAGES.map((code) => {
let label = code;
try {
label = new Intl.DisplayNames([code], { type: "language" }).of(code) || code;
} catch {
/* runtime without DisplayNames — the code is still selectable */
}
return { code, label: label.charAt(0).toUpperCase() + label.slice(1) };
}),
);
function signOut() {
logout();
section.value = "overview";
@@ -151,7 +166,7 @@ const superadminApi = [
<span class="text-strong">Driver</span><span class="text-brandtext">Vault</span>
</span>
</span>
<span class="eyebrow mt-1.5">API server</span>
<span class="eyebrow mt-1.5">{{ t("app.apiServer") }}</span>
<div class="flex-1"></div>
<span v-if="me" class="data hidden text-xs text-muted sm:inline">
@@ -159,9 +174,18 @@ const superadminApi = [
</span>
<span v-if="me" class="dh-pill bg-info-soft text-info">{{ me.role }}</span>
<select
class="dh-select !w-auto !py-1 !text-xs"
:value="lang"
:aria-label="t('sections.overview')"
@change="setLang($event.target.value)"
>
<option v-for="l in languageOptions" :key="l.code" :value="l.code">{{ l.label }}</option>
</select>
<button
class="dh-btn-ghost"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
:title="theme === 'dark' ? t('app.switchToLight') : t('app.switchToDark')"
@click="toggleTheme"
>
<svg
@@ -187,12 +211,12 @@ const superadminApi = [
>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
Theme
{{ t("app.theme") }}
</button>
<button v-if="token" class="dh-btn-ghost" @click="signOut">Sign out</button>
<button v-if="token" class="dh-btn-ghost" @click="signOut">{{ t("app.signOut") }}</button>
</div>
<p v-if="booting" class="eyebrow py-16 text-center">Loading…</p>
<p v-if="booting" class="eyebrow py-16 text-center">{{ t("app.loading") }}</p>
<!-- Unauthenticated: the login gate is the whole console. -->
<LoginView v-else-if="!token" />
@@ -219,21 +243,21 @@ const superadminApi = [
<PluginsCard v-else-if="section === 'plugins'" />
<template v-else-if="section === 'api'">
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
<EndpointTable title="Identity" auth="Bearer token" :endpoints="identityApi" />
<EndpointTable title="Cars" auth="Bearer token" :endpoints="carsApi" />
<EndpointTable title="Service records" auth="Bearer token" :endpoints="serviceApi" />
<EndpointTable title="Parts" auth="Bearer token" :endpoints="partsApi" />
<EndpointTable title="Account" auth="Bearer token" :endpoints="accountApi" />
<EndpointTable title="Management" auth="Admin / superadmin" :endpoints="managementApi" />
<EndpointTable title="Superadmin" auth="Superadmin" :endpoints="superadminApi" />
<EndpointTable :title="t('api.groupPublic')" :auth="t('api.authNone')" :endpoints="publicApi" />
<EndpointTable :title="t('api.groupIdentity')" :auth="t('api.authBearer')" :endpoints="identityApi" />
<EndpointTable :title="t('api.groupCars')" :auth="t('api.authBearer')" :endpoints="carsApi" />
<EndpointTable :title="t('api.groupService')" :auth="t('api.authBearer')" :endpoints="serviceApi" />
<EndpointTable :title="t('api.groupParts')" :auth="t('api.authBearer')" :endpoints="partsApi" />
<EndpointTable :title="t('api.groupAccount')" :auth="t('api.authBearer')" :endpoints="accountApi" />
<EndpointTable :title="t('api.groupManagement')" :auth="t('api.authManager')" :endpoints="managementApi" />
<EndpointTable :title="t('api.groupSuperadmin')" :auth="t('api.authSuperadmin')" :endpoints="superadminApi" />
</template>
<p v-if="!isManager && section === 'overview'" class="eyebrow text-center">
Signed in as a standard user — management sections need an admin role
{{ t("app.standardUserNote") }}
</p>
</template>
<p class="eyebrow text-center">DriverVault — car maintenance &amp; service tracker.</p>
<p class="eyebrow text-center">{{ t("app.footer") }}</p>
</div>
</template>
@@ -1,4 +1,6 @@
<script setup>
import { t } from "../i18n";
defineProps({
title: String,
auth: String,
@@ -22,8 +24,8 @@ const methodClass = {
<table class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Endpoint</th>
<th>Description</th>
<th>{{ t("api.colEndpoint") }}</th>
<th>{{ t("api.colDescription") }}</th>
</tr>
</thead>
<tbody>
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed } from "vue";
import { login } from "../api";
import { t } from "../i18n";
const emit = defineEmits(["authenticated"]);
@@ -24,8 +25,8 @@ async function submit() {
// 400/404 from PocketBase both mean "bad credentials" — don't leak which.
error.value =
e.status === 400 || e.status === 404
? "Invalid email or password."
: e.message || "Could not sign in.";
? t("login.invalid")
: e.message || t("login.failed");
password.value = "";
} finally {
busy.value = false;
@@ -36,14 +37,14 @@ async function submit() {
<template>
<div class="mx-auto flex w-full max-w-sm flex-col gap-5 pt-24">
<div class="dh-card p-6">
<h1 class="text-lg font-bold tracking-[-0.02em] text-strong">Sign in</h1>
<h1 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("login.title") }}</h1>
<p class="mt-1 mb-5 text-sm text-body">
Superadmin console for the DriverVault API Server.
{{ t("login.subtitle") }}
</p>
<form class="flex flex-col gap-4" @submit.prevent="submit">
<div>
<label class="dh-label" for="login-email">Email</label>
<label class="dh-label" for="login-email">{{ t("login.email") }}</label>
<input
id="login-email"
v-model="email"
@@ -55,7 +56,7 @@ async function submit() {
/>
</div>
<div>
<label class="dh-label" for="login-password">Password</label>
<label class="dh-label" for="login-password">{{ t("login.password") }}</label>
<input
id="login-password"
v-model="password"
@@ -71,13 +72,13 @@ async function submit() {
</p>
<button class="dh-btn w-full" type="submit" :disabled="!canSubmit">
{{ busy ? "Signing in…" : "Sign in" }}
{{ busy ? t("login.submitting") : t("login.submit") }}
</button>
</form>
</div>
<p class="eyebrow text-center">
Authenticates against PocketBase through this server
{{ t("login.authNote") }}
</p>
</div>
</template>
+14 -13
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { isSuperadmin, request } from "../api";
import { t } from "../i18n";
// Listing is manager-scoped (an admin sees only their own org); creating,
// renaming and deleting are superadmin-only, matching the server's gates.
@@ -58,7 +59,7 @@ async function save() {
}
async function remove(o) {
if (!confirm(`Delete the organization "${o.name}"?`)) return;
if (!confirm(t("orgs.confirmDelete", { name: o.name }))) return;
busy.value = true;
error.value = "";
try {
@@ -77,34 +78,34 @@ async function remove(o) {
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">Organizations</div>
<p class="mt-0.5 text-xs text-muted">Tenants users belong to</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("orgs.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("orgs.subtitle") }}</p>
</div>
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">New organization</button>
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">{{ t("orgs.newOrg") }}</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
<label class="dh-label">Name</label>
<input v-model="draftName" class="dh-input" placeholder="Acme Fleet" @keyup.enter="save" />
<label class="dh-label">{{ t("common.name") }}</label>
<input v-model="draftName" class="dh-input" :placeholder="t('orgs.namePlaceholder')" @keyup.enter="save" />
<div class="mt-3 flex items-center gap-2">
<button class="dh-btn" :disabled="busy || !draftName.trim()" @click="save">
{{ editing === "new" ? "Create" : "Save" }}
{{ editing === "new" ? t("common.create") : t("common.save") }}
</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
</div>
</div>
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
No organizations yet.
{{ t("orgs.empty") }}
</p>
<table v-else class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Name</th>
<th>ID</th>
<th>{{ t("common.name") }}</th>
<th>{{ t("orgs.colId") }}</th>
<th></th>
</tr>
</thead>
@@ -114,9 +115,9 @@ async function remove(o) {
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
<td class="px-5 py-2.5 text-right whitespace-nowrap">
<template v-if="isSuperadmin">
<button class="dh-btn-ghost" @click="startEdit(o)">Rename</button>
<button class="dh-btn-ghost" @click="startEdit(o)">{{ t("common.rename") }}</button>
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
Delete
{{ t("common.delete") }}
</button>
</template>
</td>
+22 -21
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, reactive } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// Superadmin-only. Each plugin advertises its own config fields (Descriptor.
// ConfigFields), so the form below is generated rather than hard-coded — that's
@@ -47,7 +48,7 @@ async function save(p, enabled) {
});
// A save can succeed while Init fails (e.g. bad credentials) — the server
// returns the saved plugin plus a warning.
rowNotice[p.name] = out.warning || "Saved.";
rowNotice[p.name] = out.warning || t("plugins.saved");
await load();
} catch (e) {
rowNotice[p.name] = e.message;
@@ -58,7 +59,7 @@ async function save(p, enabled) {
async function health(p) {
busy.value = true;
rowNotice[p.name] = "Checking…";
rowNotice[p.name] = t("plugins.checking");
try {
const out = await request(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
method: "POST",
@@ -73,7 +74,7 @@ async function health(p) {
}
async function remove(p) {
if (!confirm(`Remove the external plugin "${p.name}"? Its saved config is deleted.`)) return;
if (!confirm(t("plugins.confirmRemove", { name: p.name }))) return;
busy.value = true;
try {
await request(`/api/admin/plugins/${encodeURIComponent(p.name)}`, { method: "DELETE" });
@@ -113,11 +114,11 @@ const healthClass = (s) =>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">Plugins</div>
<p class="mt-0.5 text-xs text-muted">Third-party service integrations</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("plugins.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("plugins.subtitle") }}</p>
</div>
<button class="dh-btn-ghost" @click="showRegister = !showRegister">
{{ showRegister ? "Cancel" : "Register external" }}
{{ showRegister ? t("plugins.cancelRegister") : t("plugins.registerExternal") }}
</button>
</div>
@@ -125,15 +126,15 @@ const healthClass = (s) =>
<div v-if="showRegister" class="border-b border-subtle bg-sunken px-5 py-4">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label class="dh-label">Name</label>
<label class="dh-label">{{ t("plugins.name") }}</label>
<input v-model="reg.name" class="dh-input" placeholder="acme-parts" />
</div>
<div>
<label class="dh-label">Base URL</label>
<label class="dh-label">{{ t("plugins.baseUrl") }}</label>
<input v-model="reg.baseURL" class="dh-input" placeholder="http://127.0.0.1:9100" />
</div>
<div>
<label class="dh-label">Provider</label>
<label class="dh-label">{{ t("plugins.provider") }}</label>
<input v-model="reg.provider" class="dh-input" placeholder="ACME Corp" />
</div>
</div>
@@ -142,14 +143,14 @@ const healthClass = (s) =>
:disabled="busy || !reg.name || !reg.baseURL"
@click="registerExternal"
>
Register
{{ t("plugins.register") }}
</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
<p v-if="!plugins.length" class="px-5 py-6 text-center text-sm text-muted">
No plugins yet. Register an external one above, or compile a built-in connector.
{{ t("plugins.empty") }}
</p>
<div v-for="p in plugins" :key="p.name" class="border-t border-subtle first:border-t-0">
@@ -157,22 +158,22 @@ const healthClass = (s) =>
<div class="flex items-center gap-3 px-5 py-3">
<button class="flex flex-1 items-center gap-3 text-left" @click="expand(p)">
<span class="font-semibold text-strong">{{ p.name }}</span>
<span class="dh-pill bg-sunken text-muted">{{ p.kind || "builtin" }}</span>
<span class="dh-pill bg-sunken text-muted">{{ p.kind || t("plugins.builtin") }}</span>
<span v-if="p.provider" class="text-xs text-muted">{{ p.provider }}</span>
<span v-if="p.health" class="dh-pill" :class="healthClass(p.health.status)">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ p.health.status }}
</span>
</button>
<span class="dh-pill" :class="p.enabled ? 'bg-success-soft text-success' : 'bg-sunken text-muted'">
{{ p.enabled ? "enabled" : "disabled" }}
{{ p.enabled ? t("plugins.enabled") : t("plugins.disabled") }}
</span>
<button class="dh-btn-ghost" :disabled="busy" @click="health(p)">Health</button>
<button class="dh-btn-ghost" :disabled="busy" @click="health(p)">{{ t("plugins.health") }}</button>
<button
class="dh-btn-ghost"
:disabled="busy"
@click="expand(p)"
>
{{ open === p.name ? "Close" : "Configure" }}
{{ open === p.name ? t("plugins.close") : t("plugins.configure") }}
</button>
</div>
@@ -201,10 +202,10 @@ const healthClass = (s) =>
<p v-if="f.help" class="mt-1 text-xs text-muted">{{ f.help }}</p>
</div>
</div>
<p v-else class="text-xs text-muted">This plugin takes no configuration.</p>
<p v-else class="text-xs text-muted">{{ t("plugins.noConfig") }}</p>
<div v-if="(p.capabilities || []).length" class="mt-4">
<div class="eyebrow mb-1.5">Capabilities</div>
<div class="eyebrow mb-1.5">{{ t("plugins.capabilities") }}</div>
<ul class="data flex flex-col gap-1 text-xs text-muted">
<li v-for="c in p.capabilities" :key="c.id">
<span class="text-strong">{{ c.id }}</span>
@@ -218,10 +219,10 @@ const healthClass = (s) =>
<div class="mt-4 flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save(p, true)">
{{ p.enabled ? "Save" : "Save &amp; enable" }}
{{ p.enabled ? t("plugins.save") : t("plugins.saveEnable") }}
</button>
<button v-if="p.enabled" class="dh-btn-ghost" :disabled="busy" @click="save(p, false)">
Disable
{{ t("plugins.disable") }}
</button>
<span class="flex-1"></span>
<button
@@ -230,11 +231,11 @@ const healthClass = (s) =>
:disabled="busy"
@click="remove(p)"
>
Remove
{{ t("plugins.remove") }}
</button>
</div>
<p v-if="p.kind !== 'external'" class="eyebrow mt-2">
Built-in plugins can be disabled but not removed
{{ t("plugins.builtinNote") }}
</p>
</div>
</div>
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// Superadmin-only: retarget the PocketBase this server talks to. The change is
// applied at runtime AND persisted to the server's .env, so it survives a
@@ -37,10 +38,10 @@ async function test() {
body: form.value,
});
notice.value = probe.value.superuser
? "Connection OK — superuser authenticated."
? t("pocketbase.testOk")
: probe.value.reachable
? "PocketBase is reachable, but the service account did not authenticate."
: "PocketBase is not reachable at that address.";
? t("pocketbase.testReachableNoAuth")
: t("pocketbase.testUnreachable");
} catch (e) {
error.value = e.message;
} finally {
@@ -57,7 +58,7 @@ async function save() {
cfg.value = out.config;
probe.value = out.config.probe;
form.value.adminPassword = "";
notice.value = out.warning || "Saved. The server is now using this PocketBase.";
notice.value = out.warning || t("pocketbase.savedNotice");
} catch (e) {
error.value = e.message;
} finally {
@@ -70,8 +71,8 @@ async function save() {
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">PocketBase</div>
<p class="mt-0.5 text-xs text-muted">Database connection used by every endpoint</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("pocketbase.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("pocketbase.subtitle") }}</p>
</div>
<span
v-if="probe"
@@ -83,30 +84,30 @@ async function save() {
: 'bg-danger-soft text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ probe.superuser ? "connected" : probe.reachable ? "no superuser" : "unreachable" }}
{{ probe.superuser ? t("pocketbase.connected") : probe.reachable ? t("pocketbase.noSuperuser") : t("pocketbase.unreachable") }}
</span>
</div>
<div class="flex flex-col gap-4 px-5 py-4">
<div>
<label class="dh-label" for="pb-url">Base URL</label>
<label class="dh-label" for="pb-url">{{ t("pocketbase.baseUrl") }}</label>
<input id="pb-url" v-model="form.url" class="dh-input" placeholder="http://10.2.1.10:8027" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="dh-label" for="pb-email">Superuser email</label>
<label class="dh-label" for="pb-email">{{ t("pocketbase.superuserEmail") }}</label>
<input id="pb-email" v-model="form.adminEmail" class="dh-input" autocomplete="off" />
</div>
<div>
<label class="dh-label" for="pb-password">Superuser password</label>
<label class="dh-label" for="pb-password">{{ t("pocketbase.superuserPassword") }}</label>
<input
id="pb-password"
v-model="form.adminPassword"
class="dh-input"
type="password"
autocomplete="new-password"
:placeholder="cfg?.adminConfigured ? 'unchanged' : 'not set'"
:placeholder="cfg?.adminConfigured ? t('pocketbase.passwordUnchanged') : t('pocketbase.passwordNotSet')"
/>
</div>
</div>
@@ -116,10 +117,10 @@ async function save() {
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-xs text-danger">{{ error }}</p>
<div class="flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save">Save &amp; apply</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">Test connection</button>
<button class="dh-btn" :disabled="busy" @click="save">{{ t("pocketbase.saveApply") }}</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">{{ t("pocketbase.testConnection") }}</button>
<span class="flex-1"></span>
<span class="eyebrow">persisted to .env</span>
<span class="eyebrow">{{ t("pocketbase.persistedEnv") }}</span>
</div>
</div>
</div>
+16 -12
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { ref, onMounted, onUnmounted, computed } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// /api/status probes PocketBase and the Web App server-side, so the browser
// never has to reach either directly.
@@ -14,10 +15,17 @@ async function check() {
error.value = "";
} catch (e) {
status.value = null;
error.value = e.message || "unreachable";
error.value = e.message || t("status.unreachable");
}
}
// Computed so the row labels re-evaluate when the language changes.
const rows = computed(() => [
{ key: "apiServer", label: t("status.apiServer"), h: status.value?.apiServer },
{ key: "pocketBase", label: t("status.pocketBase"), h: status.value?.pocketBase },
{ key: "webApp", label: t("status.webApp"), h: status.value?.webApp },
]);
onMounted(() => {
check();
timer = setInterval(check, 10000);
@@ -31,9 +39,9 @@ const pillFor = (s) =>
<template>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("status.title") }}</div>
<span v-if="error" class="dh-pill bg-danger-soft text-danger">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>unreachable
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ t("status.unreachable") }}
</span>
</div>
@@ -41,15 +49,11 @@ const pillFor = (s) =>
<table v-else-if="status" class="w-full text-left text-sm">
<tbody>
<tr v-for="row in [
{ key: 'apiServer', label: 'API Server', h: status.apiServer },
{ key: 'pocketBase', label: 'PocketBase', h: status.pocketBase },
{ key: 'webApp', label: 'Web App', h: status.webApp },
]" :key="row.key" class="border-t border-subtle first:border-t-0">
<tr v-for="row in rows" :key="row.key" class="border-t border-subtle first:border-t-0">
<td class="px-5 py-3 font-medium text-strong">{{ row.label }}</td>
<td class="data px-5 py-3 text-xs text-muted">{{ row.h.url || "this process" }}</td>
<td class="data px-5 py-3 text-xs text-muted">{{ row.h.url || t("status.thisProcess") }}</td>
<td class="data px-5 py-3 text-right text-xs text-muted">
{{ row.h.latencyMs != null ? row.h.latencyMs + "ms" : "—" }}
{{ row.h.latencyMs != null ? row.h.latencyMs + "ms" : t("common.empty") }}
</td>
<td class="px-5 py-3 text-right">
<span class="dh-pill" :class="pillFor(row.h.status)">
@@ -60,6 +64,6 @@ const pillFor = (s) =>
</tbody>
</table>
<div v-else class="px-5 py-4 text-sm text-muted">Checking…</div>
<div v-else class="px-5 py-4 text-sm text-muted">{{ t("status.checking") }}</div>
</div>
</template>
+24 -23
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, computed } from "vue";
import { request, me, isSuperadmin } from "../api";
import { t } from "../i18n";
// Manager-only. A superadmin sees and edits everyone; an admin is scoped by the
// server to their own organization. The UI mirrors those limits, but the server
@@ -77,7 +78,7 @@ async function save() {
}
async function remove(u) {
if (!confirm(`Delete ${u.email}? This cannot be undone.`)) return;
if (!confirm(t("users.confirmDelete", { email: u.email }))) return;
busy.value = true;
error.value = "";
try {
@@ -102,12 +103,12 @@ const roleClass = (r) =>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">Users</div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("users.title") }}</div>
<p class="mt-0.5 text-xs text-muted">
{{ isSuperadmin ? "All organizations" : "Your organization" }}
{{ isSuperadmin ? t("users.allOrgs") : t("users.yourOrg") }}
</p>
</div>
<button class="dh-btn" @click="startNew">New user</button>
<button class="dh-btn" @click="startNew">{{ t("users.newUser") }}</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
@@ -116,56 +117,56 @@ const roleClass = (r) =>
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="dh-label">Email</label>
<label class="dh-label">{{ t("common.email") }}</label>
<input v-model="draft.email" class="dh-input" type="email" autocomplete="off" />
</div>
<div>
<label class="dh-label">Name</label>
<label class="dh-label">{{ t("common.name") }}</label>
<input v-model="draft.name" class="dh-input" autocomplete="off" />
</div>
<div>
<label class="dh-label">
Password{{ editing === "new" ? "" : " (blank = unchanged)" }}
{{ editing === "new" ? t("users.password") : t("users.passwordUnchanged") }}
</label>
<input
v-model="draft.password"
class="dh-input"
type="password"
autocomplete="new-password"
placeholder="min 8 characters"
:placeholder="t('users.passwordPlaceholder')"
/>
</div>
<div>
<label class="dh-label">Role</label>
<label class="dh-label">{{ t("common.role") }}</label>
<select v-model="draft.role" class="dh-select">
<option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
</select>
</div>
<div v-if="isSuperadmin">
<label class="dh-label">Organization</label>
<label class="dh-label">{{ t("common.organization") }}</label>
<select v-model="draft.organization" class="dh-select">
<option value="">— none —</option>
<option value="">{{ t("users.orgNone") }}</option>
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save">
{{ editing === "new" ? "Create" : "Save" }}
{{ editing === "new" ? t("common.create") : t("common.save") }}
</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
</div>
</div>
<p v-if="!users.length" class="px-5 py-6 text-center text-sm text-muted">No users.</p>
<p v-if="!users.length" class="px-5 py-6 text-center text-sm text-muted">{{ t("users.empty") }}</p>
<table v-else class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Email</th>
<th>Name</th>
<th>Organization</th>
<th>Role</th>
<th>{{ t("common.email") }}</th>
<th>{{ t("common.name") }}</th>
<th>{{ t("common.organization") }}</th>
<th>{{ t("common.role") }}</th>
<th></th>
</tr>
</thead>
@@ -173,22 +174,22 @@ const roleClass = (r) =>
<tr v-for="u in users" :key="u.id" class="border-t border-subtle transition-colors hover:bg-sunken">
<td class="data px-5 py-2.5 text-xs text-strong">
{{ u.email }}
<span v-if="u.id === me?.id" class="eyebrow ml-1">you</span>
<span v-if="u.id === me?.id" class="eyebrow ml-1">{{ t("users.you") }}</span>
</td>
<td class="px-5 py-2.5 text-body">{{ u.name || "—" }}</td>
<td class="px-5 py-2.5 text-body">{{ u.organizationName || "—" }}</td>
<td class="px-5 py-2.5 text-body">{{ u.name || t("common.empty") }}</td>
<td class="px-5 py-2.5 text-body">{{ u.organizationName || t("common.empty") }}</td>
<td class="px-5 py-2.5">
<span class="dh-pill" :class="roleClass(u.role)">{{ u.role }}</span>
</td>
<td class="px-5 py-2.5 text-right whitespace-nowrap">
<button class="dh-btn-ghost" @click="startEdit(u)">Edit</button>
<button class="dh-btn-ghost" @click="startEdit(u)">{{ t("common.edit") }}</button>
<button
v-if="u.id !== me?.id"
class="dh-btn-danger ml-1.5"
:disabled="busy"
@click="remove(u)"
>
Delete
{{ t("common.delete") }}
</button>
</td>
</tr>
+14 -12
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { request } from "../api";
import { t, tSplit } from "../i18n";
// Superadmin-only: where the Web App lives (the address /api/status probes) and
// which browser origins CORS admits. Like the PocketBase card, the change is
@@ -42,8 +43,8 @@ async function test() {
});
notice.value =
probe.value.status === "ok"
? "Web App is reachable."
: "Web App did not answer its health check at that address.";
? t("webapp.testOk")
: t("webapp.testFailed");
} catch (e) {
error.value = e.message;
} finally {
@@ -61,7 +62,7 @@ async function save() {
body: { url: form.value.url, allowOrigins: splitOrigins(form.value.origins) },
});
apply(out.config);
notice.value = out.warning || "Saved. New origins apply to the next request.";
notice.value = out.warning || t("webapp.savedNotice");
} catch (e) {
error.value = e.message;
} finally {
@@ -74,8 +75,8 @@ async function save() {
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">Web App</div>
<p class="mt-0.5 text-xs text-muted">Address probed by the status check, and who may call this API from a browser</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("webapp.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("webapp.subtitle") }}</p>
</div>
<span
v-if="probe"
@@ -83,18 +84,18 @@ async function save() {
:class="probe.status === 'ok' ? 'bg-success-soft text-success' : 'bg-danger-soft text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ probe.status === "ok" ? "reachable" : "unreachable" }}
{{ probe.status === "ok" ? t("webapp.reachable") : t("webapp.unreachable") }}
</span>
</div>
<div class="flex flex-col gap-4 px-5 py-4">
<div>
<label class="dh-label" for="web-url">Base URL</label>
<label class="dh-label" for="web-url">{{ t("webapp.baseUrl") }}</label>
<input id="web-url" v-model="form.url" class="dh-input" placeholder="http://localhost:5173" />
</div>
<div>
<label class="dh-label" for="web-origins">Allowed origins</label>
<label class="dh-label" for="web-origins">{{ t("webapp.allowedOrigins") }}</label>
<input
id="web-origins"
v-model="form.origins"
@@ -102,7 +103,8 @@ async function save() {
placeholder="http://localhost:5173, https://app.example.com"
/>
<p class="mt-1 text-xs text-muted">
Comma separated, or <span class="data">*</span> for any. Native mobile apps are not subject to CORS.
{{ tSplit("webapp.originsHint", "star").before
}}<span class="data">*</span>{{ tSplit("webapp.originsHint", "star").after }}
</p>
</div>
@@ -111,10 +113,10 @@ async function save() {
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-xs text-danger">{{ error }}</p>
<div class="flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save">Save &amp; apply</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">Test connection</button>
<button class="dh-btn" :disabled="busy" @click="save">{{ t("webapp.saveApply") }}</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">{{ t("webapp.testConnection") }}</button>
<span class="flex-1"></span>
<span class="eyebrow">persisted to .env</span>
<span class="eyebrow">{{ t("webapp.persistedEnv") }}</span>
</div>
</div>
</div>
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "API-server",
"switchToLight": "Skift til lyst tema",
"switchToDark": "Skift til mørkt tema",
"theme": "Tema",
"signOut": "Log ud",
"loading": "Indlæser…",
"standardUserNote": "Logget ind som almindelig bruger — administrationssektioner kræver en administratorrolle",
"footer": "DriverVault — bilservice- og vedligeholdelsesregister."
},
"sections": {
"overview": "Oversigt",
"users": "Brugere",
"orgs": "Organisationer",
"pocketbase": "PocketBase",
"webapp": "Webapp",
"plugins": "Plugins",
"api": "API"
},
"login": {
"title": "Log ind",
"subtitle": "Superadmin-konsol til DriverVault API-serveren.",
"email": "E-mail",
"password": "Adgangskode",
"submit": "Log ind",
"submitting": "Logger ind…",
"invalid": "Ugyldig e-mail eller adgangskode.",
"failed": "Kunne ikke logge ind.",
"authNote": "Godkender mod PocketBase gennem denne server"
},
"status": {
"title": "Status",
"unreachable": "utilgængelig",
"checking": "Tjekker…",
"apiServer": "API-server",
"pocketBase": "PocketBase",
"webApp": "Webapp",
"thisProcess": "denne proces"
},
"common": {
"create": "Opret",
"save": "Gem",
"cancel": "Annuller",
"edit": "Rediger",
"delete": "Slet",
"rename": "Omdøb",
"empty": "—",
"name": "Navn",
"email": "E-mail",
"role": "Rolle",
"organization": "Organisation"
},
"users": {
"title": "Brugere",
"allOrgs": "Alle organisationer",
"yourOrg": "Din organisation",
"newUser": "Ny bruger",
"password": "Adgangskode",
"passwordUnchanged": "Adgangskode (tom = uændret)",
"passwordPlaceholder": "mindst 8 tegn",
"orgNone": "— ingen —",
"you": "dig",
"empty": "Ingen brugere.",
"confirmDelete": "Slet {email}? Dette kan ikke fortrydes."
},
"orgs": {
"title": "Organisationer",
"subtitle": "Enheder, som brugere tilhører",
"newOrg": "Ny organisation",
"namePlaceholder": "Acme Fleet",
"empty": "Ingen organisationer endnu.",
"colId": "ID",
"confirmDelete": "Slet organisationen \"{name}\"?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Databaseforbindelse brugt af hvert endpoint",
"connected": "forbundet",
"noSuperuser": "ingen superbruger",
"unreachable": "utilgængelig",
"baseUrl": "Basis-URL",
"superuserEmail": "Superbrugerens e-mail",
"superuserPassword": "Superbrugerens adgangskode",
"passwordUnchanged": "uændret",
"passwordNotSet": "ikke angivet",
"saveApply": "Gem og anvend",
"testConnection": "Test forbindelse",
"persistedEnv": "gemt i .env",
"testOk": "Forbindelse OK — superbruger godkendt.",
"testReachableNoAuth": "PocketBase er tilgængelig, men servicekontoen blev ikke godkendt.",
"testUnreachable": "PocketBase er ikke tilgængelig på den adresse.",
"savedNotice": "Gemt. Serveren bruger nu denne PocketBase."
},
"webapp": {
"title": "Webapp",
"subtitle": "Adressen, der tjekkes ved statuskontrol, og hvem der må kalde dette API fra en browser",
"reachable": "tilgængelig",
"unreachable": "utilgængelig",
"baseUrl": "Basis-URL",
"allowedOrigins": "Tilladte oprindelser",
"originsHint": "Kommasepareret, eller {star} for enhver. Native mobilapps er ikke underlagt CORS.",
"saveApply": "Gem og anvend",
"testConnection": "Test forbindelse",
"persistedEnv": "gemt i .env",
"testOk": "Webappen er tilgængelig.",
"testFailed": "Webappen svarede ikke på sit helbredstjek på den adresse.",
"savedNotice": "Gemt. Nye oprindelser gælder fra næste anmodning."
},
"plugins": {
"title": "Plugins",
"subtitle": "Integrationer med tredjepartstjenester",
"registerExternal": "Registrér ekstern",
"cancelRegister": "Annuller",
"name": "Navn",
"baseUrl": "Basis-URL",
"provider": "Udbyder",
"register": "Registrér",
"empty": "Ingen plugins endnu. Registrér et eksternt ovenfor, eller kompilér et indbygget stik.",
"builtin": "indbygget",
"enabled": "aktiveret",
"disabled": "deaktiveret",
"health": "Helbred",
"configure": "Konfigurer",
"close": "Luk",
"noConfig": "Dette plugin kræver ingen konfiguration.",
"capabilities": "Funktioner",
"saved": "Gemt.",
"checking": "Tjekker…",
"save": "Gem",
"saveEnable": "Gem og aktivér",
"disable": "Deaktiver",
"remove": "Fjern",
"builtinNote": "Indbyggede plugins kan deaktiveres, men ikke fjernes",
"confirmRemove": "Fjern det eksterne plugin \"{name}\"? Dets gemte konfiguration slettes."
},
"api": {
"colEndpoint": "Endpoint",
"colDescription": "Beskrivelse",
"authNone": "Ingen godkendelse",
"authBearer": "Bearer-token",
"authManager": "Admin / superadmin",
"authSuperadmin": "Superadmin",
"groupPublic": "Offentlig",
"groupIdentity": "Identitet",
"groupCars": "Biler",
"groupService": "Serviceposter",
"groupParts": "Reservedele",
"groupAccount": "Konto",
"groupManagement": "Administration",
"groupSuperadmin": "Superadmin"
}
}
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "API server",
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme",
"theme": "Theme",
"signOut": "Sign out",
"loading": "Loading…",
"standardUserNote": "Signed in as a standard user — management sections need an admin role",
"footer": "DriverVault — car maintenance & service tracker."
},
"sections": {
"overview": "Overview",
"users": "Users",
"orgs": "Organizations",
"pocketbase": "PocketBase",
"webapp": "Web App",
"plugins": "Plugins",
"api": "API"
},
"login": {
"title": "Sign in",
"subtitle": "Superadmin console for the DriverVault API Server.",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in…",
"invalid": "Invalid email or password.",
"failed": "Could not sign in.",
"authNote": "Authenticates against PocketBase through this server"
},
"status": {
"title": "Status",
"unreachable": "unreachable",
"checking": "Checking…",
"apiServer": "API Server",
"pocketBase": "PocketBase",
"webApp": "Web App",
"thisProcess": "this process"
},
"common": {
"create": "Create",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"rename": "Rename",
"empty": "—",
"name": "Name",
"email": "Email",
"role": "Role",
"organization": "Organization"
},
"users": {
"title": "Users",
"allOrgs": "All organizations",
"yourOrg": "Your organization",
"newUser": "New user",
"password": "Password",
"passwordUnchanged": "Password (blank = unchanged)",
"passwordPlaceholder": "min 8 characters",
"orgNone": "— none —",
"you": "you",
"empty": "No users.",
"confirmDelete": "Delete {email}? This cannot be undone."
},
"orgs": {
"title": "Organizations",
"subtitle": "Tenants users belong to",
"newOrg": "New organization",
"namePlaceholder": "Acme Fleet",
"empty": "No organizations yet.",
"colId": "ID",
"confirmDelete": "Delete the organization \"{name}\"?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Database connection used by every endpoint",
"connected": "connected",
"noSuperuser": "no superuser",
"unreachable": "unreachable",
"baseUrl": "Base URL",
"superuserEmail": "Superuser email",
"superuserPassword": "Superuser password",
"passwordUnchanged": "unchanged",
"passwordNotSet": "not set",
"saveApply": "Save & apply",
"testConnection": "Test connection",
"persistedEnv": "persisted to .env",
"testOk": "Connection OK — superuser authenticated.",
"testReachableNoAuth": "PocketBase is reachable, but the service account did not authenticate.",
"testUnreachable": "PocketBase is not reachable at that address.",
"savedNotice": "Saved. The server is now using this PocketBase."
},
"webapp": {
"title": "Web App",
"subtitle": "Address probed by the status check, and who may call this API from a browser",
"reachable": "reachable",
"unreachable": "unreachable",
"baseUrl": "Base URL",
"allowedOrigins": "Allowed origins",
"originsHint": "Comma separated, or {star} for any. Native mobile apps are not subject to CORS.",
"saveApply": "Save & apply",
"testConnection": "Test connection",
"persistedEnv": "persisted to .env",
"testOk": "Web App is reachable.",
"testFailed": "Web App did not answer its health check at that address.",
"savedNotice": "Saved. New origins apply to the next request."
},
"plugins": {
"title": "Plugins",
"subtitle": "Third-party service integrations",
"registerExternal": "Register external",
"cancelRegister": "Cancel",
"name": "Name",
"baseUrl": "Base URL",
"provider": "Provider",
"register": "Register",
"empty": "No plugins yet. Register an external one above, or compile a built-in connector.",
"builtin": "builtin",
"enabled": "enabled",
"disabled": "disabled",
"health": "Health",
"configure": "Configure",
"close": "Close",
"noConfig": "This plugin takes no configuration.",
"capabilities": "Capabilities",
"saved": "Saved.",
"checking": "Checking…",
"save": "Save",
"saveEnable": "Save & enable",
"disable": "Disable",
"remove": "Remove",
"builtinNote": "Built-in plugins can be disabled but not removed",
"confirmRemove": "Remove the external plugin \"{name}\"? Its saved config is deleted."
},
"api": {
"colEndpoint": "Endpoint",
"colDescription": "Description",
"authNone": "No auth",
"authBearer": "Bearer token",
"authManager": "Admin / superadmin",
"authSuperadmin": "Superadmin",
"groupPublic": "Public",
"groupIdentity": "Identity",
"groupCars": "Cars",
"groupService": "Service records",
"groupParts": "Parts",
"groupAccount": "Account",
"groupManagement": "Management",
"groupSuperadmin": "Superadmin"
}
}
Binary file not shown.
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "Serwer API",
"switchToLight": "Przełącz na motyw jasny",
"switchToDark": "Przełącz na motyw ciemny",
"theme": "Motyw",
"signOut": "Wyloguj się",
"loading": "Ładowanie…",
"standardUserNote": "Zalogowano jako zwykły użytkownik — sekcje zarządzania wymagają roli administratora",
"footer": "DriverVault — rejestr serwisu i konserwacji samochodu."
},
"sections": {
"overview": "Przegląd",
"users": "Użytkownicy",
"orgs": "Organizacje",
"pocketbase": "PocketBase",
"webapp": "Aplikacja webowa",
"plugins": "Wtyczki",
"api": "API"
},
"login": {
"title": "Zaloguj się",
"subtitle": "Konsola superadministratora serwera API DriverVault.",
"email": "E-mail",
"password": "Hasło",
"submit": "Zaloguj się",
"submitting": "Logowanie…",
"invalid": "Nieprawidłowy e-mail lub hasło.",
"failed": "Nie udało się zalogować.",
"authNote": "Uwierzytelnia w PocketBase za pośrednictwem tego serwera"
},
"status": {
"title": "Status",
"unreachable": "niedostępny",
"checking": "Sprawdzanie…",
"apiServer": "Serwer API",
"pocketBase": "PocketBase",
"webApp": "Aplikacja webowa",
"thisProcess": "ten proces"
},
"common": {
"create": "Utwórz",
"save": "Zapisz",
"cancel": "Anuluj",
"edit": "Edytuj",
"delete": "Usuń",
"rename": "Zmień nazwę",
"empty": "—",
"name": "Nazwa",
"email": "E-mail",
"role": "Rola",
"organization": "Organizacja"
},
"users": {
"title": "Użytkownicy",
"allOrgs": "Wszystkie organizacje",
"yourOrg": "Twoja organizacja",
"newUser": "Nowy użytkownik",
"password": "Hasło",
"passwordUnchanged": "Hasło (puste = bez zmian)",
"passwordPlaceholder": "min. 8 znaków",
"orgNone": "— brak —",
"you": "Ty",
"empty": "Brak użytkowników.",
"confirmDelete": "Usunąć {email}? Tej operacji nie można cofnąć."
},
"orgs": {
"title": "Organizacje",
"subtitle": "Podmioty, do których należą użytkownicy",
"newOrg": "Nowa organizacja",
"namePlaceholder": "Acme Fleet",
"empty": "Brak organizacji.",
"colId": "ID",
"confirmDelete": "Usunąć organizację „{name}”?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Połączenie z bazą danych używane przez każdy punkt końcowy",
"connected": "połączono",
"noSuperuser": "brak superużytkownika",
"unreachable": "niedostępny",
"baseUrl": "Adres bazowy",
"superuserEmail": "E-mail superużytkownika",
"superuserPassword": "Hasło superużytkownika",
"passwordUnchanged": "bez zmian",
"passwordNotSet": "nie ustawiono",
"saveApply": "Zapisz i zastosuj",
"testConnection": "Testuj połączenie",
"persistedEnv": "zapisano w .env",
"testOk": "Połączenie OK — superużytkownik uwierzytelniony.",
"testReachableNoAuth": "PocketBase jest dostępny, ale konto usługowe nie zostało uwierzytelnione.",
"testUnreachable": "PocketBase jest niedostępny pod tym adresem.",
"savedNotice": "Zapisano. Serwer korzysta teraz z tego PocketBase."
},
"webapp": {
"title": "Aplikacja webowa",
"subtitle": "Adres sprawdzany podczas kontroli statusu oraz kto może wywoływać to API z przeglądarki",
"reachable": "dostępna",
"unreachable": "niedostępna",
"baseUrl": "Adres bazowy",
"allowedOrigins": "Dozwolone źródła",
"originsHint": "Oddzielone przecinkami lub {star} dla dowolnego. Natywne aplikacje mobilne nie podlegają CORS.",
"saveApply": "Zapisz i zastosuj",
"testConnection": "Testuj połączenie",
"persistedEnv": "zapisano w .env",
"testOk": "Aplikacja webowa jest dostępna.",
"testFailed": "Aplikacja webowa nie odpowiedziała na kontrolę stanu pod tym adresem.",
"savedNotice": "Zapisano. Nowe źródła obowiązują od następnego żądania."
},
"plugins": {
"title": "Wtyczki",
"subtitle": "Integracje z usługami zewnętrznymi",
"registerExternal": "Zarejestruj zewnętrzną",
"cancelRegister": "Anuluj",
"name": "Nazwa",
"baseUrl": "Adres bazowy",
"provider": "Dostawca",
"register": "Zarejestruj",
"empty": "Brak wtyczek. Zarejestruj zewnętrzną powyżej lub skompiluj wbudowany łącznik.",
"builtin": "wbudowana",
"enabled": "włączona",
"disabled": "wyłączona",
"health": "Stan",
"configure": "Konfiguruj",
"close": "Zamknij",
"noConfig": "Ta wtyczka nie wymaga konfiguracji.",
"capabilities": "Możliwości",
"saved": "Zapisano.",
"checking": "Sprawdzanie…",
"save": "Zapisz",
"saveEnable": "Zapisz i włącz",
"disable": "Wyłącz",
"remove": "Usuń",
"builtinNote": "Wtyczki wbudowane można wyłączyć, ale nie usunąć",
"confirmRemove": "Usunąć zewnętrzną wtyczkę „{name}”? Jej zapisana konfiguracja zostanie usunięta."
},
"api": {
"colEndpoint": "Punkt końcowy",
"colDescription": "Opis",
"authNone": "Bez uwierzytelniania",
"authBearer": "Token Bearer",
"authManager": "Administrator / superadministrator",
"authSuperadmin": "Superadministrator",
"groupPublic": "Publiczne",
"groupIdentity": "Tożsamość",
"groupCars": "Samochody",
"groupService": "Wpisy serwisowe",
"groupParts": "Części",
"groupAccount": "Konto",
"groupManagement": "Zarządzanie",
"groupSuperadmin": "Superadministrator"
}
}