Implement AsciiDoc and TipTap conversion logic, including new Admonition node and CodeBlock extension; add DiffViewer and HistoryPanel components for document version comparison; introduce password hashing utility with SHA-256.
This commit is contained in:
459
ARCHITECTURE.md
Normal file
459
ARCHITECTURE.md
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
# Archivum — Arkitektur och designdokumentation
|
||||||
|
|
||||||
|
## Innehållsförteckning
|
||||||
|
|
||||||
|
1. [Systemöversikt](#1-systemöversikt)
|
||||||
|
2. [Frontend](#2-frontend)
|
||||||
|
3. [Backend](#3-backend)
|
||||||
|
4. [Exekveringsflöden](#4-exekveringsflöden)
|
||||||
|
5. [Kodstruktur](#5-kodstruktur)
|
||||||
|
6. [Containerisering och driftsättning](#6-containerisering-och-driftsättning)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Systemöversikt
|
||||||
|
|
||||||
|
Archivum är en självhostad wiki/dokumenthanterare. Alla dokument lagras som AsciiDoc-filer (`.adoc`) på disk och versionshanteras automatiskt med Git. Frontenden är en SPA (Single-Page Application) som kommunicerar med backenden uteslutande via GraphQL.
|
||||||
|
|
||||||
|
```
|
||||||
|
Webbläsare
|
||||||
|
│ HTTPS / HTTP
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Go HTTP-server :4000 │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ /graphql │ │ SPA / │ │
|
||||||
|
│ └────┬─────┘ └──────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────▼──────────────────┐ │
|
||||||
|
│ │ GraphQL dispatcher │ │
|
||||||
|
│ └─┬──────┬──────┬───────┘ │
|
||||||
|
│ Auth Storage Git │
|
||||||
|
│ (SQLite)(disk) (os/exec) │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Teknologistack
|
||||||
|
|
||||||
|
| Lager | Teknik |
|
||||||
|
|--------------|----------------------------------------------------------------|
|
||||||
|
| Frontend | Vue 3 (Composition API), Vite, TypeScript, Tailwind CSS |
|
||||||
|
| State | Pinia |
|
||||||
|
| Routing | Vue Router 4 |
|
||||||
|
| Editor | TipTap 2 (visuell), CodeMirror 6 (AsciiDoc-källa) |
|
||||||
|
| AsciiDoc | Asciidoctor.js (rendering i webbläsaren) |
|
||||||
|
| PWA | vite-plugin-pwa (Service Worker, manifest) |
|
||||||
|
| Backend | Go 1.22, HTTP-standardbibliotek |
|
||||||
|
| Databas | SQLite via modernc.org/sqlite (CGO-fri, multi-arch) |
|
||||||
|
| Autentisering| Lokala konton (bcrypt) + LDAP, JWT-liknande sessions-tokens |
|
||||||
|
| Versionshantering | Git via `os/exec` |
|
||||||
|
| Containerisering | Docker (multi-stage, multi-arch: amd64 + arm64) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Frontend
|
||||||
|
|
||||||
|
### Paket och beroenden
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── App.vue # Rotemponent — kontrollerar setup/login/app-läge
|
||||||
|
│ ├── main.ts # Appstart, monterar Vue + Pinia + Router
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── gql.ts # Minimal GraphQL-klient (fetch-baserad)
|
||||||
|
│ │ └── crypto.ts # Lösenords-hashing (SHA-256 via Web Crypto API)
|
||||||
|
│ ├── stores/
|
||||||
|
│ │ ├── app.ts # Global state: token, requiresSetup, login/logout
|
||||||
|
│ │ └── theme.ts # Mörkt/ljust läge, persistas i localStorage
|
||||||
|
│ ├── router/index.ts # SPA-routes
|
||||||
|
│ ├── views/ # Sidkomponenter
|
||||||
|
│ ├── components/ # Återanvändningsbara UI-komponenter
|
||||||
|
│ │ ├── editor/ # Visuell och källkodseditor
|
||||||
|
│ │ ├── layout/ # AppLayout, Sidebar, ThemeToggle
|
||||||
|
│ │ └── wizard/ # Installationsguide (SetupWizard)
|
||||||
|
│ ├── bridge/
|
||||||
|
│ │ └── asciidoc-bridge.ts # AsciiDoc ↔ TipTap JSON-konvertering
|
||||||
|
│ └── assets/main.css # Tailwind-bas + CSS-variabler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
| Sökväg | Komponent | Åtkomstkrav |
|
||||||
|
|---------------|------------------|---------------------|
|
||||||
|
| `/` | HomeView | Inloggad |
|
||||||
|
| `/doc/:slug` | DocumentView | Inloggad |
|
||||||
|
| `/admin` | AdminView | Inloggad, admin |
|
||||||
|
| `/setup` | SetupWizard | Ej konfigurerat |
|
||||||
|
|
||||||
|
Routeskydd hanteras **inte** med router guards utan direkt i `App.vue` — se avsnitt 4.1.
|
||||||
|
|
||||||
|
### GraphQL-klienten (`lib/gql.ts`)
|
||||||
|
|
||||||
|
Alla API-anrop görs med en enkel `fetch`-wrapper. Klienten:
|
||||||
|
- Läser JWT-token från `localStorage` och skickar den som `Authorization: Bearer <token>`
|
||||||
|
- Kastar ett `Error` om svaret innehåller GraphQL-errors
|
||||||
|
- Hanterar automatisk utloggning vid `UNAUTHORIZED`-fel
|
||||||
|
|
||||||
|
### Lösenordshantering (`lib/crypto.ts`)
|
||||||
|
|
||||||
|
Lösenordet hashas på klienten **innan** det skickas till servern med:
|
||||||
|
```
|
||||||
|
SHA-256(lowercase(username) + ":" + password)
|
||||||
|
```
|
||||||
|
Web Crypto API (`crypto.subtle.digest`) används — ingen extern beroende. Username fungerar som domän-separator (salt) så att samma lösenord ger olika digest för olika konton. Backenden tar emot denna digest och lagrar `bcrypt(digest)`.
|
||||||
|
|
||||||
|
### PWA
|
||||||
|
|
||||||
|
Konfigureras i `vite.config.ts` via `vite-plugin-pwa`:
|
||||||
|
- Service Worker med `skipWaiting` + `clientsClaim` (ny SW aktiveras omedelbart)
|
||||||
|
- Cachar statiska assets (JS, CSS, fonts)
|
||||||
|
- HTML och `/graphql` går alltid till nätverket (`NetworkFirst`)
|
||||||
|
- `navigateFallback: null` — förhindrar att SW returnerar cachat HTML för routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Backend
|
||||||
|
|
||||||
|
### Paket
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── cmd/server/main.go # Startpunkt — laddar config, startar HTTP-server
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/config.go # Config-struktur, Load/Save, ErrRequireSetup
|
||||||
|
│ ├── auth/auth.go # Session-hantering, bcrypt, LDAP-bind
|
||||||
|
│ ├── db/db.go # SQLite-wrapper — users-tabell
|
||||||
|
│ ├── storage/storage.go # Filhantering — Read/Write/List .adoc-filer
|
||||||
|
│ ├── git/git.go # Git-wrapper via os/exec — Commit/Log/Diff
|
||||||
|
│ └── graph/
|
||||||
|
│ ├── server.go # HTTP-handler + handgjord GraphQL-dispatcher
|
||||||
|
│ ├── resolver.go # Resolver-stub (plats för gqlgen-genererad kod)
|
||||||
|
│ └── schema.graphql # GraphQL-schema
|
||||||
|
```
|
||||||
|
|
||||||
|
### GraphQL-dispatcher
|
||||||
|
|
||||||
|
Backenden använder **inte** gqlgen-genererad kod i produktion. `server.go` implementerar en handgjord dispatcher som parsar query-strängen med `strings.Contains` och router till respektive handler-funktion. Detta ger enkel deployment utan kodgenerering men kräver manuell underhåll av schema och handlers.
|
||||||
|
|
||||||
|
### Autentisering och sessioner
|
||||||
|
|
||||||
|
```
|
||||||
|
Login-flöde:
|
||||||
|
1. Klient: SHA-256(username:password) → skickas som "password"
|
||||||
|
2. Server: bcrypt.CompareHashAndPassword(stored_hash, received_digest)
|
||||||
|
3. Vid match: slumpmässig hex-token genereras och sparas i minnet
|
||||||
|
4. Token returneras till klient, lagras i localStorage
|
||||||
|
5. Alla efterföljande requests: Authorization: Bearer <token>
|
||||||
|
|
||||||
|
Session-storage: in-memory map[token]*Session (försvinner vid omstart)
|
||||||
|
Session-livstid: 7 dagar (konfigurerat i auth.go)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Viktigt:** Sessions lagras enbart i minnet. Vid server-omstart måste alla klienter logga in igen.
|
||||||
|
|
||||||
|
### Setup-detektion — `needsSetup`
|
||||||
|
|
||||||
|
Backenden räknar ett system som oklonfigurerat (`REQUIRE_SETUP`) om **något** av följande är sant:
|
||||||
|
|
||||||
|
1. `config.json` saknas eller är ofullständig (`cfg == nil`)
|
||||||
|
2. Databasen kunde inte öppnas (`database == nil`)
|
||||||
|
3. Databasen är tom — ingen adminanvändare har skapats (`!database.HasUsers()`)
|
||||||
|
|
||||||
|
`db.HasUsers()` gör en enkel `SELECT COUNT(*) FROM users` och returnerar `true` om minst ett konto finns. Detta innebär att en nyskapad, tom SQLite-fil (som Go skapar vid `db.New()`) fortfarande triggar setup-läge.
|
||||||
|
|
||||||
|
### DB-sökväg — `resolveDBPath()`
|
||||||
|
|
||||||
|
Setup-handleren bestämmer DB-sökvägen dynamiskt för att fungera i både Docker och dev-miljö:
|
||||||
|
|
||||||
|
```
|
||||||
|
resolveDBPath(configPath):
|
||||||
|
├─ Kan /data/db skapas/skrivas? → /data/db/archivum.db (Docker-volym)
|
||||||
|
└─ Annars → <config-katalog>/archivum.db (dev-miljö)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Konfiguration
|
||||||
|
|
||||||
|
`config.json` hanteras av `internal/config`:
|
||||||
|
- `storage_path` — rotkatalog för .adoc-filer och Git-repo
|
||||||
|
- `db_path` — sökväg till SQLite-fil
|
||||||
|
- `jwt_secret` — används för session-token generation (ej JWT i klassisk mening)
|
||||||
|
- `listen_addr` — TCP-adress att lyssna på, t.ex. `:4000`
|
||||||
|
- `ldap` — valfri LDAP-konfiguration
|
||||||
|
|
||||||
|
Om filen saknas returnerar `Load()` `ErrRequireSetup` — backenden startar i setup-läge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Exekveringsflöden
|
||||||
|
|
||||||
|
### 4.1 Appstart och setup-detektion
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser laddar /
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
App.vue onMounted()
|
||||||
|
│
|
||||||
|
├─ theme.init() (laddar tema från localStorage)
|
||||||
|
│
|
||||||
|
├─ app.checkStatus() → POST /graphql { systemStatus }
|
||||||
|
│ │ Backend: needsSetup = cfg==nil || db==nil || !db.HasUsers()
|
||||||
|
│ │
|
||||||
|
│ ├─ svar: "REQUIRE_SETUP"
|
||||||
|
│ │ → app.requiresSetup = true
|
||||||
|
│ │ → visas: <SetupWizard /> (renderas direkt, ej via router)
|
||||||
|
│ │
|
||||||
|
│ └─ svar: "OK"
|
||||||
|
│ → app.requiresSetup = false
|
||||||
|
│ ├─ app.token finns: → visas: <AppLayout />
|
||||||
|
│ └─ app.token saknas: → visas: <LoginView />
|
||||||
|
│
|
||||||
|
└─ ready = true (spinner försvinner)
|
||||||
|
```
|
||||||
|
|
||||||
|
`SetupWizard`, `LoginView` och `AppLayout` renderas direkt i `App.vue` (inte via `<router-view>`) för att undvika race conditions mellan `router.replace()` och Vue-renderingen.
|
||||||
|
|
||||||
|
### 4.2 Installationsguiden (SetupWizard)
|
||||||
|
|
||||||
|
```
|
||||||
|
Steg 1: Ange storage-sökväg, admin-användare, lösenord
|
||||||
|
Steg 2: Valfri LDAP-konfiguration
|
||||||
|
Steg 3: Bekräftelse
|
||||||
|
|
||||||
|
Vid submit:
|
||||||
|
1. SHA-256(adminUser:adminPass) → hashedPass
|
||||||
|
2. POST /graphql mutation Setup { setup(input: {..., adminPass: hashedPass}) }
|
||||||
|
├─ Backend: resolveDBPath() → bestämmer db_path (Docker vs dev)
|
||||||
|
├─ Backend: skapar SQLite-DB och admin-användare med bcrypt(hashedPass) ← FÖRST
|
||||||
|
├─ Backend: sparar config.json ← SEDAN
|
||||||
|
└─ Backend: initierar storage-katalog och Git-repo
|
||||||
|
3. app.requiresSetup = false
|
||||||
|
4. app.login(adminUser, adminPass) → SHA-256 igen → POST /graphql mutation Login
|
||||||
|
5. Redirect till /
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordningen är viktig: om DB-skapandet eller user-insert misslyckas sparas **ingen** `config.json`. Nästa sidladdning ger återigen `REQUIRE_SETUP` och guiden visas på nytt. Tidigare sparades config först, vilket kunde lämna ett halvfärdigt tillstånd där backenden startade med config men utan adminanvändare.
|
||||||
|
|
||||||
|
### 4.3 Inloggningsflöde
|
||||||
|
|
||||||
|
```
|
||||||
|
Användaren fyller i användarnamn + lösenord
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
app.login(username, password)
|
||||||
|
│
|
||||||
|
├─ SHA-256(lowercase(username):password) → hashed
|
||||||
|
│
|
||||||
|
└─ POST /graphql mutation Login($u, $p: hashed)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
handleLogin (server.go)
|
||||||
|
│
|
||||||
|
├─ db.GetUser(username) → lokal SQLite
|
||||||
|
│ ├─ hittad: bcrypt.Compare(stored, hashed) → OK/fel
|
||||||
|
│ └─ ej hittad + LDAP konfigurerat → ldapBind test
|
||||||
|
│
|
||||||
|
├─ Skapar slumpmässig session-token
|
||||||
|
└─ Returnerar token
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Klient sparar token i localStorage
|
||||||
|
AppLayout renderas
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Spara dokument
|
||||||
|
|
||||||
|
```
|
||||||
|
Användaren redigerar och klickar Spara
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /graphql mutation SaveDocument
|
||||||
|
{ slug, content (AsciiDoc), commitMessage, author }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
handleSaveDocument (server.go)
|
||||||
|
│
|
||||||
|
├─ Kontrollerar session (Bearer token → Manager.Validate())
|
||||||
|
├─ storage.Write(slug, content) → skriver slug.adoc till disk
|
||||||
|
└─ git.Commit(slug.adoc, message, author, email)
|
||||||
|
│
|
||||||
|
└─ os/exec: git add → git commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Versionshistorik och diff
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /graphql { history(slug) }
|
||||||
|
│
|
||||||
|
└─ git.Log(slug.adoc) → os/exec: git log --format=...
|
||||||
|
→ []LogEntry { Hash, Author, Date, Subject }
|
||||||
|
|
||||||
|
GET /graphql { diff(slug, fromHash, toHash) }
|
||||||
|
│
|
||||||
|
└─ git.Diff(slug.adoc, from, to) → os/exec: git diff
|
||||||
|
→ unified diff-sträng
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Kodstruktur
|
||||||
|
|
||||||
|
### Fullständig katalogstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
Archivum/
|
||||||
|
├── backend/
|
||||||
|
│ ├── cmd/server/main.go # Entrypoint
|
||||||
|
│ ├── config.json # Lokal dev-konfiguration (gitignoreras ej — bör .gitignore:as i produktion)
|
||||||
|
│ ├── go.mod / go.sum
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── auth/auth.go # Session, bcrypt, LDAP
|
||||||
|
│ ├── config/config.go # Config-struktur och fil-I/O
|
||||||
|
│ ├── db/db.go # SQLite users-tabell
|
||||||
|
│ ├── git/git.go # Git-wrapper
|
||||||
|
│ ├── storage/storage.go # .adoc fil-I/O
|
||||||
|
│ └── graph/
|
||||||
|
│ ├── schema.graphql # GraphQL-schema (source of truth)
|
||||||
|
│ ├── resolver.go # Resolver-stub
|
||||||
|
│ └── server.go # HTTP-server + dispatcher + alla handlers
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── vite.config.ts # Vite + PWA + dev-proxy konfiguration
|
||||||
|
│ ├── tailwind.config.js
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── src/
|
||||||
|
│ ├── App.vue # Rotkomponent med setup/login/app-routing
|
||||||
|
│ ├── main.ts
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── gql.ts # GraphQL fetch-klient
|
||||||
|
│ │ └── crypto.ts # SHA-256 lösenordshashing
|
||||||
|
│ ├── stores/
|
||||||
|
│ │ ├── app.ts # Global app-state (Pinia)
|
||||||
|
│ │ └── theme.ts # Temahantering (Pinia)
|
||||||
|
│ ├── router/index.ts
|
||||||
|
│ ├── views/
|
||||||
|
│ │ ├── HomeView.vue # Dokumentlista
|
||||||
|
│ │ ├── DocumentView.vue # Visa/redigera dokument
|
||||||
|
│ │ ├── AdminView.vue # Administrationspanel
|
||||||
|
│ │ └── LoginView.vue # Inloggningsformulär
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── editor/
|
||||||
|
│ │ │ ├── VisualEditor.vue # TipTap WYSIWYG-editor
|
||||||
|
│ │ │ └── SourceEditor.vue # CodeMirror AsciiDoc-källeditor
|
||||||
|
│ │ ├── layout/
|
||||||
|
│ │ │ ├── AppLayout.vue # Huvudlayout med sidebar
|
||||||
|
│ │ │ ├── Sidebar.vue # Dokumentnavigering
|
||||||
|
│ │ │ └── ThemeToggle.vue # Ljust/mörkt läge-knapp
|
||||||
|
│ │ └── wizard/
|
||||||
|
│ │ └── SetupWizard.vue # Installationsguide (3 steg)
|
||||||
|
│ ├── bridge/
|
||||||
|
│ │ └── asciidoc-bridge.ts # AsciiDoc ↔ TipTap JSON
|
||||||
|
│ └── assets/main.css
|
||||||
|
│
|
||||||
|
├── docker/
|
||||||
|
│ ├── Dockerfile # Multi-stage build (frontend → backend → runtime)
|
||||||
|
│ ├── entrypoint.sh # PUID/PGID-hantering, katalogskapande
|
||||||
|
│ ├── docker-compose.yml # Dev/standard deploy
|
||||||
|
│ └── docker-compose.prod.yml # Produktionsinställningar
|
||||||
|
│
|
||||||
|
└── ARCHITECTURE.md # Detta dokument
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nyckelgränssnitt och datakontrakt
|
||||||
|
|
||||||
|
**GraphQL-schema (urval):**
|
||||||
|
```graphql
|
||||||
|
type Query {
|
||||||
|
systemStatus: SystemStatus! # OK | REQUIRE_SETUP
|
||||||
|
documents(prefix: String): [DocumentMeta!]!
|
||||||
|
document(slug: String!): Document
|
||||||
|
history(slug: String!): [CommitEntry!]!
|
||||||
|
diff(slug: String!, fromHash: String!, toHash: String!): String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
setup(input: SetupInput!): Boolean!
|
||||||
|
login(username: String!, password: String!): String! # returnerar session-token
|
||||||
|
logout: Boolean!
|
||||||
|
saveDocument(input: SaveDocumentInput!): Document!
|
||||||
|
deleteDocument(slug: String!): Boolean!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Containerisering och driftsättning
|
||||||
|
|
||||||
|
### Dockerfile (multi-stage)
|
||||||
|
|
||||||
|
```
|
||||||
|
Stage 1 (frontend-builder) — node:20-alpine
|
||||||
|
npm install && npm run build → /app/frontend/dist
|
||||||
|
|
||||||
|
Stage 2 (backend-builder) — golang:1.22-alpine
|
||||||
|
CGO_ENABLED=0 go build → /archivum (statisk binär)
|
||||||
|
|
||||||
|
Stage 3 (runtime) — alpine:3.20
|
||||||
|
Installerar: su-exec, git
|
||||||
|
Kopierar: dist/ → /srv/archivum/ui
|
||||||
|
archivum → /usr/local/bin/archivum
|
||||||
|
entrypoint.sh
|
||||||
|
Volumes: /config, /data
|
||||||
|
Port: 4000
|
||||||
|
Entrypoint: entrypoint.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Volymer och kataloger
|
||||||
|
|
||||||
|
| Volym | Innehåll | Env-variabel |
|
||||||
|
|-----------|---------------------------------------------------|----------------|
|
||||||
|
| `/config` | `config.json` | `DOCKER_PATH` |
|
||||||
|
| `/data` | `wiki/` (AsciiDoc + Git-repo), `db/archivum.db` | — |
|
||||||
|
|
||||||
|
### Miljövariabler
|
||||||
|
|
||||||
|
| Variabel | Standard | Beskrivning |
|
||||||
|
|---------------|-------------------|------------------------------------------|
|
||||||
|
| `DOCKER_PATH` | — | Host-sökväg som monteras som `/config` |
|
||||||
|
| `UI_DIR` | `/srv/archivum/ui`| Sökväg till kompilerad frontend |
|
||||||
|
| `PUID` | `1000` | Filsystemsägare (UID) |
|
||||||
|
| `PGID` | `1000` | Filsystemsägare (GID) |
|
||||||
|
| `TZ` | `Europe/Stockholm`| Tidszon |
|
||||||
|
| `HOST_PORT` | `8080` | Extern port i docker-compose |
|
||||||
|
|
||||||
|
### Multi-arch build
|
||||||
|
|
||||||
|
Bilden stöder `linux/amd64` (server/desktop) och `linux/arm64` (Raspberry Pi 5):
|
||||||
|
```bash
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t registry:5000/archivum:latest \
|
||||||
|
-f docker/Dockerfile --push .
|
||||||
|
```
|
||||||
|
|
||||||
|
### entrypoint.sh — PUID/PGID-hantering
|
||||||
|
|
||||||
|
Containern startar som `root`. `entrypoint.sh`:
|
||||||
|
1. Skapar `/config`, `/data/wiki`, `/data/db` om de saknas
|
||||||
|
2. Ändrar ägare på volymerna till `PUID:PGID`
|
||||||
|
3. Kör applikationen som `PUID:PGID` via `su-exec`
|
||||||
|
|
||||||
|
### docker-compose.yml (minimal .env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
DOCKER_PATH=/opt/archivum # Host-sökväg — config/ och data/ skapas här
|
||||||
|
HOST_PORT=8080
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
TZ=Europe/Stockholm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uppgraderingsförfarande
|
||||||
|
|
||||||
|
1. Bygg/hämta ny image
|
||||||
|
2. `docker compose down`
|
||||||
|
3. `docker compose up -d`
|
||||||
|
|
||||||
|
Inga databas-migreringar behövs i de flesta uppgraderingar; SQLite-schemat är bakåtkompatibelt (`CREATE TABLE IF NOT EXISTS`).
|
||||||
351
ASCIIDOC_TIPTAP_CONVERSION.md
Normal file
351
ASCIIDOC_TIPTAP_CONVERSION.md
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
# Konvertering mellan AsciiDoc och TipTap: Typspecifikation
|
||||||
|
|
||||||
|
Detta dokument beskriver i detalj hur datamodellen mappas mellan **TipTap JSON** och **AsciiDoc**. Dokumentet fungerar som en specifikation för utvecklare som ska implementera konverteringslogik, baserat på referenslogiken från `tiptap-to-asciidoc`.
|
||||||
|
|
||||||
|
**Gällande versioner:**
|
||||||
|
* **TipTap:** v2
|
||||||
|
* **AsciiDoc:** Asciidoctor (Asciidoctor.js / källkodsstandard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Översikt av datamodeller
|
||||||
|
|
||||||
|
* **TipTap JSON:** Ett träd av _Nodes_ (blockelement som stycken, rubriker, listor) och _Marks_ (inline-formatering som fetstil, kursivt, länkar). Roten i trädet är en nod av typen `doc` som innehåller en array av block-noder i `content`.
|
||||||
|
* **AsciiDoc:** Textbaserad markup som förlitar sig på radbrytningar och prefix/suffix (t.ex. `==`, `*`, `_`).
|
||||||
|
|
||||||
|
Vid konvertering från TipTap till AsciiDoc traverseras JSON-trädet uppifrån och ner.
|
||||||
|
Vid konvertering från AsciiDoc till TipTap behöver AsciiDoc-mjukvara generellt konvertera texten till HTML, varefter TipTaps inbyggda parser (`@tiptap/html` -> `generateJSON`) nyttjas för att omvandla HTML till TipTap JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Block-noder (Nodes)
|
||||||
|
|
||||||
|
### 1. Document (Rot-nod)
|
||||||
|
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"content": [
|
||||||
|
... // Andra block-noder
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc:**
|
||||||
|
Inget specifikt syntax, detta representerar hela dokumentfilen. Block-noderna under `content` renderas sekventiellt separerade med tomma rader (två radbrytningar `\n\n` mellan blockelement rekommenderas generellt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Heading (Rubriker)
|
||||||
|
|
||||||
|
Rubriknivåer definieras med attributet `level`.
|
||||||
|
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": {
|
||||||
|
"level": 2
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "Min Rubrik" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc:**
|
||||||
|
Antalet likhetstecken (`=`) motsvarar värdet i `level`. Exempel för `level: 2`:
|
||||||
|
```asciidoc
|
||||||
|
== Min Rubrik
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Paragraph (Stycken och Tomma rader)
|
||||||
|
|
||||||
|
Ett stycke översätts normalt rätt upp och ner. En tom rad i TipTap (ett tomt stycke) representeras via AsciiDocs specialattribut `{blank}` vilket skapar en tom paragraf som parsas tillbaka som ett tomt stycke.
|
||||||
|
|
||||||
|
**TipTap JSON (Vanligt stycke):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "Ett vanligt stycke text." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc (Vanligt stycke):**
|
||||||
|
```asciidoc
|
||||||
|
Ett vanligt stycke text.
|
||||||
|
```
|
||||||
|
|
||||||
|
**TipTap JSON (Tomt stycke / Tom rad):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc (Tomt stycke / Tom rad):**
|
||||||
|
```asciidoc
|
||||||
|
{blank}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Code Block (Kodblock)
|
||||||
|
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "codeBlock",
|
||||||
|
"attrs": {
|
||||||
|
"language": "javascript"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "console.log('Hej');" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
[source,javascript]
|
||||||
|
----
|
||||||
|
console.log('Hej');
|
||||||
|
----
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Blockquote (Citatblock)
|
||||||
|
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "blockquote",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "Detta är ett citat." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
____
|
||||||
|
Detta är ett citat.
|
||||||
|
____
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Horizontal Rule (Avdelare)
|
||||||
|
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "horizontalRule"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
'''
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Listor (Bullet List / Ordered List)
|
||||||
|
|
||||||
|
**TipTap JSON (Oordnad / Bullet List):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "listItem",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "Punkt 1" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc (Oordnad):**
|
||||||
|
```asciidoc
|
||||||
|
* Punkt 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**TipTap JSON (Ordnad / Ordered List):**
|
||||||
|
Samma men `type` är `"orderedList"`.
|
||||||
|
|
||||||
|
**AsciiDoc (Ordnad):**
|
||||||
|
```asciidoc
|
||||||
|
. Punkt 1
|
||||||
|
. Punkt 2
|
||||||
|
.. Underpunkt 2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inline-formatering (Marks)
|
||||||
|
|
||||||
|
### Hard Break
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{ "type": "hardBreak" }
|
||||||
|
```
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bold
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{ "type": "text", "text": "viktig", "marks": [{ "type": "bold" }] }
|
||||||
|
```
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
*viktig*
|
||||||
|
```
|
||||||
|
|
||||||
|
### Italic
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{ "type": "text", "text": "speciell", "marks": [{ "type": "italic" }] }
|
||||||
|
```
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
_speciell_
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strike
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{ "type": "text", "text": "gammal", "marks": [{ "type": "strike" }] }
|
||||||
|
```
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
[line-through]#gammal#
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{ "type": "text", "text": "const x = 1;", "marks": [{ "type": "code" }] }
|
||||||
|
```
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
`const x = 1;`
|
||||||
|
```
|
||||||
|
|
||||||
|
### Link
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{ "type": "text", "text": "Klicka här", "marks": [{ "type": "link", "attrs": { "href": "https://example.com" } }] }
|
||||||
|
```
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
https://example.com[Klicka här]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Länkar (Links)
|
||||||
|
|
||||||
|
Explicita attributslänkar i AsciiDoc.
|
||||||
|
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "platsöversikten",
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"attrs": {
|
||||||
|
"href": "index"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
link:index[platsöversikten]
|
||||||
|
```
|
||||||
|
*(Notera att http/https-länkar kan använda `https://exempel.com[text]`, medan interna/släktlänkar primärt använder `link:path[text]` i AsciiDoc).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Admonitions (Varningar, Tips, Notiser)
|
||||||
|
|
||||||
|
Informationsblock som sticker ut från mängden. För att TipTap inte ska tappa bort detta (vilket ofta sker om man låter standard HTML-tabeller importeras), representeras detta som en egen anpassad (custom) nod-typ i JSON, `admonition`.
|
||||||
|
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "admonition",
|
||||||
|
"attrs": {
|
||||||
|
"type": "tip"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "Spelarna känner till detta..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc (Förenklad ettklyftig admonition):**
|
||||||
|
```asciidoc
|
||||||
|
[TIP]
|
||||||
|
====
|
||||||
|
Spelarna känner till detta...
|
||||||
|
====
|
||||||
|
```
|
||||||
|
*(Eller enraders som `TIP: Spelarna...` - men blockformat är mer robust och generikt).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Code Block - Språk-attribut (Syntax Highlighting)
|
||||||
|
|
||||||
|
För att kodblocket ska förstå och färgkoda språket korrekt (till exempel `yaml`), paraserar koden `attrs.language` i TipTap JSON med `language` till `source`-parametern i AsciiDoc.
|
||||||
|
|
||||||
|
**TipTap JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "codeBlock",
|
||||||
|
"attrs": {
|
||||||
|
"language": "yaml"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "services:\n archivum:" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AsciiDoc:**
|
||||||
|
```asciidoc
|
||||||
|
[source,yaml]
|
||||||
|
----
|
||||||
|
services:
|
||||||
|
archivum:
|
||||||
|
----
|
||||||
|
```
|
||||||
165
README.md
165
README.md
@@ -1,55 +1,136 @@
|
|||||||
# Archivum
|
# Archivum
|
||||||
|
|
||||||
A high-performance, production-ready wiki system optimized for Desktop and Mobile, designed to run on a Raspberry Pi 5 (ARM64) or any AMD64 host.
|
Archivum är ett självhostat wiki- och dokumenthanteringssystem. Det är tänkt att användas för allt från enkla anteckningar och teknisk dokumentation till världsbyggande inför rollspelskampanjer. Alla dokument lagras som **AsciiDoc**-filer (`.adoc`) på disk, vilket innebär att du kan läsa och redigera dem direkt med valfritt predikat — utan att öppna webbgränssnittet. Applikationen renderar dokumenten till HTML i webbläsaren och hanterar automatisk **versionshantering via Git**, så att varje sparning skapar ett commit och alla ändringar kan rullas tillbaka.
|
||||||
|
|
||||||
## Tech Stack
|
> Se [ARCHITECTURE.md](ARCHITECTURE.md) för detaljerad dokumentation om arkitektur, exekveringsflöden och komponentstruktur.
|
||||||
- **Backend:** Go (Golang) + GraphQL (gqlgen) + SQLite (modernc.org/sqlite).
|
|
||||||
- **Frontend:** Vue 3 (Composition API) + Vite + Tailwind CSS.
|
|
||||||
- **PWA:** Vite-plugin-pwa for mobile installation and offline caching.
|
|
||||||
- **Version Control:** Git (Backend executes Git commands on the storage path).
|
|
||||||
- **Editor:** TipTap (Visual) and CodeMirror (Manual AsciiDoc).
|
|
||||||
- **Auth:** LDAP + JWT with in-memory session tracking.
|
|
||||||
|
|
||||||
## Core Architectural Flow
|
---
|
||||||
|
|
||||||
### 1. Configuration & First-Run Setup
|
## Användningsområden
|
||||||
- **Config Management:** Separate `config.json` (Backend) and `settings.json` (Frontend). Paths defined via Docker environment variables.
|
|
||||||
- **Initial State:** If configs are missing/empty, API signals `REQUIRE_SETUP`. Frontend triggers a "Setup Wizard" for LDAP, Admin user, and Storage paths.
|
|
||||||
|
|
||||||
### 2. Mobile & PWA Optimization
|
- **Anteckningar** — snabbanteckningar och personliga kunskapsbaser
|
||||||
- **Responsive Design:** Mobile-first UI using Tailwind. The sidebar (tree view) should become a slide-over menu on mobile.
|
- **Wiki** — team-wiki med dokumentträd och historik
|
||||||
- **Read-Optimized:** Documents must be perfectly rendered for small screens with adjustable font sizes.
|
- **Teknisk dokumentation** — API-dokumentation, systembeskrivningar, runbooks
|
||||||
- **PWA Features:** Manifest and Service Worker for "Add to Home Screen" support, fast loading, and basic offline viewing of cached documents.
|
- **Rollspelskampanjer** — världsbyggande, NPC-register, kartor och händelseloggar
|
||||||
|
|
||||||
### 3. Git-Storage & History
|
---
|
||||||
- **Save Operation:** Receive AsciiDoc string + Commit Message -> Write to disk -> Git Commit with user as author.
|
|
||||||
- **History & Diff:** GraphQL queries for commit logs and unified diffs.
|
|
||||||
|
|
||||||
### 4. The Visual Transformer (Round-trip)
|
## Krav
|
||||||
- TypeScript "Bridge" using **Asciidoctor.js AST**:
|
|
||||||
- `toTipTap(asciidoc: string): JSON`
|
|
||||||
- `fromTipTap(json: JSON): string`
|
|
||||||
|
|
||||||
## Detailed Requirements
|
### För att köra med Docker (rekommenderat)
|
||||||
|
|
||||||
### Backend (Go)
|
- [Docker](https://docs.docker.com/get-docker/) ≥ 24
|
||||||
- **CGO-Free:** Pure Go SQLite for multi-arch support (ARM64/AMD64).
|
- [Docker Compose](https://docs.docker.com/compose/) v2
|
||||||
- **Git:** Manage repo via `os/exec` or `go-git`.
|
|
||||||
- **Auth:** LDAP + Salted Bearer tokens.
|
|
||||||
|
|
||||||
### Frontend (Vue 3)
|
### För lokal utveckling
|
||||||
- **Hybrid Editor:** Seamless toggle between Visual (TipTap) and Source (CodeMirror).
|
|
||||||
- **Mobile UI:** Collapsible navigation and touch-friendly buttons.
|
|
||||||
- **Setup Wizard:** Dedicated route for initial configuration.
|
|
||||||
|
|
||||||
### Docker & Deployment
|
- [Go](https://go.dev/dl/) ≥ 1.22
|
||||||
- **Multi-Stage Dockerfile:** Multi-arch support.
|
- [Node.js](https://nodejs.org/) ≥ 20 + npm
|
||||||
- **Volumes:** Handle `${DOCKER_PATH}/config` and `${DOCKER_PATH}/data`.
|
- [Git](https://git-scm.com/) (används av backenden för versionshantering)
|
||||||
- **Permissions:** Respect PUID/PGID for file system access.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Snabbstart med Docker Compose
|
||||||
|
|
||||||
|
**1. Skapa en `.env`-fil** i mappen `docker/` (eller i roten):
|
||||||
|
|
||||||
|
```env
|
||||||
|
DOCKER_PATH=/opt/archivum # Host-katalog där config/ och data/ skapas
|
||||||
|
HOST_PORT=8080
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
TZ=Europe/Stockholm
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Starta:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Öppna** `http://localhost:8080` i webbläsaren. Installationsguiden startar automatiskt vid första körningen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lokal utveckling
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go run ./cmd/server
|
||||||
|
# Servern lyssnar på :4000
|
||||||
|
# config.json i backend/ används som konfiguration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# Dev-server på :5173 med proxy till :4000
|
||||||
|
```
|
||||||
|
|
||||||
|
Öppna `http://localhost:5173`. API-anrop proxyas automatiskt till backend-servern.
|
||||||
|
|
||||||
|
### Bygga frontend för produktion
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
# Utdata i frontend/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Konfigurationen lagras i `config.json`. Filen skapas automatiskt av installationsguiden.
|
||||||
|
|
||||||
|
| Fält | Beskrivning |
|
||||||
|
|----------------|-----------------------------------------------------|
|
||||||
|
| `storage_path` | Sökväg till katalogen där .adoc-filer och Git-repot lagras |
|
||||||
|
| `db_path` | Sökväg till SQLite-databasen (användarkonton) |
|
||||||
|
| `jwt_secret` | Hemlig nyckel för session-tokens |
|
||||||
|
| `listen_addr` | TCP-adress att lyssna på, t.ex. `:4000` |
|
||||||
|
| `ldap` | Valfri LDAP-konfiguration för företagsinloggning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dokumentformat
|
||||||
|
|
||||||
|
Alla dokument skrivs i [AsciiDoc](https://asciidoc.org/). Exempeldokument:
|
||||||
|
|
||||||
|
```asciidoc
|
||||||
|
= Mitt dokument
|
||||||
|
:author: Anna Andersson
|
||||||
|
:date: 2026-04-12
|
||||||
|
|
||||||
|
== Introduktion
|
||||||
|
|
||||||
|
Det här är ett *fetstilt* stycke med en https://example.com[länk].
|
||||||
|
|
||||||
|
== Kodexempel
|
||||||
|
|
||||||
|
[source,go]
|
||||||
|
----
|
||||||
|
fmt.Println("Hello, Archivum!")
|
||||||
|
----
|
||||||
|
```
|
||||||
|
|
||||||
|
Filen sparas som `mitt-dokument.adoc` i `storage_path` och versionshanteras automatiskt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker: Multi-arch build
|
||||||
|
|
||||||
|
För att bygga och pusha en image som stöder både AMD64 och ARM64 (Raspberry Pi):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t ditt-registry/archivum:latest \
|
||||||
|
-f docker/Dockerfile --push .
|
||||||
|
```
|
||||||
|
|
||||||
## Deliverables
|
|
||||||
1. Full project structure.
|
|
||||||
2. PWA configuration (`vite-plugin-pwa`) and responsive layout components.
|
|
||||||
3. The TypeScript Bridge for AsciiDoc <-> TipTap conversion.
|
|
||||||
4. Multi-arch Dockerfile and Docker Compose template.
|
|
||||||
5. README.md with setup guide and JSON schema.
|
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ func (d *DB) init() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasUsers returns true if at least one user account exists.
|
||||||
|
func (d *DB) HasUsers() bool {
|
||||||
|
var n int
|
||||||
|
d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
// CreateUser inserts a new user. Returns ErrUserExists if the username is taken.
|
// CreateUser inserts a new user. Returns ErrUserExists if the username is taken.
|
||||||
func (d *DB) CreateUser(username, passHash, role string) error {
|
func (d *DB) CreateUser(username, passHash, role string) error {
|
||||||
_, err := d.sql.Exec(
|
_, err := d.sql.Exec(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,28 +50,43 @@ func (r *Repo) Commit(path, content, message, author, email string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log returns the commit history for a file.
|
// Log returns the commit history for a file, including added/removed line counts.
|
||||||
func (r *Repo) Log(path string) ([]LogEntry, error) {
|
func (r *Repo) Log(path string) ([]LogEntry, error) {
|
||||||
out, err := r.run("log", "--format=%H|%an|%ae|%ai|%s", "--", path)
|
out, err := r.run("log", "--format=COMMIT|%H|%an|%ae|%ai|%s", "--numstat", "--", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var entries []LogEntry
|
var entries []LogEntry
|
||||||
|
var current *LogEntry
|
||||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||||
if line == "" {
|
if strings.HasPrefix(line, "COMMIT|") {
|
||||||
continue
|
if current != nil {
|
||||||
|
entries = append(entries, *current)
|
||||||
}
|
}
|
||||||
parts := strings.SplitN(line, "|", 5)
|
parts := strings.SplitN(strings.TrimPrefix(line, "COMMIT|"), "|", 5)
|
||||||
if len(parts) == 5 {
|
if len(parts) == 5 {
|
||||||
entries = append(entries, LogEntry{
|
current = &LogEntry{
|
||||||
Hash: parts[0],
|
Hash: parts[0],
|
||||||
Author: parts[1],
|
Author: parts[1],
|
||||||
Email: parts[2],
|
Email: parts[2],
|
||||||
Date: parts[3],
|
Date: parts[3],
|
||||||
Subject: parts[4],
|
Subject: parts[4],
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if current != nil && strings.Contains(line, "\t") {
|
||||||
|
// numstat line: "added\tremoved\tfilename"
|
||||||
|
fields := strings.SplitN(line, "\t", 3)
|
||||||
|
if len(fields) == 3 {
|
||||||
|
added, _ := strconv.Atoi(fields[0])
|
||||||
|
removed, _ := strconv.Atoi(fields[1])
|
||||||
|
current.Added += added
|
||||||
|
current.Removed += removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current != nil {
|
||||||
|
entries = append(entries, *current)
|
||||||
|
}
|
||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +101,34 @@ func (r *Repo) Show(hash, path string) (string, error) {
|
|||||||
return r.run("show", fmt.Sprintf("%s:%s", hash, path))
|
return r.run("show", fmt.Sprintf("%s:%s", hash, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasUncommitted returns true if the working tree contains untracked or
|
||||||
|
// modified files that have not yet been committed.
|
||||||
|
func (r *Repo) HasUncommitted() bool {
|
||||||
|
out, err := r.run("status", "--porcelain")
|
||||||
|
return err == nil && strings.TrimSpace(out) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitAll stages every file in the repo root and creates a commit.
|
||||||
|
func (r *Repo) CommitAll(message, author, email string) error {
|
||||||
|
if _, err := r.run("add", "-A"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := r.run(
|
||||||
|
"-c", fmt.Sprintf("user.name=%s", author),
|
||||||
|
"-c", fmt.Sprintf("user.email=%s", email),
|
||||||
|
"commit", "-m", message,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type LogEntry struct {
|
type LogEntry struct {
|
||||||
Hash string
|
Hash string
|
||||||
Author string
|
Author string
|
||||||
Email string
|
Email string
|
||||||
Date string
|
Date string
|
||||||
Subject string
|
Subject string
|
||||||
|
Added int
|
||||||
|
Removed int
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeFile(path, content string) error {
|
func writeFile(path, content string) error {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ type Query {
|
|||||||
|
|
||||||
# Raw content of a document at a specific commit.
|
# Raw content of a document at a specific commit.
|
||||||
documentAtCommit(slug: String!, hash: String!): String!
|
documentAtCommit(slug: String!, hash: String!): String!
|
||||||
|
|
||||||
|
# Whether the current storage path has uncommitted files.
|
||||||
|
repoStatus: RepoStatus!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@@ -51,6 +54,9 @@ type Mutation {
|
|||||||
|
|
||||||
# Change the current user's password.
|
# Change the current user's password.
|
||||||
changePassword(old: String!, new: String!): Boolean!
|
changePassword(old: String!, new: String!): Boolean!
|
||||||
|
|
||||||
|
# Commit all uncommitted files in the storage path.
|
||||||
|
initCommit(message: String!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Types ──────────────────────────────────────────────────────────────────────
|
# ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -100,6 +106,12 @@ type CommitEntry {
|
|||||||
email: String!
|
email: String!
|
||||||
date: String!
|
date: String!
|
||||||
subject: String!
|
subject: String!
|
||||||
|
added: Int!
|
||||||
|
removed: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepoStatus {
|
||||||
|
hasUncommitted: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Inputs ─────────────────────────────────────────────────────────────────────
|
# ── Inputs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ func (s *Server) initRuntime(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var d *db.DB
|
var d *db.DB
|
||||||
|
if dir := filepath.Dir(cfg.DBPath); dir != "." && dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
log.Printf("[db] failed to create directory %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if database, err := db.New(cfg.DBPath); err != nil {
|
if database, err := db.New(cfg.DBPath); err != nil {
|
||||||
log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err)
|
log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -127,8 +132,11 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
cfg := s.cfg
|
cfg := s.cfg
|
||||||
store := s.store
|
store := s.store
|
||||||
|
database := s.database
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
needsSetup := cfg == nil || database == nil || !database.HasUsers()
|
||||||
|
|
||||||
q := req.Query
|
q := req.Query
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -137,7 +145,7 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleSetup(w, req)
|
s.handleSetup(w, req)
|
||||||
|
|
||||||
case strings.Contains(q, "systemStatus"):
|
case strings.Contains(q, "systemStatus"):
|
||||||
if cfg == nil {
|
if needsSetup {
|
||||||
writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`)
|
writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`)
|
||||||
} else {
|
} else {
|
||||||
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
|
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
|
||||||
@@ -155,7 +163,7 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ── All others require initialised config ─────────────────────────────────
|
// ── All others require initialised config ─────────────────────────────────
|
||||||
default:
|
default:
|
||||||
if cfg == nil {
|
if needsSetup {
|
||||||
writeGQLError(w, "REQUIRE_SETUP")
|
writeGQLError(w, "REQUIRE_SETUP")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -201,6 +209,12 @@ func (s *Server) dispatchAuthenticated(
|
|||||||
case strings.Contains(q, "config"):
|
case strings.Contains(q, "config"):
|
||||||
s.handleConfig(w, sess)
|
s.handleConfig(w, sess)
|
||||||
|
|
||||||
|
case strings.Contains(q, "repoStatus"):
|
||||||
|
s.handleRepoStatus(w, sess)
|
||||||
|
|
||||||
|
case strings.Contains(q, "initCommit"):
|
||||||
|
s.handleInitCommit(w, req, sess)
|
||||||
|
|
||||||
case strings.Contains(q, "history"):
|
case strings.Contains(q, "history"):
|
||||||
s.handleHistory(w, req, sess)
|
s.handleHistory(w, req, sess)
|
||||||
|
|
||||||
@@ -826,6 +840,63 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRepoStatus(w http.ResponseWriter, sess *auth.Session) {
|
||||||
|
if sess == nil {
|
||||||
|
writeGQLError(w, "UNAUTHORIZED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
repo := s.gitRepo
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
hasUncommitted := repo != nil && repo.HasUncommitted()
|
||||||
|
writeJSONObj(w, map[string]interface{}{
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"repoStatus": map[string]interface{}{
|
||||||
|
"hasUncommitted": hasUncommitted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleInitCommit(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
||||||
|
if sess == nil {
|
||||||
|
writeGQLError(w, "UNAUTHORIZED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message, _ := req.Variables["message"].(string)
|
||||||
|
if message == "" {
|
||||||
|
message = "Initial commit"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
repo := s.gitRepo
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if repo == nil {
|
||||||
|
writeGQLError(w, "storage not initialised")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !repo.HasUncommitted() {
|
||||||
|
// Nothing to commit — return success without error.
|
||||||
|
writeJSON(w, `{"data":{"initCommit":true}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := sess.Username + "@archivum"
|
||||||
|
if err := repo.CommitAll(message, sess.Username, email); err != nil {
|
||||||
|
log.Printf("[git] initCommit failed for %s: %v", sess.Username, err)
|
||||||
|
writeGQLError(w, fmt.Sprintf("commit failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[git] initCommit by %s: %q", sess.Username, message)
|
||||||
|
writeJSON(w, `{"data":{"initCommit":true}}`)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdateStoragePath(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
func (s *Server) handleUpdateStoragePath(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
log.Printf("[unauth] session is nil")
|
log.Printf("[unauth] session is nil")
|
||||||
@@ -886,7 +957,7 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
|
|||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
StoragePath: storagePath,
|
StoragePath: storagePath,
|
||||||
DBPath: "/data/db/archivum.db",
|
DBPath: resolveDBPath(s.configPath),
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
ListenAddr: ":4000",
|
ListenAddr: ":4000",
|
||||||
}
|
}
|
||||||
@@ -905,19 +976,8 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
|
// Open database and create admin user BEFORE saving config,
|
||||||
writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err))
|
// so a failed DB creation does not leave a broken config.json on disk.
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.Save(s.configPath, cfg); err != nil {
|
|
||||||
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[setup] config written to %s", s.configPath)
|
|
||||||
|
|
||||||
// Open database and create admin user.
|
|
||||||
if err := os.MkdirAll(dirOf(cfg.DBPath), 0755); err != nil {
|
if err := os.MkdirAll(dirOf(cfg.DBPath), 0755); err != nil {
|
||||||
writeGQLError(w, fmt.Sprintf("could not create db directory: %v", err))
|
writeGQLError(w, fmt.Sprintf("could not create db directory: %v", err))
|
||||||
return
|
return
|
||||||
@@ -942,6 +1002,18 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
|
|||||||
|
|
||||||
log.Printf("[setup] admin user %q created", adminUser)
|
log.Printf("[setup] admin user %q created", adminUser)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
|
||||||
|
writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Save(s.configPath, cfg); err != nil {
|
||||||
|
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[setup] config written to %s", s.configPath)
|
||||||
|
|
||||||
store, err := storage.New(storagePath)
|
store, err := storage.New(storagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[setup] failed to open storage at %s: %v", storagePath, err)
|
log.Printf("[setup] failed to open storage at %s: %v", storagePath, err)
|
||||||
@@ -1144,6 +1216,17 @@ func dirOf(path string) string {
|
|||||||
return path[:idx]
|
return path[:idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveDBPath returns the best DB path given the config file location.
|
||||||
|
// Prefers /data/db/archivum.db (Docker volume) if /data is writable;
|
||||||
|
// otherwise places the DB next to the config file.
|
||||||
|
func resolveDBPath(configPath string) string {
|
||||||
|
const dockerDB = "/data/db/archivum.db"
|
||||||
|
if err := os.MkdirAll("/data/db", 0755); err == nil {
|
||||||
|
return dockerDB
|
||||||
|
}
|
||||||
|
return filepath.Join(dirOf(configPath), "archivum.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
||||||
@@ -1174,6 +1257,8 @@ func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth
|
|||||||
"email": entry.Email,
|
"email": entry.Email,
|
||||||
"date": entry.Date,
|
"date": entry.Date,
|
||||||
"subject": entry.Subject,
|
"subject": entry.Subject,
|
||||||
|
"added": entry.Added,
|
||||||
|
"removed": entry.Removed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "history": out } })
|
writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "history": out } })
|
||||||
|
|||||||
98
frontend/package-lock.json
generated
98
frontend/package-lock.json
generated
@@ -9,6 +9,22 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/core": "^2.4.0",
|
"@tiptap/core": "^2.4.0",
|
||||||
|
"@tiptap/extension-blockquote": "^2.27.2",
|
||||||
|
"@tiptap/extension-bold": "^2.27.2",
|
||||||
|
"@tiptap/extension-bullet-list": "^2.27.2",
|
||||||
|
"@tiptap/extension-code": "^2.27.2",
|
||||||
|
"@tiptap/extension-code-block": "^2.27.2",
|
||||||
|
"@tiptap/extension-document": "^2.27.2",
|
||||||
|
"@tiptap/extension-heading": "^2.27.2",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^2.27.2",
|
||||||
|
"@tiptap/extension-italic": "^2.27.2",
|
||||||
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-list-item": "^2.27.2",
|
||||||
|
"@tiptap/extension-ordered-list": "^2.27.2",
|
||||||
|
"@tiptap/extension-paragraph": "^2.27.2",
|
||||||
|
"@tiptap/extension-strike": "^2.27.2",
|
||||||
|
"@tiptap/extension-text": "^2.27.2",
|
||||||
|
"@tiptap/html": "^2.27.2",
|
||||||
"@tiptap/pm": "^2.4.0",
|
"@tiptap/pm": "^2.4.0",
|
||||||
"@tiptap/starter-kit": "^2.4.0",
|
"@tiptap/starter-kit": "^2.4.0",
|
||||||
"@tiptap/vue-3": "^2.4.0",
|
"@tiptap/vue-3": "^2.4.0",
|
||||||
@@ -3068,6 +3084,23 @@
|
|||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-list-item": {
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
|
||||||
@@ -3146,6 +3179,23 @@
|
|||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/html": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-WiZgAvFjUprjyAczxAHLN9k++w7klwsCJ7EJDljtW/QXQ476Q0GqNtaHG4WRuW25cBH7c7AWZFptALeak2OE4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"zeed-dom": "^0.15.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/pm": {
|
"node_modules/@tiptap/pm": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
||||||
@@ -4155,6 +4205,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -4748,7 +4810,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -5775,6 +5836,12 @@
|
|||||||
"uc.micro": "^2.0.0"
|
"uc.micro": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
@@ -8863,6 +8930,35 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zeed-dom": {
|
||||||
|
"version": "0.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zeed-dom/-/zeed-dom-0.15.1.tgz",
|
||||||
|
"integrity": "sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"entities": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.13.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/holtwick"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zeed-dom/node_modules/entities": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/core": "^2.4.0",
|
"@tiptap/core": "^2.4.0",
|
||||||
|
"@tiptap/extension-blockquote": "^2.27.2",
|
||||||
|
"@tiptap/extension-bold": "^2.27.2",
|
||||||
|
"@tiptap/extension-bullet-list": "^2.27.2",
|
||||||
|
"@tiptap/extension-code": "^2.27.2",
|
||||||
|
"@tiptap/extension-code-block": "^2.27.2",
|
||||||
|
"@tiptap/extension-document": "^2.27.2",
|
||||||
|
"@tiptap/extension-heading": "^2.27.2",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^2.27.2",
|
||||||
|
"@tiptap/extension-italic": "^2.27.2",
|
||||||
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-list-item": "^2.27.2",
|
||||||
|
"@tiptap/extension-ordered-list": "^2.27.2",
|
||||||
|
"@tiptap/extension-paragraph": "^2.27.2",
|
||||||
|
"@tiptap/extension-strike": "^2.27.2",
|
||||||
|
"@tiptap/extension-text": "^2.27.2",
|
||||||
|
"@tiptap/html": "^2.27.2",
|
||||||
"@tiptap/pm": "^2.4.0",
|
"@tiptap/pm": "^2.4.0",
|
||||||
"@tiptap/starter-kit": "^2.4.0",
|
"@tiptap/starter-kit": "^2.4.0",
|
||||||
"@tiptap/vue-3": "^2.4.0",
|
"@tiptap/vue-3": "^2.4.0",
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import LoginView from '@/views/LoginView.vue'
|
import LoginView from '@/views/LoginView.vue'
|
||||||
|
import SetupWizard from '@/components/wizard/SetupWizard.vue'
|
||||||
|
|
||||||
const app = useAppStore()
|
const app = useAppStore()
|
||||||
const theme = useThemeStore()
|
const theme = useThemeStore()
|
||||||
const router = useRouter()
|
|
||||||
const ready = ref(false)
|
const ready = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
theme.init()
|
theme.init()
|
||||||
await app.checkStatus()
|
await app.checkStatus()
|
||||||
if (app.requiresSetup) {
|
|
||||||
router.replace('/setup')
|
|
||||||
}
|
|
||||||
ready.value = true
|
ready.value = true
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -37,7 +33,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<router-view v-if="app.requiresSetup" />
|
<SetupWizard v-if="app.requiresSetup" />
|
||||||
<LoginView v-else-if="!app.token" />
|
<LoginView v-else-if="!app.token" />
|
||||||
<AppLayout v-else />
|
<AppLayout v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,164 +1,179 @@
|
|||||||
/**
|
|
||||||
* AsciiDoc ↔ TipTap Bridge
|
|
||||||
*
|
|
||||||
* Converts between raw AsciiDoc strings and TipTap/ProseMirror JSON documents.
|
|
||||||
* Uses Asciidoctor.js to parse AsciiDoc into an AST, then maps nodes to
|
|
||||||
* ProseMirror node types understood by TipTap's StarterKit.
|
|
||||||
*
|
|
||||||
* Round-trip guarantee: fromTipTap(toTipTap(adoc)) should be semantically
|
|
||||||
* equivalent to the original adoc (formatting may differ slightly).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Asciidoctor from 'asciidoctor'
|
import Asciidoctor from 'asciidoctor'
|
||||||
|
import { generateJSON } from '@tiptap/html'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
|
import { Admonition, CustomCodeBlock } from './asciidoc-extensions'
|
||||||
|
|
||||||
const asciidoctor = Asciidoctor()
|
const asciidoctor = Asciidoctor()
|
||||||
|
|
||||||
// ── AsciiDoc → TipTap ────────────────────────────────────────────────────────
|
// ── AsciiDoc → TipTap ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function toTipTap(adoc: string): JSONContent {
|
export function toTipTap(adoc: string): JSONContent {
|
||||||
if (!adoc.trim()) {
|
if (!adoc || !adoc.trim()) {
|
||||||
return { type: 'doc', content: [{ type: 'paragraph' }] }
|
return { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = asciidoctor.load(adoc, { safe: 'safe' })
|
// Convert AsciiDoc to HTML, showtitle: false to skip huge document header wrappers
|
||||||
const blocks = (doc.getBlocks?.() ?? []) as AsciidoctorBlock[]
|
let htmlContent = asciidoctor.convert(adoc, {
|
||||||
const content = blocks.flatMap(convertBlock).filter(Boolean) as JSONContent[]
|
attributes: { showtitle: false },
|
||||||
|
standalone: false
|
||||||
|
}) as string
|
||||||
|
|
||||||
return {
|
// Rewrite admonition tables to our custom tag before letting TipTap parse it
|
||||||
type: 'doc',
|
htmlContent = htmlContent.replace(
|
||||||
content: content.length ? content : [{ type: 'paragraph' }],
|
/<div class="admonitionblock ([^"]+)">[\s\S]*?<td class="content">\s*([\s\S]*?)\s*<\/td>[\s\S]*?<\/div>/g,
|
||||||
|
(_match, type, content) => {
|
||||||
|
// The content might contain paragraphs from asciidoctor.
|
||||||
|
return `<div data-type="admonition" data-admonition-type="${type}">${content}</div>`
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
StarterKit.configure({ codeBlock: false }),
|
||||||
|
CustomCodeBlock,
|
||||||
|
Link,
|
||||||
|
Admonition
|
||||||
|
]
|
||||||
|
|
||||||
|
return generateJSON(htmlContent, extensions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── TipTap → AsciiDoc ────────────────────────────────────────────────────────
|
// ── TipTap → AsciiDoc ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fromTipTap(json: JSONContent): string {
|
class TipTapToAsciidoc {
|
||||||
if (!json.content?.length) return ''
|
json: JSONContent
|
||||||
return json.content.map(nodeToAdoc).join('\n\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal converters ──────────────────────────────────────────────────────
|
constructor(tiptapJson: JSONContent) {
|
||||||
|
this.json = tiptapJson
|
||||||
// Asciidoctor.js types are not fully typed — use a minimal interface.
|
|
||||||
interface AsciidoctorBlock {
|
|
||||||
getNodeName(): string
|
|
||||||
getLevel?(): number
|
|
||||||
getTitle?(): string
|
|
||||||
getSource?(): string
|
|
||||||
getSourceLanguage?(): string
|
|
||||||
getContent?(): string
|
|
||||||
getBlocks?(): AsciidoctorBlock[]
|
|
||||||
getItems?(): AsciidoctorBlock[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertBlock(block: AsciidoctorBlock): JSONContent | JSONContent[] {
|
|
||||||
const name = block.getNodeName()
|
|
||||||
|
|
||||||
switch (name) {
|
|
||||||
case 'section':
|
|
||||||
case 'preamble':
|
|
||||||
return (block.getBlocks?.() ?? []).flatMap(convertBlock)
|
|
||||||
|
|
||||||
case 'paragraph':
|
|
||||||
return {
|
|
||||||
type: 'paragraph',
|
|
||||||
content: parseInline(block.getContent?.() ?? ''),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'listing':
|
convert(): string {
|
||||||
case 'literal': {
|
if (!this.json || this.json.type !== 'doc' || !this.json.content) {
|
||||||
const lang = block.getSourceLanguage?.() ?? ''
|
return ''
|
||||||
return {
|
|
||||||
type: 'codeBlock',
|
|
||||||
attrs: { language: lang || null },
|
|
||||||
content: [{ type: 'text', text: block.getSource?.() ?? '' }],
|
|
||||||
}
|
}
|
||||||
|
return this.processNodes(this.json.content).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'ulist':
|
processNodes(nodes: JSONContent[], listLevel: number = 1, listType: 'bullet' | 'ordered' = 'bullet'): string {
|
||||||
return {
|
let output = ''
|
||||||
type: 'bulletList',
|
|
||||||
content: (block.getItems?.() ?? []).map((item) => ({
|
|
||||||
type: 'listItem',
|
|
||||||
content: [{ type: 'paragraph', content: parseInline(item.getContent?.() ?? '') }],
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'olist':
|
nodes.forEach((node) => {
|
||||||
return {
|
|
||||||
type: 'orderedList',
|
|
||||||
content: (block.getItems?.() ?? []).map((item) => ({
|
|
||||||
type: 'listItem',
|
|
||||||
content: [{ type: 'paragraph', content: parseInline(item.getContent?.() ?? '') }],
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Fallback: render as paragraph.
|
|
||||||
return {
|
|
||||||
type: 'paragraph',
|
|
||||||
content: parseInline(block.getContent?.() ?? block.getSource?.() ?? ''),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Very basic inline markup → TipTap marks. */
|
|
||||||
function parseInline(text: string): JSONContent[] {
|
|
||||||
// Strip Asciidoctor HTML output to plain text for now.
|
|
||||||
// A full implementation would parse *bold*, _italic_, `code` etc.
|
|
||||||
const plain = text.replace(/<[^>]+>/g, '')
|
|
||||||
return plain ? [{ type: 'text', text: plain }] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeToAdoc(node: JSONContent): string {
|
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
|
case 'heading':
|
||||||
|
output += this.convertHeading(node)
|
||||||
|
break
|
||||||
case 'paragraph':
|
case 'paragraph':
|
||||||
return inlineToAdoc(node.content ?? [])
|
output += this.convertParagraph(node)
|
||||||
|
break
|
||||||
case 'heading': {
|
|
||||||
const level = (node.attrs?.level as number) ?? 1
|
|
||||||
const prefix = '='.repeat(level + 1)
|
|
||||||
return `${prefix} ${inlineToAdoc(node.content ?? [])}`
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'codeBlock': {
|
|
||||||
const lang = (node.attrs?.language as string | null) ?? ''
|
|
||||||
const src = node.content?.[0]?.text ?? ''
|
|
||||||
return `[source${lang ? ',' + lang : ''}]\n----\n${src}\n----`
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'bulletList':
|
case 'bulletList':
|
||||||
return (node.content ?? [])
|
output += this.processNodes(node.content || [], listLevel, 'bullet')
|
||||||
.map((li) => `* ${inlineToAdoc(li.content?.[0]?.content ?? [])}`)
|
output += '\n' // Extra linebreak after list
|
||||||
.join('\n')
|
break
|
||||||
|
|
||||||
case 'orderedList':
|
case 'orderedList':
|
||||||
return (node.content ?? [])
|
output += this.processNodes(node.content || [], listLevel, 'ordered')
|
||||||
.map((li) => `. ${inlineToAdoc(li.content?.[0]?.content ?? [])}`)
|
output += '\n'
|
||||||
.join('\n')
|
break
|
||||||
|
case 'listItem':
|
||||||
|
output += this.convertListItem(node, listLevel, listType)
|
||||||
|
break
|
||||||
|
case 'codeBlock':
|
||||||
|
output += this.convertCodeBlock(node)
|
||||||
|
break
|
||||||
case 'blockquote':
|
case 'blockquote':
|
||||||
return `[quote]\n____\n${(node.content ?? []).map(nodeToAdoc).join('\n')}\n____`
|
output += this.convertBlockquote(node)
|
||||||
|
break
|
||||||
|
case 'admonition':
|
||||||
|
output += this.convertAdmonition(node)
|
||||||
|
break
|
||||||
case 'horizontalRule':
|
case 'horizontalRule':
|
||||||
return "'''"
|
output += "'''\n\n"
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
return inlineToAdoc(node.content ?? [])
|
console.warn(`Ohanterad nod-typ: ${node.type}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
convertHeading(node: JSONContent): string {
|
||||||
|
const level = (node.attrs?.level as number) || 1
|
||||||
|
const prefix = '='.repeat(level)
|
||||||
|
const text = this.renderTextNodes(node.content || [])
|
||||||
|
return `${prefix} ${text}\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
convertParagraph(node: JSONContent): string {
|
||||||
|
if (!node.content || node.content.length === 0) return '{blank}\n\n'
|
||||||
|
const text = this.renderTextNodes(node.content)
|
||||||
|
return `${text}\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
convertListItem(node: JSONContent, level: number, type: 'bullet' | 'ordered'): string {
|
||||||
|
const prefix = type === 'ordered' ? '.'.repeat(level) : '*'.repeat(level)
|
||||||
|
|
||||||
|
let itemText = node.content
|
||||||
|
? this.processNodes(node.content, level + 1, type).trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return `${prefix} ${itemText}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
convertCodeBlock(node: JSONContent): string {
|
||||||
|
const lang = (node.attrs?.language as string) || ''
|
||||||
|
const code = this.renderTextNodes(node.content || [])
|
||||||
|
return `[source${lang ? ',' + lang : ''}]\n----\n${code}\n----\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
convertBlockquote(node: JSONContent): string {
|
||||||
|
const text = node.content ? this.processNodes(node.content).trim() : ''
|
||||||
|
return `____\n${text}\n____\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
convertAdmonition(node: JSONContent): string {
|
||||||
|
const type = (node.attrs?.type as string) || 'TIP'
|
||||||
|
const text = node.content ? this.processNodes(node.content).trim() : ''
|
||||||
|
return `[${type.toUpperCase()}]\n====\n${text}\n====\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTextNodes(nodes: JSONContent[]): string {
|
||||||
|
if (!nodes || nodes.length === 0) return ''
|
||||||
|
let textOut = ''
|
||||||
|
|
||||||
|
nodes.forEach((t) => {
|
||||||
|
if (t.type === 'text') {
|
||||||
|
let text = t.text || ''
|
||||||
|
if (t.marks) {
|
||||||
|
t.marks.forEach(mark => {
|
||||||
|
switch (mark.type) {
|
||||||
|
case 'bold': text = `*${text}*`; break
|
||||||
|
case 'italic': text = `_${text}_`; break
|
||||||
|
case 'strike': text = `[line-through]#${text}#`; break
|
||||||
|
case 'code': text = `\`${text}\``; break
|
||||||
|
case 'link':
|
||||||
|
const url = mark.attrs?.href || ''
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
text = `${url}[${text}]`
|
||||||
|
} else {
|
||||||
|
text = `link:${url}[${text}]`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
textOut += text
|
||||||
|
}
|
||||||
|
if (t.type === 'hardBreak') {
|
||||||
|
textOut += ' +\n'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return textOut
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineToAdoc(nodes: JSONContent[]): string {
|
export function fromTipTap(json: JSONContent): string {
|
||||||
return nodes
|
const converter = new TipTapToAsciidoc(json)
|
||||||
.map((n) => {
|
return converter.convert()
|
||||||
const text = n.text ?? ''
|
|
||||||
const marks = (n.marks ?? []).map((m) => m.type)
|
|
||||||
let result = text
|
|
||||||
if (marks.includes('bold')) result = `*${result}*`
|
|
||||||
if (marks.includes('italic')) result = `_${result}_`
|
|
||||||
if (marks.includes('code')) result = `\`${result}\``
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
}
|
}
|
||||||
|
|||||||
41
frontend/src/bridge/asciidoc-extensions.ts
Normal file
41
frontend/src/bridge/asciidoc-extensions.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Node } from '@tiptap/core'
|
||||||
|
import CodeBlock from '@tiptap/extension-code-block'
|
||||||
|
|
||||||
|
export const Admonition = Node.create({
|
||||||
|
name: 'admonition',
|
||||||
|
group: 'block',
|
||||||
|
content: 'block+',
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
type: { default: 'note' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div[data-type="admonition"]',
|
||||||
|
getAttrs: (el) => {
|
||||||
|
if (typeof el === 'string') return {}
|
||||||
|
return { type: el.getAttribute('data-admonition-type') || 'note' }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', { 'data-type': 'admonition', 'data-admonition-type': HTMLAttributes.type, class: `admonition ${HTMLAttributes.type}` }, 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CustomCodeBlock = CodeBlock.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
language: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const code = element.querySelector('code') || element
|
||||||
|
return code?.getAttribute('data-lang') || code?.className.match(/language-(\w+)/)?.[1] || null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch, onBeforeUnmount } from 'vue'
|
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
import { toTipTap, fromTipTap } from '@/bridge/asciidoc-bridge'
|
import { toTipTap, fromTipTap } from '@/bridge/asciidoc-bridge'
|
||||||
|
import { Admonition, CustomCodeBlock } from '@/bridge/asciidoc-extensions'
|
||||||
|
|
||||||
const model = defineModel<string>({ required: true })
|
const model = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
const isEditing = ref(false)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [StarterKit],
|
extensions: [
|
||||||
|
StarterKit.configure({ codeBlock: false }),
|
||||||
|
CustomCodeBlock,
|
||||||
|
Link,
|
||||||
|
Admonition
|
||||||
|
],
|
||||||
content: toTipTap(model.value),
|
content: toTipTap(model.value),
|
||||||
|
editable: false,
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
model.value = fromTipTap(editor.getJSON())
|
model.value = fromTipTap(editor.getJSON())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(isEditing, (editing) => {
|
||||||
|
editor.value?.setEditable(editing)
|
||||||
|
if (editing) nextTick(() => editor.value?.commands.focus())
|
||||||
|
})
|
||||||
|
|
||||||
// Sync external changes (e.g. switching from SourceEditor) into TipTap.
|
// Sync external changes (e.g. switching from SourceEditor) into TipTap.
|
||||||
watch(model, (adoc) => {
|
watch(model, (adoc) => {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
@@ -25,10 +40,195 @@ watch(model, (adoc) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => editor.value?.destroy())
|
onBeforeUnmount(() => editor.value?.destroy())
|
||||||
|
|
||||||
|
function btnClass(active: boolean | undefined) {
|
||||||
|
return [
|
||||||
|
'px-2 py-0.5 rounded text-sm font-medium transition-colors select-none',
|
||||||
|
active ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-700',
|
||||||
|
]
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 prose prose-invert max-w-none">
|
<div class="flex flex-col h-full bg-white dark:bg-slate-900 relative">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div
|
||||||
|
v-if="editor"
|
||||||
|
class="flex overflow-x-auto items-center gap-1 p-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/80 flex-shrink-0"
|
||||||
|
:class="{ 'opacity-50 pointer-events-none': !isEditing }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleBold().run()"
|
||||||
|
:class="btnClass(editor.isActive('bold'))"
|
||||||
|
title="Fet [Ctrl+B]"
|
||||||
|
>
|
||||||
|
<span class="font-bold">B</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleItalic().run()"
|
||||||
|
:class="btnClass(editor.isActive('italic'))"
|
||||||
|
title="Kursiv [Ctrl+I]"
|
||||||
|
>
|
||||||
|
<span class="italic font-serif">I</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleStrike().run()"
|
||||||
|
:class="btnClass(editor.isActive('strike'))"
|
||||||
|
title="Genomstruken"
|
||||||
|
>
|
||||||
|
<span class="line-through">S</span>
|
||||||
|
</button>
|
||||||
|
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
|
||||||
|
|
||||||
|
<!-- Rubriker -->
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||||
|
:class="btnClass(editor.isActive('heading', { level: 1 }))"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||||
|
:class="btnClass(editor.isActive('heading', { level: 2 }))"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||||
|
:class="btnClass(editor.isActive('heading', { level: 3 }))"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
|
||||||
|
|
||||||
|
<!-- Listor -->
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleBulletList().run()"
|
||||||
|
:class="btnClass(editor.isActive('bulletList'))"
|
||||||
|
>
|
||||||
|
Lista
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||||
|
:class="btnClass(editor.isActive('orderedList'))"
|
||||||
|
>
|
||||||
|
Numrerad
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||||
|
:class="btnClass(editor.isActive('blockquote'))"
|
||||||
|
>
|
||||||
|
Citat
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||||
|
:class="btnClass(editor.isActive('codeBlock'))"
|
||||||
|
>
|
||||||
|
Kodblock
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-grow"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Area -->
|
||||||
|
<div class="flex-grow overflow-y-auto p-4 w-full">
|
||||||
|
<div
|
||||||
|
class="max-w-2xl mx-auto prose prose-indigo dark:prose-invert"
|
||||||
|
@click="!isEditing && (isEditing = true)"
|
||||||
|
>
|
||||||
<EditorContent :editor="editor" />
|
<EditorContent :editor="editor" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ... Tiptyap core styles ... */
|
||||||
|
.ProseMirror {
|
||||||
|
outline: none !important;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: #adb5bd;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.ProseMirror pre {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admonition Styling for Editor rendering */
|
||||||
|
.ProseMirror div.admonition {
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
background-color: #eff6ff;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ProseMirror div.admonition::before {
|
||||||
|
content: attr(data-admonition-type);
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #1e40af;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.ProseMirror div.admonition.warning {
|
||||||
|
border-left-color: #eab308;
|
||||||
|
background-color: #fefce8;
|
||||||
|
}
|
||||||
|
.ProseMirror div.admonition.warning::before {
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
.ProseMirror div.admonition.important {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
background-color: #fef2f2;
|
||||||
|
}
|
||||||
|
.ProseMirror div.admonition.important::before {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admonition Dark Mode */
|
||||||
|
html.dark .ProseMirror div.admonition,
|
||||||
|
.dark .ProseMirror div.admonition {
|
||||||
|
background-color: rgba(59, 130, 246, 0.15);
|
||||||
|
border-left-color: #60a5fa;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
html.dark .ProseMirror div.admonition::before,
|
||||||
|
.dark .ProseMirror div.admonition::before {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ProseMirror div.admonition.warning,
|
||||||
|
.dark .ProseMirror div.admonition.warning {
|
||||||
|
background-color: rgba(234, 179, 8, 0.15);
|
||||||
|
border-left-color: #facc15;
|
||||||
|
}
|
||||||
|
html.dark .ProseMirror div.admonition.warning::before,
|
||||||
|
.dark .ProseMirror div.admonition.warning::before {
|
||||||
|
color: #fde047;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ProseMirror div.admonition.important,
|
||||||
|
.dark .ProseMirror div.admonition.important {
|
||||||
|
background-color: rgba(239, 68, 68, 0.15);
|
||||||
|
border-left-color: #f87171;
|
||||||
|
}
|
||||||
|
html.dark .ProseMirror div.admonition.important::before,
|
||||||
|
.dark .ProseMirror div.admonition.important::before {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
354
frontend/src/components/history/DiffViewer.vue
Normal file
354
frontend/src/components/history/DiffViewer.vue
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* DiffViewer — shows the difference between two versions of a document.
|
||||||
|
*
|
||||||
|
* Three view modes:
|
||||||
|
* • raw-diff — classic unified diff with green/red line coloring
|
||||||
|
* • rendered-diff — rendered AsciiDoc, block-level LCS diff with colored sections
|
||||||
|
* • side-by-side — both rendered versions in two panels, no highlighting
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import Asciidoctor from 'asciidoctor'
|
||||||
|
|
||||||
|
const asciidoctor = Asciidoctor()
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Unified diff text from `diff(slug, oldHash, newHash)` */
|
||||||
|
unifiedDiff: string
|
||||||
|
/** Raw AsciiDoc of the older (from) version */
|
||||||
|
oldContent: string
|
||||||
|
/** Raw AsciiDoc of the newer (to) version */
|
||||||
|
newContent: string
|
||||||
|
/** Display label for the old version (e.g. short hash) */
|
||||||
|
oldLabel: string
|
||||||
|
/** Display label for the new version */
|
||||||
|
newLabel: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ── Mode ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Mode = 'raw-diff' | 'rendered-diff' | 'side-by-side'
|
||||||
|
const mode = ref<Mode>('raw-diff')
|
||||||
|
|
||||||
|
// ── Raw diff parsing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
type: 'added' | 'removed' | 'unchanged' | 'hunk' | 'meta'
|
||||||
|
content: string
|
||||||
|
lineOld: number | null
|
||||||
|
lineNew: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDiff = computed<DiffLine[]>(() => {
|
||||||
|
const lines = props.unifiedDiff.split('\n')
|
||||||
|
const result: DiffLine[] = []
|
||||||
|
let lineOld = 0
|
||||||
|
let lineNew = 0
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
if (raw.startsWith('diff ') || raw.startsWith('index ') || raw.startsWith('--- ') || raw.startsWith('+++ ')) {
|
||||||
|
result.push({ type: 'meta', content: raw, lineOld: null, lineNew: null })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (raw.startsWith('@@')) {
|
||||||
|
const m = raw.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
|
||||||
|
if (m) {
|
||||||
|
lineOld = parseInt(m[1])
|
||||||
|
lineNew = parseInt(m[2])
|
||||||
|
}
|
||||||
|
result.push({ type: 'hunk', content: raw, lineOld: null, lineNew: null })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (raw.startsWith('+')) {
|
||||||
|
result.push({ type: 'added', content: raw.slice(1), lineOld: null, lineNew: lineNew++ })
|
||||||
|
} else if (raw.startsWith('-')) {
|
||||||
|
result.push({ type: 'removed', content: raw.slice(1), lineOld: lineOld++, lineNew: null })
|
||||||
|
} else {
|
||||||
|
const content = raw.startsWith(' ') ? raw.slice(1) : raw
|
||||||
|
result.push({ type: 'unchanged', content, lineOld: lineOld++, lineNew: lineNew++ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Block-level LCS diff (for rendered-diff mode) ─────────────────────────────
|
||||||
|
|
||||||
|
/** Split AsciiDoc into logical blocks separated by blank lines. */
|
||||||
|
function splitBlocks(adoc: string): string[] {
|
||||||
|
return adoc
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((b) => b.trim())
|
||||||
|
.filter((b) => b.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LCS matrix — returns the DP table. */
|
||||||
|
function buildLCS(a: string[], b: string[]): number[][] {
|
||||||
|
const m = a.length
|
||||||
|
const n = b.length
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dp
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockStatus = 'unchanged' | 'removed' | 'added'
|
||||||
|
|
||||||
|
interface AnnotatedBlock {
|
||||||
|
status: BlockStatus
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute side-specific annotated block lists using LCS backtracking. */
|
||||||
|
function computeBlockDiff(
|
||||||
|
oldBlocks: string[],
|
||||||
|
newBlocks: string[],
|
||||||
|
): { oldSide: AnnotatedBlock[]; newSide: AnnotatedBlock[] } {
|
||||||
|
const dp = buildLCS(oldBlocks, newBlocks)
|
||||||
|
const oldSide: AnnotatedBlock[] = []
|
||||||
|
const newSide: AnnotatedBlock[] = []
|
||||||
|
|
||||||
|
let i = oldBlocks.length
|
||||||
|
let j = newBlocks.length
|
||||||
|
const oldTemp: AnnotatedBlock[] = []
|
||||||
|
const newTemp: AnnotatedBlock[] = []
|
||||||
|
|
||||||
|
while (i > 0 || j > 0) {
|
||||||
|
if (i > 0 && j > 0 && oldBlocks[i - 1] === newBlocks[j - 1]) {
|
||||||
|
oldTemp.push({ status: 'unchanged', content: oldBlocks[i - 1] })
|
||||||
|
newTemp.push({ status: 'unchanged', content: newBlocks[j - 1] })
|
||||||
|
i--
|
||||||
|
j--
|
||||||
|
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||||
|
newTemp.push({ status: 'added', content: newBlocks[j - 1] })
|
||||||
|
j--
|
||||||
|
} else {
|
||||||
|
oldTemp.push({ status: 'removed', content: oldBlocks[i - 1] })
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldTemp.reverse().forEach((b) => oldSide.push(b))
|
||||||
|
newTemp.reverse().forEach((b) => newSide.push(b))
|
||||||
|
|
||||||
|
return { oldSide, newSide }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlock(adoc: string): string {
|
||||||
|
if (!adoc.trim()) return ''
|
||||||
|
return asciidoctor.convert(adoc, { safe: 'safe', standalone: false }) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockDiff = computed(() => {
|
||||||
|
const oldBlocks = splitBlocks(props.oldContent)
|
||||||
|
const newBlocks = splitBlocks(props.newContent)
|
||||||
|
return computeBlockDiff(oldBlocks, newBlocks)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Rendered HTML for side-by-side mode ──────────────────────────────────────
|
||||||
|
|
||||||
|
const oldRendered = computed(() =>
|
||||||
|
(asciidoctor.convert(props.oldContent || '', { safe: 'safe', standalone: false }) as string) || '<p class="text-slate-400 italic">(empty)</p>',
|
||||||
|
)
|
||||||
|
|
||||||
|
const newRendered = computed(() =>
|
||||||
|
(asciidoctor.convert(props.newContent || '', { safe: 'safe', standalone: false }) as string) || '<p class="text-slate-400 italic">(empty)</p>',
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full overflow-hidden bg-white dark:bg-slate-900">
|
||||||
|
<!-- Header bar -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-800/60 flex-shrink-0 flex-wrap"
|
||||||
|
>
|
||||||
|
<!-- Back button -->
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 transition-colors"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to document
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-slate-300 dark:text-slate-600">|</div>
|
||||||
|
|
||||||
|
<!-- Version labels -->
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 font-mono">
|
||||||
|
{{ oldLabel }}
|
||||||
|
</span>
|
||||||
|
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||||
|
</svg>
|
||||||
|
<span class="px-2 py-0.5 rounded bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 font-mono">
|
||||||
|
{{ newLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
<!-- Mode switcher -->
|
||||||
|
<div class="flex items-center gap-1 rounded-lg bg-slate-200 dark:bg-slate-700/60 p-0.5">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
|
||||||
|
mode === 'raw-diff'
|
||||||
|
? 'bg-white dark:bg-slate-600 shadow-sm text-slate-900 dark:text-slate-100'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200',
|
||||||
|
]"
|
||||||
|
@click="mode = 'raw-diff'"
|
||||||
|
>Raw diff</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
|
||||||
|
mode === 'rendered-diff'
|
||||||
|
? 'bg-white dark:bg-slate-600 shadow-sm text-slate-900 dark:text-slate-100'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200',
|
||||||
|
]"
|
||||||
|
@click="mode = 'rendered-diff'"
|
||||||
|
>Rendered diff</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
|
||||||
|
mode === 'side-by-side'
|
||||||
|
? 'bg-white dark:bg-slate-600 shadow-sm text-slate-900 dark:text-slate-100'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200',
|
||||||
|
]"
|
||||||
|
@click="mode = 'side-by-side'"
|
||||||
|
>Side by side</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Raw diff view ──────────────────────────────────────────────────── -->
|
||||||
|
<div
|
||||||
|
v-if="mode === 'raw-diff'"
|
||||||
|
class="flex-1 overflow-auto font-mono text-xs leading-5"
|
||||||
|
>
|
||||||
|
<div v-if="!unifiedDiff.trim()" class="p-6 text-slate-400 italic">No differences found.</div>
|
||||||
|
<table v-else class="w-full border-collapse">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(line, idx) in parsedDiff" :key="idx"
|
||||||
|
:class="{
|
||||||
|
'bg-green-50 dark:bg-green-900/20': line.type === 'added',
|
||||||
|
'bg-red-50 dark:bg-red-900/20': line.type === 'removed',
|
||||||
|
'bg-slate-100 dark:bg-slate-800/60 text-slate-400': line.type === 'hunk',
|
||||||
|
'text-slate-400 dark:text-slate-600': line.type === 'meta',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Line number old -->
|
||||||
|
<td class="select-none w-10 text-right pr-2 pl-1 text-slate-400 dark:text-slate-600 border-r border-slate-200 dark:border-slate-700/40">
|
||||||
|
{{ line.lineOld ?? '' }}
|
||||||
|
</td>
|
||||||
|
<!-- Line number new -->
|
||||||
|
<td class="select-none w-10 text-right pr-2 pl-1 text-slate-400 dark:text-slate-600 border-r border-slate-200 dark:border-slate-700/40">
|
||||||
|
{{ line.lineNew ?? '' }}
|
||||||
|
</td>
|
||||||
|
<!-- Gutter marker -->
|
||||||
|
<td class="select-none w-5 text-center font-bold"
|
||||||
|
:class="{
|
||||||
|
'text-green-600 dark:text-green-400': line.type === 'added',
|
||||||
|
'text-red-500 dark:text-red-400': line.type === 'removed',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span v-if="line.type === 'added'">+</span>
|
||||||
|
<span v-else-if="line.type === 'removed'">-</span>
|
||||||
|
<span v-else-if="line.type === 'hunk'">@@</span>
|
||||||
|
</td>
|
||||||
|
<!-- Content -->
|
||||||
|
<td class="pl-2 pr-4 whitespace-pre-wrap break-all">
|
||||||
|
<span
|
||||||
|
:class="{
|
||||||
|
'text-green-800 dark:text-green-300': line.type === 'added',
|
||||||
|
'text-red-800 dark:text-red-300': line.type === 'removed',
|
||||||
|
'text-slate-500 dark:text-slate-400': line.type === 'hunk' || line.type === 'meta',
|
||||||
|
'text-slate-800 dark:text-slate-200': line.type === 'unchanged',
|
||||||
|
}"
|
||||||
|
>{{ line.content }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Rendered diff view ─────────────────────────────────────────────── -->
|
||||||
|
<div v-else-if="mode === 'rendered-diff'" class="flex-1 flex min-h-0 divide-x divide-slate-200 dark:divide-slate-700/60">
|
||||||
|
<!-- Old side -->
|
||||||
|
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold text-red-600 dark:text-red-400 bg-red-50/60 dark:bg-red-900/10 border-b border-slate-200 dark:border-slate-700/40 flex-shrink-0">
|
||||||
|
{{ oldLabel }} — removed
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto px-6 py-4 space-y-2">
|
||||||
|
<template v-for="(block, idx) in blockDiff.oldSide" :key="idx">
|
||||||
|
<div
|
||||||
|
v-if="block.status !== 'added'"
|
||||||
|
:class="[
|
||||||
|
'rounded px-3 py-2 prose dark:prose-invert prose-sm max-w-none',
|
||||||
|
block.status === 'removed'
|
||||||
|
? 'bg-red-50 dark:bg-red-900/20 ring-1 ring-red-300 dark:ring-red-700/50'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
v-html="renderBlock(block.content)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New side -->
|
||||||
|
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold text-green-600 dark:text-green-400 bg-green-50/60 dark:bg-green-900/10 border-b border-slate-200 dark:border-slate-700/40 flex-shrink-0">
|
||||||
|
{{ newLabel }} — added
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto px-6 py-4 space-y-2">
|
||||||
|
<template v-for="(block, idx) in blockDiff.newSide" :key="idx">
|
||||||
|
<div
|
||||||
|
v-if="block.status !== 'removed'"
|
||||||
|
:class="[
|
||||||
|
'rounded px-3 py-2 prose dark:prose-invert prose-sm max-w-none',
|
||||||
|
block.status === 'added'
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 ring-1 ring-green-300 dark:ring-green-700/50'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
v-html="renderBlock(block.content)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Side-by-side view ──────────────────────────────────────────────── -->
|
||||||
|
<div v-else class="flex-1 flex min-h-0 divide-x divide-slate-200 dark:divide-slate-700/60">
|
||||||
|
<!-- Old side -->
|
||||||
|
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold text-slate-600 dark:text-slate-300 bg-slate-100/60 dark:bg-slate-800/40 border-b border-slate-200 dark:border-slate-700/40 flex-shrink-0">
|
||||||
|
{{ oldLabel }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-auto px-6 py-4 prose dark:prose-invert prose-sm max-w-none"
|
||||||
|
v-html="oldRendered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New side -->
|
||||||
|
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold text-slate-600 dark:text-slate-300 bg-slate-100/60 dark:bg-slate-800/40 border-b border-slate-200 dark:border-slate-700/40 flex-shrink-0">
|
||||||
|
{{ newLabel }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-auto px-6 py-4 prose dark:prose-invert prose-sm max-w-none"
|
||||||
|
v-html="newRendered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
170
frontend/src/components/history/HistoryPanel.vue
Normal file
170
frontend/src/components/history/HistoryPanel.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { gql } from '@/lib/gql'
|
||||||
|
|
||||||
|
export interface CommitEntry {
|
||||||
|
hash: string
|
||||||
|
author: string
|
||||||
|
email: string
|
||||||
|
date: string
|
||||||
|
subject: string
|
||||||
|
added: number
|
||||||
|
removed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
slug: string
|
||||||
|
isOpen: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'view-version', entry: CommitEntry): void
|
||||||
|
(e: 'compare-with-latest', entry: CommitEntry, latestHash: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const history = ref<CommitEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
if (!props.slug || props.slug === 'new') return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await gql<{ history: CommitEntry[] }>(
|
||||||
|
`query History($slug: String!) {
|
||||||
|
history(slug: $slug) {
|
||||||
|
hash author email date subject added removed
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ slug: props.slug },
|
||||||
|
)
|
||||||
|
history.value = data.history ?? []
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message ?? 'Failed to load history'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.isOpen, props.slug] as const,
|
||||||
|
([open]) => {
|
||||||
|
if (open) loadHistory()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleString('sv-SE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<transition name="history-slide">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="flex flex-col w-80 flex-shrink-0 border-l border-slate-200 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-800/60 overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Panel header -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700/60 flex-shrink-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-sm font-semibold text-slate-700 dark:text-slate-200">History</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
|
||||||
|
aria-label="Close history panel"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<div v-if="loading" class="p-4 text-sm text-slate-400 animate-pulse">Loading history…</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="p-4 text-sm text-red-400">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-else-if="history.length === 0" class="p-4 text-sm text-slate-400">
|
||||||
|
No commits found for this document.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="divide-y divide-slate-200 dark:divide-slate-700/40">
|
||||||
|
<li
|
||||||
|
v-for="(entry, idx) in history"
|
||||||
|
:key="entry.hash"
|
||||||
|
class="p-4 hover:bg-white/60 dark:hover:bg-slate-700/30 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- Commit subject -->
|
||||||
|
<p class="text-sm font-medium text-slate-800 dark:text-slate-100 mb-1 leading-snug line-clamp-2">
|
||||||
|
{{ entry.subject || '(no message)' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Author + date -->
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400 mb-2">
|
||||||
|
<span class="font-medium">{{ entry.author }}</span>
|
||||||
|
· {{ formatDate(entry.date) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Line diff stats -->
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<span class="flex items-center gap-1 text-xs font-mono font-semibold text-green-600 dark:text-green-400">
|
||||||
|
<span>+{{ entry.added }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1 text-xs font-mono font-semibold text-red-500 dark:text-red-400">
|
||||||
|
<span>-{{ entry.removed }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate-400 font-mono truncate flex-1" :title="entry.hash">
|
||||||
|
{{ entry.hash.slice(0, 7) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="text-xs px-2.5 py-1 rounded bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 transition-colors"
|
||||||
|
@click="emit('view-version', entry)"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="idx > 0"
|
||||||
|
class="text-xs px-2.5 py-1 rounded bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-800/60 text-blue-700 dark:text-blue-300 transition-colors"
|
||||||
|
@click="emit('compare-with-latest', entry, history[0].hash)"
|
||||||
|
>
|
||||||
|
Compare with latest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.history-slide-enter-active,
|
||||||
|
.history-slide-leave-active {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.history-slide-enter-from,
|
||||||
|
.history-slide-leave-to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -39,7 +39,7 @@ const sidebarOpen = ref(false)
|
|||||||
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
|
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||||
|
|
||||||
<!-- Top bar (always visible on mobile, only shows breadcrumb on desktop) -->
|
<!-- Top bar (always visible on mobile, only shows breadcrumb on desktop) -->
|
||||||
<header class="flex items-center gap-2 px-4 py-3 border-b border-slate-200 dark:border-slate-700/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md">
|
<header class="relative z-40 flex items-center gap-2 px-4 py-3 border-b border-slate-200 dark:border-slate-700/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md">
|
||||||
<!-- Hamburger (mobile only) -->
|
<!-- Hamburger (mobile only) -->
|
||||||
<button
|
<button
|
||||||
class="btn-ghost p-2 -ml-2 lg:hidden"
|
class="btn-ghost p-2 -ml-2 lg:hidden"
|
||||||
|
|||||||
@@ -1,31 +1,106 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { gql } from '@/lib/gql'
|
import { gql } from '@/lib/gql'
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
interface DocMeta { slug: string; title: string }
|
interface DocMeta { slug: string; title: string }
|
||||||
|
|
||||||
const docs = ref<DocMeta[]>([])
|
interface TreeFolder {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
folders: TreeFolder[]
|
||||||
|
docs: DocMeta[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlatItem =
|
||||||
|
| { type: 'folder'; name: string; path: string; depth: number }
|
||||||
|
| { type: 'doc'; name: string; slug: string; depth: number }
|
||||||
|
|
||||||
|
const allDocs = ref<DocMeta[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const openFolders = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
function buildTree(docs: DocMeta[]): TreeFolder {
|
||||||
|
const root: TreeFolder = { name: '', path: '', folders: [], docs: [] }
|
||||||
|
for (const doc of docs) {
|
||||||
|
const parts = doc.slug.split('/')
|
||||||
|
if (parts.length === 1) {
|
||||||
|
root.docs.push(doc)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let node = root
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const folderPath = parts.slice(0, i + 1).join('/')
|
||||||
|
let child = node.folders.find(f => f.path === folderPath)
|
||||||
|
if (!child) {
|
||||||
|
child = { name: parts[i], path: folderPath, folders: [], docs: [] }
|
||||||
|
node.folders.push(child)
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
}
|
||||||
|
node.docs.push(doc)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ documents?: DocMeta[] }>(`{ documents { slug title } }`)
|
const data = await gql<{ documents?: DocMeta[] }>(`{ documents { slug title } }`)
|
||||||
docs.value = data.documents ?? []
|
allDocs.value = data.documents ?? []
|
||||||
|
// expand all folders by default
|
||||||
|
for (const doc of allDocs.value) {
|
||||||
|
const parts = doc.slug.split('/')
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
openFolders[parts.slice(0, i).join('/')] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
docs.value = []
|
allDocs.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const flatItems = computed<FlatItem[]>(() => {
|
||||||
|
const items: FlatItem[] = []
|
||||||
|
const tree = buildTree(allDocs.value)
|
||||||
|
|
||||||
|
function traverse(node: TreeFolder, depth: number) {
|
||||||
|
for (const folder of node.folders) {
|
||||||
|
items.push({ type: 'folder', name: folder.name, path: folder.path, depth })
|
||||||
|
if (openFolders[folder.path]) {
|
||||||
|
traverse(folder, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const doc of node.docs) {
|
||||||
|
const label = doc.title || doc.slug.split('/').pop() || doc.slug
|
||||||
|
items.push({ type: 'doc', name: label, slug: doc.slug, depth })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(tree, 0)
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleFolder(path: string) {
|
||||||
|
openFolders[path] = !openFolders[path]
|
||||||
|
}
|
||||||
|
|
||||||
function navigate(slug: string) {
|
function navigate(slug: string) {
|
||||||
router.push(`/doc/${slug}`)
|
router.push(`/doc/${slug}`)
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActive(slug: string) {
|
||||||
|
const current = Array.isArray(route.params.slug)
|
||||||
|
? route.params.slug.join('/')
|
||||||
|
: (route.params.slug as string)
|
||||||
|
return current === slug
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -72,23 +147,56 @@ function navigate(slug: string) {
|
|||||||
<div v-for="i in 4" :key="i" class="h-7 rounded-md bg-slate-200 dark:bg-slate-700 animate-pulse" :style="{ width: `${60 + i * 8}%` }" />
|
<div v-for="i in 4" :key="i" class="h-7 rounded-md bg-slate-200 dark:bg-slate-700 animate-pulse" :style="{ width: `${60 + i * 8}%` }" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else-if="docs.length === 0" class="text-xs text-slate-400 px-3 py-2">
|
<p v-else-if="flatItems.length === 0" class="text-xs text-slate-400 px-3 py-2">
|
||||||
No documents yet.
|
No documents yet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<template v-for="item in flatItems" :key="item.type === 'folder' ? 'f:' + item.path : 'd:' + item.slug">
|
||||||
|
<!-- Folder row -->
|
||||||
<button
|
<button
|
||||||
v-for="doc in docs"
|
v-if="item.type === 'folder'"
|
||||||
:key="doc.slug"
|
:style="{ paddingLeft: `${0.5 + item.depth * 1}rem` }"
|
||||||
|
class="w-full flex items-center gap-1.5 pr-2 py-1.5 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/60 transition"
|
||||||
|
@click="toggleFolder(item.path)"
|
||||||
|
>
|
||||||
|
<!-- chevron -->
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5 flex-shrink-0 transition-transform"
|
||||||
|
:class="openFolders[item.path] ? 'rotate-90' : ''"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<!-- folder icon -->
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="truncate font-medium">{{ item.name }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Document row -->
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
:style="{ paddingLeft: `${0.5 + item.depth * 1}rem` }"
|
||||||
:class="[
|
:class="[
|
||||||
'w-full text-left px-3 py-2 rounded-lg text-sm truncate transition',
|
'w-full flex items-center gap-1.5 pr-2 py-1.5 rounded-lg text-sm truncate transition',
|
||||||
route.params.slug === doc.slug
|
isActive(item.slug)
|
||||||
? 'bg-accent-500/10 text-accent-600 dark:text-accent-400 font-medium'
|
? 'bg-accent-500/10 text-accent-600 dark:text-accent-400 font-medium'
|
||||||
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/60'
|
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/60'
|
||||||
]"
|
]"
|
||||||
@click="navigate(doc.slug)"
|
@click="navigate(item.slug)"
|
||||||
>
|
>
|
||||||
{{ doc.title || doc.slug }}
|
<!-- doc icon -->
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 9.414V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ item.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Bottom actions -->
|
<!-- Bottom actions -->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { reactive, ref, computed } from 'vue'
|
import { reactive, ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { gql } from '@/lib/gql'
|
import { gql } from '@/lib/gql'
|
||||||
|
import { hashPassword } from '@/lib/crypto'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import ThemeToggle from '@/components/layout/ThemeToggle.vue'
|
import ThemeToggle from '@/components/layout/ThemeToggle.vue'
|
||||||
@@ -12,7 +13,7 @@ const app = useAppStore()
|
|||||||
useThemeStore() // ensures theme is initialized before ThemeToggle renders
|
useThemeStore() // ensures theme is initialized before ThemeToggle renders
|
||||||
|
|
||||||
const step = ref(1)
|
const step = ref(1)
|
||||||
const totalSteps = 3
|
const totalSteps = ref(3)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ const passwordMismatch = computed(
|
|||||||
() => adminPassConfirm.value.length > 0 && adminPassConfirm.value !== form.adminPass
|
() => adminPassConfirm.value.length > 0 && adminPassConfirm.value !== form.adminPass
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Git initial commit state (step 4)
|
||||||
|
const gitCommitMessage = ref('Initial commit')
|
||||||
|
const committing = ref(false)
|
||||||
|
const gitError = ref('')
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
storagePath: '/data/wiki',
|
storagePath: '/data/wiki',
|
||||||
adminUser: 'admin',
|
adminUser: 'admin',
|
||||||
@@ -50,7 +56,6 @@ const stepValid = computed(() => {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
async function testLdap() {
|
async function testLdap() {
|
||||||
ldapTesting.value = true
|
ldapTesting.value = true
|
||||||
ldapTestResult.value = null
|
ldapTestResult.value = null
|
||||||
@@ -77,13 +82,14 @@ async function submit() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
|
const hashedAdminPass = await hashPassword(form.adminUser, form.adminPass)
|
||||||
await gql(
|
await gql(
|
||||||
`mutation Setup($i: SetupInput!) { setup(input: $i) }`,
|
`mutation Setup($i: SetupInput!) { setup(input: $i) }`,
|
||||||
{
|
{
|
||||||
i: {
|
i: {
|
||||||
storagePath: form.storagePath,
|
storagePath: form.storagePath,
|
||||||
adminUser: form.adminUser,
|
adminUser: form.adminUser,
|
||||||
adminPass: form.adminPass,
|
adminPass: hashedAdminPass,
|
||||||
jwtSecret: form.jwtSecret,
|
jwtSecret: form.jwtSecret,
|
||||||
ldap: form.ldapEnabled ? form.ldap : null,
|
ldap: form.ldapEnabled ? form.ldap : null,
|
||||||
},
|
},
|
||||||
@@ -91,7 +97,17 @@ async function submit() {
|
|||||||
)
|
)
|
||||||
app.requiresSetup = false
|
app.requiresSetup = false
|
||||||
await app.login(form.adminUser, form.adminPass)
|
await app.login(form.adminUser, form.adminPass)
|
||||||
|
|
||||||
|
// Check if the storage path has uncommitted files.
|
||||||
|
const statusData = await gql<{ repoStatus: { hasUncommitted: boolean } }>(
|
||||||
|
`{ repoStatus { hasUncommitted } }`
|
||||||
|
)
|
||||||
|
if (statusData.repoStatus.hasUncommitted) {
|
||||||
|
totalSteps.value = 4
|
||||||
|
step.value = 4
|
||||||
|
} else {
|
||||||
router.replace('/')
|
router.replace('/')
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value = e instanceof Error ? e.message : 'Setup failed'
|
error.value = e instanceof Error ? e.message : 'Setup failed'
|
||||||
step.value = 1
|
step.value = 1
|
||||||
@@ -99,6 +115,22 @@ async function submit() {
|
|||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doInitCommit() {
|
||||||
|
gitError.value = ''
|
||||||
|
committing.value = true
|
||||||
|
try {
|
||||||
|
await gql(
|
||||||
|
`mutation InitCommit($message: String!) { initCommit(message: $message) }`,
|
||||||
|
{ message: gitCommitMessage.value || 'Initial commit' },
|
||||||
|
)
|
||||||
|
router.replace('/')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
gitError.value = e instanceof Error ? e.message : 'Commit failed'
|
||||||
|
} finally {
|
||||||
|
committing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -261,7 +293,7 @@ async function submit() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ── Step 3: Review ─────────────────────────────────────────────── -->
|
<!-- ── Step 3: Review ─────────────────────────────────────────────── -->
|
||||||
<template v-else>
|
<template v-else-if="step === 3">
|
||||||
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-1">Review & Finish</h2>
|
<h2 class="text-xl font-bold text-slate-900 dark:text-white mb-1">Review & Finish</h2>
|
||||||
<p class="text-slate-500 dark:text-slate-400 text-sm mb-6">Check your settings before completing setup.</p>
|
<p class="text-slate-500 dark:text-slate-400 text-sm mb-6">Check your settings before completing setup.</p>
|
||||||
|
|
||||||
@@ -290,10 +322,48 @@ async function submit() {
|
|||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Step 4: Git initial commit ─────────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7C5 4 4 5 4 7z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6M9 8h6M9 16h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-slate-900 dark:text-white">Initialize Git repository</h2>
|
||||||
|
<p class="text-slate-500 dark:text-slate-400 text-sm">The storage folder has files that aren't tracked yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400 mb-5">
|
||||||
|
Create an initial commit to start tracking all existing files in
|
||||||
|
<code class="font-mono text-xs bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded">{{ form.storagePath }}</code>
|
||||||
|
with Git.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Commit message</label>
|
||||||
|
<input
|
||||||
|
v-model="gitCommitMessage"
|
||||||
|
class="input"
|
||||||
|
placeholder="Initial commit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="gitError" class="mt-4 flex items-center gap-2 text-sm text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg px-3 py-2.5">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
{{ gitError }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Navigation buttons -->
|
<!-- Navigation buttons -->
|
||||||
<div class="flex items-center gap-3 mt-8">
|
<div class="flex items-center gap-3 mt-8">
|
||||||
<button
|
<button
|
||||||
v-if="step > 1"
|
v-if="step > 1 && step < 4"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
@click="step--"
|
@click="step--"
|
||||||
@@ -301,16 +371,31 @@ async function submit() {
|
|||||||
|
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
<template v-if="step === 4">
|
||||||
|
<button class="btn-secondary" @click="router.replace('/')">Skip</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary min-w-36"
|
||||||
|
:disabled="committing || !gitCommitMessage.trim()"
|
||||||
|
@click="doInitCommit"
|
||||||
|
>
|
||||||
|
<svg v-if="committing" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
||||||
|
</svg>
|
||||||
|
{{ committing ? 'Committing…' : 'Commit & finish' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="step < totalSteps">
|
||||||
<button
|
<button
|
||||||
v-if="step < totalSteps"
|
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
:disabled="!stepValid"
|
:disabled="!stepValid"
|
||||||
@click="step++"
|
@click="step++"
|
||||||
>
|
>
|
||||||
Continue →
|
Continue →
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
v-else
|
|
||||||
class="btn-primary min-w-32"
|
class="btn-primary min-w-32"
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
@click="submit"
|
@click="submit"
|
||||||
@@ -321,6 +406,7 @@ async function submit() {
|
|||||||
</svg>
|
</svg>
|
||||||
{{ submitting ? 'Setting up…' : 'Finish setup' }}
|
{{ submitting ? 'Setting up…' : 'Finish setup' }}
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
14
frontend/src/lib/crypto.ts
Normal file
14
frontend/src/lib/crypto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Derives a deterministic hex digest from username + password using SHA-256.
|
||||||
|
* The username is used as a domain-separator so the same password produces
|
||||||
|
* a different hash for every account. The plaintext password never leaves
|
||||||
|
* the browser.
|
||||||
|
*/
|
||||||
|
export async function hashPassword(username: string, password: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(`${username.toLowerCase()}:${password}`)
|
||||||
|
const buffer = await crypto.subtle.digest('SHA-256', data)
|
||||||
|
return Array.from(new Uint8Array(buffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { gql } from '@/lib/gql'
|
import { gql } from '@/lib/gql'
|
||||||
|
import { hashPassword } from '@/lib/crypto'
|
||||||
|
|
||||||
export const useAppStore = defineStore('app', () => {
|
export const useAppStore = defineStore('app', () => {
|
||||||
const requiresSetup = ref(false)
|
const requiresSetup = ref(false)
|
||||||
@@ -17,13 +18,14 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function login(user: string, password: string) {
|
async function login(user: string, password: string) {
|
||||||
|
const hashed = await hashPassword(user, password)
|
||||||
const data = await gql<{ login: string }>(
|
const data = await gql<{ login: string }>(
|
||||||
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`,
|
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`,
|
||||||
{ u: user, p: password },
|
{ u: user, p: hashed },
|
||||||
)
|
)
|
||||||
token.value = data.login
|
token.value = data.login
|
||||||
username.value = user
|
username.value = user
|
||||||
localStorage.setItem('token', data.login); console.log('[login] token is:', data.login, localStorage.getItem('token'))
|
localStorage.setItem('token', data.login)
|
||||||
localStorage.setItem('username', user)
|
localStorage.setItem('username', user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ const newPass = ref('')
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const status = ref({ msg: '', isError: false })
|
const status = ref({ msg: '', isError: false })
|
||||||
|
|
||||||
|
// Git initial commit prompt state
|
||||||
|
const showGitPrompt = ref(false)
|
||||||
|
const gitCommitMessage = ref('Initial commit')
|
||||||
|
const gitCommitting = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ config: any }>(`{ config { storagePath ldap { host port baseDN bindDN } } }`)
|
const data = await gql<{ config: any }>(`{ config { storagePath ldap { host port baseDN bindDN } } }`)
|
||||||
@@ -44,12 +49,35 @@ function showStatus(msg: string, isError: boolean = false) {
|
|||||||
async function saveStorage() {
|
async function saveStorage() {
|
||||||
try {
|
try {
|
||||||
await gql(`mutation UpdateStorage($path: String!) { updateStoragePath(path: $path) }`, { path: storagePath.value })
|
await gql(`mutation UpdateStorage($path: String!) { updateStoragePath(path: $path) }`, { path: storagePath.value })
|
||||||
|
// Check if the new storage path has uncommitted files.
|
||||||
|
const data = await gql<{ repoStatus: { hasUncommitted: boolean } }>(`{ repoStatus { hasUncommitted } }`)
|
||||||
|
if (data.repoStatus.hasUncommitted) {
|
||||||
|
showGitPrompt.value = true
|
||||||
|
gitCommitMessage.value = 'Initial commit'
|
||||||
|
} else {
|
||||||
showStatus('Storage path updated.')
|
showStatus('Storage path updated.')
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showStatus(err.message, true)
|
showStatus(err.message, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doInitCommit() {
|
||||||
|
gitCommitting.value = true
|
||||||
|
try {
|
||||||
|
await gql(
|
||||||
|
`mutation InitCommit($message: String!) { initCommit(message: $message) }`,
|
||||||
|
{ message: gitCommitMessage.value || 'Initial commit' },
|
||||||
|
)
|
||||||
|
showGitPrompt.value = false
|
||||||
|
showStatus('Storage path updated and initial commit created.')
|
||||||
|
} catch (err: any) {
|
||||||
|
showStatus(err.message, true)
|
||||||
|
} finally {
|
||||||
|
gitCommitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveLdap() {
|
async function saveLdap() {
|
||||||
try {
|
try {
|
||||||
let input = null
|
let input = null
|
||||||
@@ -113,6 +141,50 @@ async function savePassword() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Git initial commit prompt (shown after storage path change if repo has uncommitted files) -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-y-2"
|
||||||
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 -translate-y-2"
|
||||||
|
>
|
||||||
|
<section v-if="showGitPrompt" class="card p-6 border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10">
|
||||||
|
<div class="flex items-start gap-3 mb-4">
|
||||||
|
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-slate-900 dark:text-white">Untracked files found</h3>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
The storage folder contains files that are not yet tracked by Git. Create an initial commit to start version-tracking them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="label">Commit message</label>
|
||||||
|
<input v-model="gitCommitMessage" class="input" placeholder="Initial commit" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button class="btn-secondary" @click="showGitPrompt = false; showStatus('Storage path updated.')">Skip</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="gitCommitting || !gitCommitMessage.trim()"
|
||||||
|
@click="doInitCommit"
|
||||||
|
>
|
||||||
|
<svg v-if="gitCommitting" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
||||||
|
</svg>
|
||||||
|
{{ gitCommitting ? 'Committing…' : 'Commit files' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<!-- Admin Account -->
|
<!-- Admin Account -->
|
||||||
<section class="card p-6">
|
<section class="card p-6">
|
||||||
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Admin Account</h2>
|
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Admin Account</h2>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { gql } from '@/lib/gql'
|
import { gql } from '@/lib/gql'
|
||||||
import VisualEditor from '@/components/editor/VisualEditor.vue'
|
import VisualEditor from '@/components/editor/VisualEditor.vue'
|
||||||
import SourceEditor from '@/components/editor/SourceEditor.vue'
|
import SourceEditor from '@/components/editor/SourceEditor.vue'
|
||||||
|
import HistoryPanel from '@/components/history/HistoryPanel.vue'
|
||||||
|
import DiffViewer from '@/components/history/DiffViewer.vue'
|
||||||
|
import type { CommitEntry } from '@/components/history/HistoryPanel.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const slug = computed(() => route.params.slug as string)
|
const slug = computed(() => {
|
||||||
|
const s = route.params.slug
|
||||||
|
return Array.isArray(s) ? s.join('/') : (s as string)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Normal editing state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const docPath = ref('')
|
const docPath = ref('')
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
@@ -16,24 +24,36 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const commitMsg = ref('Update document')
|
const commitMsg = ref('Update document')
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadDocument(s: string) {
|
||||||
if (slug.value === 'new') {
|
// Leave any special mode when navigating to a different doc
|
||||||
loading.value = false
|
historyPanelOpen.value = false
|
||||||
|
exitVersionView()
|
||||||
|
exitDiffView()
|
||||||
|
|
||||||
|
if (s === 'new') {
|
||||||
|
content.value = ''
|
||||||
|
docPath.value = ''
|
||||||
commitMsg.value = 'Initial commit'
|
commitMsg.value = 'Initial commit'
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
docPath.value = slug.value
|
loading.value = true
|
||||||
|
docPath.value = s
|
||||||
|
commitMsg.value = 'Update document'
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ document: { content: string } }>(
|
const data = await gql<{ document: { content: string } }>(
|
||||||
`query Doc($s: String!) { document(slug: $s) { content } }`,
|
`query Doc($s: String!) { document(slug: $s) { content } }`,
|
||||||
{ s: slug.value },
|
{ s },
|
||||||
)
|
)
|
||||||
content.value = data.document?.content ?? ''
|
content.value = data.document?.content ?? ''
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadDocument(slug.value))
|
||||||
|
watch(slug, (newSlug) => loadDocument(newSlug))
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const targetSlug = slug.value === 'new' ? docPath.value : slug.value
|
const targetSlug = slug.value === 'new' ? docPath.value : slug.value
|
||||||
@@ -41,7 +61,6 @@ async function save() {
|
|||||||
alert('Please enter a document name.')
|
alert('Please enter a document name.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ saveDocument: { slug: string } }>(
|
const res = await gql<{ saveDocument: { slug: string } }>(
|
||||||
@@ -55,47 +74,269 @@ async function save() {
|
|||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── History panel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const historyPanelOpen = ref(false)
|
||||||
|
|
||||||
|
function toggleHistory() {
|
||||||
|
historyPanelOpen.value = !historyPanelOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Historical version viewer ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const viewingVersion = ref<CommitEntry | null>(null)
|
||||||
|
const versionContent = ref('')
|
||||||
|
const versionLoading = ref(false)
|
||||||
|
const versionEditorMode = ref<'visual' | 'source'>('visual')
|
||||||
|
|
||||||
|
async function onViewVersion(entry: CommitEntry) {
|
||||||
|
exitDiffView()
|
||||||
|
viewingVersion.value = entry
|
||||||
|
versionLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await gql<{ documentAtCommit: string }>(
|
||||||
|
`query DocAt($slug: String!, $hash: String!) { documentAtCommit(slug: $slug, hash: $hash) }`,
|
||||||
|
{ slug: slug.value, hash: entry.hash },
|
||||||
|
)
|
||||||
|
versionContent.value = data.documentAtCommit ?? ''
|
||||||
|
} finally {
|
||||||
|
versionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitVersionView() {
|
||||||
|
viewingVersion.value = null
|
||||||
|
versionContent.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diff / compare view ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const diffActive = ref(false)
|
||||||
|
const diffOldContent = ref('')
|
||||||
|
const diffNewContent = ref('')
|
||||||
|
const diffUnified = ref('')
|
||||||
|
const diffOldLabel = ref('')
|
||||||
|
const diffNewLabel = ref('')
|
||||||
|
const diffLoading = ref(false)
|
||||||
|
|
||||||
|
/** Called from HistoryPanel: compare `entry` hash against the latest (history[0]) */
|
||||||
|
async function onCompareWithLatest(entry: CommitEntry, latestHash: string) {
|
||||||
|
exitVersionView()
|
||||||
|
diffLoading.value = true
|
||||||
|
try {
|
||||||
|
const [atOld, atNew, diffRes] = await Promise.all([
|
||||||
|
gql<{ documentAtCommit: string }>(
|
||||||
|
`query DocAt($slug: String!, $hash: String!) { documentAtCommit(slug: $slug, hash: $hash) }`,
|
||||||
|
{ slug: slug.value, hash: entry.hash },
|
||||||
|
),
|
||||||
|
gql<{ documentAtCommit: string }>(
|
||||||
|
`query DocAt($slug: String!, $hash: String!) { documentAtCommit(slug: $slug, hash: $hash) }`,
|
||||||
|
{ slug: slug.value, hash: latestHash },
|
||||||
|
),
|
||||||
|
gql<{ diff: string }>(
|
||||||
|
`query Diff($slug: String!, $fromHash: String!, $toHash: String!) { diff(slug: $slug, fromHash: $fromHash, toHash: $toHash) }`,
|
||||||
|
{ slug: slug.value, fromHash: entry.hash, toHash: latestHash },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
diffOldContent.value = atOld.documentAtCommit ?? ''
|
||||||
|
diffNewContent.value = atNew.documentAtCommit ?? ''
|
||||||
|
diffUnified.value = diffRes.diff ?? ''
|
||||||
|
diffOldLabel.value = entry.hash.slice(0, 7)
|
||||||
|
diffNewLabel.value = latestHash.slice(0, 7) + ' (latest)'
|
||||||
|
diffActive.value = true
|
||||||
|
} finally {
|
||||||
|
diffLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from version viewer's own "Compare with latest" button */
|
||||||
|
async function compareVersionWithLatest() {
|
||||||
|
if (!viewingVersion.value) return
|
||||||
|
// We need the latest hash — re-fetch history to get it
|
||||||
|
const histData = await gql<{ history: CommitEntry[] }>(
|
||||||
|
`query History($slug: String!) { history(slug: $slug) { hash author email date subject added removed } }`,
|
||||||
|
{ slug: slug.value },
|
||||||
|
)
|
||||||
|
const entries = histData.history ?? []
|
||||||
|
if (entries.length === 0) return
|
||||||
|
await onCompareWithLatest(viewingVersion.value, entries[0].hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitDiffView() {
|
||||||
|
diffActive.value = false
|
||||||
|
diffOldContent.value = ''
|
||||||
|
diffNewContent.value = ''
|
||||||
|
diffUnified.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('sv-SE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex h-full overflow-hidden">
|
||||||
|
|
||||||
|
<!-- ── Main column (editor + toolbar) ─────────────────────────────────── -->
|
||||||
|
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="flex items-center gap-2 p-3 border-b border-slate-700 bg-slate-100 dark:bg-slate-800 flex-wrap">
|
<div
|
||||||
|
class="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-800/60 flex-shrink-0 flex-wrap"
|
||||||
|
>
|
||||||
|
<!-- Editor mode tabs (hidden when viewing old version or diff) -->
|
||||||
|
<template v-if="!viewingVersion && !diffActive">
|
||||||
<button
|
<button
|
||||||
:class="['px-3 py-1 rounded text-sm', editorMode === 'visual' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700']"
|
:class="['px-3 py-1 rounded text-sm', editorMode === 'visual' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200']"
|
||||||
@click="editorMode = 'visual'"
|
@click="editorMode = 'visual'"
|
||||||
>Visual</button>
|
>Visual</button>
|
||||||
<button
|
<button
|
||||||
:class="['px-3 py-1 rounded text-sm', editorMode === 'source' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700']"
|
:class="['px-3 py-1 rounded text-sm', editorMode === 'source' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200']"
|
||||||
@click="editorMode = 'source'"
|
@click="editorMode = 'source'"
|
||||||
>Source</button>
|
>Source</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Version view mode tabs -->
|
||||||
|
<template v-else-if="viewingVersion && !diffActive">
|
||||||
|
<button
|
||||||
|
:class="['px-3 py-1 rounded text-sm', versionEditorMode === 'visual' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200']"
|
||||||
|
@click="versionEditorMode = 'visual'"
|
||||||
|
>Visual</button>
|
||||||
|
<button
|
||||||
|
:class="['px-3 py-1 rounded text-sm', versionEditorMode === 'source' ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-900 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200']"
|
||||||
|
@click="versionEditorMode = 'source'"
|
||||||
|
>Source</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
<!-- "Loading diff" spinner -->
|
||||||
|
<span v-if="diffLoading" class="text-sm text-slate-400 animate-pulse">Loading diff…</span>
|
||||||
|
|
||||||
|
<!-- New doc path input -->
|
||||||
<input
|
<input
|
||||||
v-if="slug === 'new'"
|
v-if="slug === 'new' && !viewingVersion && !diffActive"
|
||||||
v-model="docPath"
|
v-model="docPath"
|
||||||
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-48 text-slate-900 dark:text-slate-100"
|
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-48 text-slate-900 dark:text-slate-100"
|
||||||
placeholder="New filename (e.g. folder/doc)"
|
placeholder="New filename (e.g. folder/doc)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Save controls (normal editing only) -->
|
||||||
|
<template v-if="!viewingVersion && !diffActive">
|
||||||
<input
|
<input
|
||||||
v-model="commitMsg"
|
v-model="commitMsg"
|
||||||
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-64 text-slate-900 dark:text-slate-100"
|
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-64 text-slate-900 dark:text-slate-100"
|
||||||
placeholder="Commit message"
|
placeholder="Commit message"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-1 bg-green-700 hover:bg-green-600 rounded text-sm disabled:opacity-50"
|
class="px-4 py-1 bg-green-700 hover:bg-green-600 text-white rounded text-sm disabled:opacity-50"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
@click="save"
|
@click="save"
|
||||||
>{{ saving ? 'Saving…' : 'Save' }}</button>
|
>{{ saving ? 'Saving…' : 'Save' }}</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- History toggle button (not shown for new docs or in diff view) -->
|
||||||
|
<button
|
||||||
|
v-if="slug !== 'new'"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-1.5 px-3 py-1 rounded text-sm transition-colors',
|
||||||
|
historyPanelOpen
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200',
|
||||||
|
]"
|
||||||
|
title="Toggle history panel"
|
||||||
|
@click="toggleHistory"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Editor -->
|
<!-- ── Historical version banner ─────────────────────────────────── -->
|
||||||
<div class="flex-1 overflow-auto">
|
<div
|
||||||
|
v-if="viewingVersion && !diffActive"
|
||||||
|
class="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-700/40 flex-shrink-0 flex-wrap text-sm"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-amber-800 dark:text-amber-200 font-medium">
|
||||||
|
Viewing version
|
||||||
|
<span class="font-mono text-xs bg-amber-100 dark:bg-amber-800/60 px-1.5 py-0.5 rounded ml-1">{{ viewingVersion.hash.slice(0, 7) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-300 text-xs">
|
||||||
|
{{ viewingVersion.author }} · {{ formatDate(viewingVersion.date) }}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 rounded text-xs bg-blue-100 dark:bg-blue-900/40 hover:bg-blue-200 dark:hover:bg-blue-800/60 text-blue-700 dark:text-blue-300 transition-colors"
|
||||||
|
@click="compareVersionWithLatest"
|
||||||
|
>Compare with latest</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 rounded text-xs bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 transition-colors"
|
||||||
|
@click="exitVersionView"
|
||||||
|
>← Back to current</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Editor / Viewer area ──────────────────────────────────────── -->
|
||||||
|
<div class="flex-1 overflow-auto min-h-0">
|
||||||
|
|
||||||
|
<!-- Diff view (full area) -->
|
||||||
|
<DiffViewer
|
||||||
|
v-if="diffActive"
|
||||||
|
:unified-diff="diffUnified"
|
||||||
|
:old-content="diffOldContent"
|
||||||
|
:new-content="diffNewContent"
|
||||||
|
:old-label="diffOldLabel"
|
||||||
|
:new-label="diffNewLabel"
|
||||||
|
class="h-full"
|
||||||
|
@close="exitDiffView"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Historical version viewer (read-only) -->
|
||||||
|
<template v-else-if="viewingVersion">
|
||||||
|
<div v-if="versionLoading" class="p-6 text-slate-400 animate-pulse">Loading version…</div>
|
||||||
|
<VisualEditor
|
||||||
|
v-else-if="versionEditorMode === 'visual'"
|
||||||
|
:model-value="versionContent"
|
||||||
|
@update:model-value="() => {}"
|
||||||
|
/>
|
||||||
|
<SourceEditor
|
||||||
|
v-else
|
||||||
|
:model-value="versionContent"
|
||||||
|
@update:model-value="() => {}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Normal editor -->
|
||||||
|
<template v-else>
|
||||||
<div v-if="loading" class="p-6 text-slate-400 animate-pulse">Loading…</div>
|
<div v-if="loading" class="p-6 text-slate-400 animate-pulse">Loading…</div>
|
||||||
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" />
|
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" />
|
||||||
<SourceEditor v-else v-model="content" />
|
<SourceEditor v-else v-model="content" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Right panel: History ────────────────────────────────────────────── -->
|
||||||
|
<HistoryPanel
|
||||||
|
:slug="slug"
|
||||||
|
:is-open="historyPanelOpen"
|
||||||
|
@close="historyPanelOpen = false"
|
||||||
|
@view-version="onViewVersion"
|
||||||
|
@compare-with-latest="onCompareWithLatest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user