feat: Archivum skeleton — Go/GraphQL backend + Vue 3 PWA frontend
Full project scaffold: multi-stage Dockerfile (ARM64/AMD64), AsciiDoc↔TipTap bridge, Setup Wizard, CodeMirror source editor, Git-backed storage layer, LDAP+JWT auth skeleton, Tailwind mobile-first layout, and VS Code build/push tasks targeting registry at 192.168.0.19:5000. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Go
|
||||||
|
backend/vendor/
|
||||||
|
backend/*.exe
|
||||||
|
backend/*.test
|
||||||
|
backend/archivum
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.env
|
||||||
|
frontend/.env.local
|
||||||
|
|
||||||
|
# Docker local data
|
||||||
|
docker/data/
|
||||||
|
|
||||||
|
# Generated GraphQL code
|
||||||
|
backend/internal/graph/generated.go
|
||||||
|
backend/internal/graph/models_gen.go
|
||||||
|
|
||||||
|
# Config (never commit secrets)
|
||||||
|
config.json
|
||||||
|
settings.json
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
157
.vscode/tasks.json
vendored
Normal file
157
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
|
||||||
|
// ── Docker: Build ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"label": "Docker: Build (Windows)",
|
||||||
|
"type": "shell",
|
||||||
|
"windows": {
|
||||||
|
"command": "docker build -t 192.168.0.19:5000/archivum:latest -f docker/Dockerfile ."
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Docker: Build (Linux)",
|
||||||
|
"type": "shell",
|
||||||
|
"linux": {
|
||||||
|
"command": "docker build -t 192.168.0.19:5000/archivum:latest -f docker/Dockerfile ."
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Docker: Push ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"label": "Docker: Push (Windows)",
|
||||||
|
"type": "shell",
|
||||||
|
"windows": {
|
||||||
|
"command": "docker push 192.168.0.19:5000/archivum:latest"
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Docker: Push (Linux)",
|
||||||
|
"type": "shell",
|
||||||
|
"linux": {
|
||||||
|
"command": "docker push 192.168.0.19:5000/archivum:latest"
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Docker: Build & Push ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"label": "Docker: Build & Push (Windows)",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"dependsOn": ["Docker: Build (Windows)", "Docker: Push (Windows)"],
|
||||||
|
"group": { "kind": "build", "isDefault": true },
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Docker: Build & Push (Linux)",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"dependsOn": ["Docker: Build (Linux)", "Docker: Push (Linux)"],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Docker: Multi-arch Build & Push (buildx) ───────────────────────────────
|
||||||
|
{
|
||||||
|
"label": "Docker: Multi-arch Build & Push (Windows)",
|
||||||
|
"type": "shell",
|
||||||
|
"windows": {
|
||||||
|
"command": "docker buildx build --platform linux/amd64,linux/arm64 -t 192.168.0.19:5000/archivum:latest -f docker/Dockerfile --push ."
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Docker: Multi-arch Build & Push (Linux)",
|
||||||
|
"type": "shell",
|
||||||
|
"linux": {
|
||||||
|
"command": "docker buildx build --platform linux/amd64,linux/arm64 -t 192.168.0.19:5000/archivum:latest -f docker/Dockerfile --push ."
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Backend: Run ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"label": "Backend: Run",
|
||||||
|
"type": "shell",
|
||||||
|
"windows": {
|
||||||
|
"command": "go run .\\cmd\\server",
|
||||||
|
"options": { "cwd": "${workspaceFolder}\\backend" }
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"command": "go run ./cmd/server",
|
||||||
|
"options": { "cwd": "${workspaceFolder}/backend" }
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||||
|
"problemMatcher": {
|
||||||
|
"pattern": { "regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)", "file": 1, "line": 2, "column": 3, "message": 4 },
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": "^Archivum listening",
|
||||||
|
"endsPattern": "^Archivum listening"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Frontend: Dev ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"label": "Frontend: Dev",
|
||||||
|
"type": "shell",
|
||||||
|
"windows": {
|
||||||
|
"command": "npm run dev",
|
||||||
|
"options": { "cwd": "${workspaceFolder}\\frontend" }
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"command": "npm run dev",
|
||||||
|
"options": { "cwd": "${workspaceFolder}/frontend" }
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||||
|
"problemMatcher": {
|
||||||
|
"pattern": { "regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)", "file": 1, "line": 2, "column": 3, "message": 4 },
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": "VITE",
|
||||||
|
"endsPattern": "Local:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Frontend: Build ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"label": "Frontend: Build",
|
||||||
|
"type": "shell",
|
||||||
|
"windows": {
|
||||||
|
"command": "npm run build",
|
||||||
|
"options": { "cwd": "${workspaceFolder}\\frontend" }
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"command": "npm run build",
|
||||||
|
"options": { "cwd": "${workspaceFolder}/frontend" }
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"problemMatcher": ["$tsc"]
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
131
README.md
131
README.md
@@ -1,3 +1,132 @@
|
|||||||
# Archivum
|
# Archivum
|
||||||
|
|
||||||
Wiki with Git and direkt file as the truth
|
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.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Go + GraphQL (gqlgen) + SQLite (modernc.org/sqlite, CGO-free) |
|
||||||
|
| Frontend | Vue 3 (Composition API) + Vite + Tailwind CSS |
|
||||||
|
| PWA | vite-plugin-pwa (offline caching, installable) |
|
||||||
|
| Version Control | Git (backend executes git on the storage path) |
|
||||||
|
| Editor | TipTap (visual) + CodeMirror (raw AsciiDoc) |
|
||||||
|
| Auth | LDAP + JWT with in-memory session tracking |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **AsciiDoc-native** — documents stored as plain `.adoc` files, Git is the source of truth.
|
||||||
|
- **Visual ↔ Source editor** — TipTap for rich editing, CodeMirror for raw AsciiDoc, with a round-trip TypeScript bridge.
|
||||||
|
- **Git history & diff** — every save is a commit; full history and unified diffs via GraphQL.
|
||||||
|
- **Setup Wizard** — guided first-run configuration for LDAP, admin user, and storage paths.
|
||||||
|
- **PWA** — installable on mobile, fast loading, offline viewing of cached documents.
|
||||||
|
- **Multi-arch Docker** — single Dockerfile targeting ARM64 and AMD64.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Archivum/
|
||||||
|
├── backend/ # Go application
|
||||||
|
│ ├── cmd/server/ # Entry point
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── auth/ # LDAP + JWT
|
||||||
|
│ ├── config/ # config.json management
|
||||||
|
│ ├── git/ # Git operations
|
||||||
|
│ ├── graph/ # GraphQL schema + resolvers
|
||||||
|
│ └── storage/ # Document read/write
|
||||||
|
├── frontend/ # Vue 3 application
|
||||||
|
│ └── src/
|
||||||
|
│ ├── bridge/ # AsciiDoc ↔ TipTap TypeScript bridge
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── editor/ # VisualEditor + SourceEditor
|
||||||
|
│ │ ├── layout/ # AppLayout + Sidebar
|
||||||
|
│ │ └── wizard/ # Setup Wizard
|
||||||
|
│ ├── router/
|
||||||
|
│ ├── stores/
|
||||||
|
│ └── views/
|
||||||
|
├── docker/ # Dockerfile + docker-compose
|
||||||
|
└── .vscode/ # VS Code build & deploy tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker (with access to your registry)
|
||||||
|
- Go 1.22+ (for local backend development)
|
||||||
|
- Node.js 20+ (for local frontend development)
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080` and follow the Setup Wizard.
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
go run ./cmd/server
|
||||||
|
|
||||||
|
# Frontend (separate terminal)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Backend — `config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"storage_path": "/data/wiki",
|
||||||
|
"db_path": "/data/archivum.db",
|
||||||
|
"ldap": {
|
||||||
|
"host": "ldap.example.com",
|
||||||
|
"port": 389,
|
||||||
|
"base_dn": "dc=example,dc=com",
|
||||||
|
"bind_dn": "cn=reader,dc=example,dc=com",
|
||||||
|
"bind_password": "secret"
|
||||||
|
},
|
||||||
|
"jwt_secret": "change-me",
|
||||||
|
"listen_addr": ":4000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend — `settings.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_url": "http://localhost:4000/graphql",
|
||||||
|
"app_name": "Archivum",
|
||||||
|
"default_theme": "light"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DOCKER_PATH` | `/config` | Base path for config and data volumes |
|
||||||
|
| `PUID` | `1000` | File system user ID |
|
||||||
|
| `PGID` | `1000` | File system group ID |
|
||||||
|
|
||||||
|
## VS Code Tasks
|
||||||
|
|
||||||
|
Open the Command Palette (`Ctrl+Shift+P`) → **Tasks: Run Task**:
|
||||||
|
|
||||||
|
| Task | Platform | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `Docker: Build` | Windows / Linux | Build multi-arch image |
|
||||||
|
| `Docker: Push` | Windows / Linux | Push to `192.168.0.19:5000` |
|
||||||
|
| `Docker: Build & Push` | Windows / Linux | Build then push |
|
||||||
|
| `Backend: Run` | Both | `go run ./cmd/server` |
|
||||||
|
| `Frontend: Dev` | Both | `npm run dev` |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
31
backend/cmd/server/main.go
Normal file
31
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/brasse-b/archivum/internal/config"
|
||||||
|
"github.com/brasse-b/archivum/internal/graph"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load(configPath())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := graph.NewServer(cfg)
|
||||||
|
|
||||||
|
log.Printf("Archivum listening on %s", cfg.ListenAddr)
|
||||||
|
if err := http.ListenAndServe(cfg.ListenAddr, srv); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPath() string {
|
||||||
|
if p := os.Getenv("DOCKER_PATH"); p != "" {
|
||||||
|
return p + "/config/config.json"
|
||||||
|
}
|
||||||
|
return "config.json"
|
||||||
|
}
|
||||||
8
backend/go.mod
Normal file
8
backend/go.mod
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module github.com/brasse-b/archivum
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.8
|
||||||
|
modernc.org/sqlite v1.30.0
|
||||||
|
)
|
||||||
111
backend/internal/auth/auth.go
Normal file
111
backend/internal/auth/auth.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/brasse-b/archivum/internal/config"
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session represents an authenticated user session.
|
||||||
|
type Session struct {
|
||||||
|
Username string
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager handles LDAP authentication and in-memory session tracking.
|
||||||
|
type Manager struct {
|
||||||
|
cfg *config.Config
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(cfg *config.Config) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
cfg: cfg,
|
||||||
|
sessions: make(map[string]*Session),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates against LDAP and returns a bearer token on success.
|
||||||
|
func (m *Manager) Login(username, password string) (string, error) {
|
||||||
|
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", m.cfg.LDAP.Host, m.cfg.LDAP.Port))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
|
||||||
|
if err := l.Bind(m.cfg.LDAP.BindDN, m.cfg.LDAP.BindPassword); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sr, err := l.Search(&ldap.SearchRequest{
|
||||||
|
BaseDN: m.cfg.LDAP.BaseDN,
|
||||||
|
Filter: fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)),
|
||||||
|
Scope: ldap.ScopeWholeSubtree,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(sr.Entries) != 1 {
|
||||||
|
return "", errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userDN := sr.Entries[0].DN
|
||||||
|
if err := l.Bind(userDN, password); err != nil {
|
||||||
|
return "", errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.sessions[token] = &Session{
|
||||||
|
Username: username,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks a bearer token and returns the associated session.
|
||||||
|
func (m *Manager) Validate(token string) (*Session, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
s, ok := m.sessions[token]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
if time.Now().After(s.ExpiresAt) {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.sessions, token)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil, errors.New("token expired")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout removes a session.
|
||||||
|
func (m *Manager) Logout(token string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.sessions, token)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
61
backend/internal/config/config.go
Normal file
61
backend/internal/config/config.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all backend runtime configuration.
|
||||||
|
type Config struct {
|
||||||
|
StoragePath string `json:"storage_path"`
|
||||||
|
DBPath string `json:"db_path"`
|
||||||
|
LDAP LDAPConfig `json:"ldap"`
|
||||||
|
JWTSecret string `json:"jwt_secret"`
|
||||||
|
ListenAddr string `json:"listen_addr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LDAPConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
BaseDN string `json:"base_dn"`
|
||||||
|
BindDN string `json:"bind_dn"`
|
||||||
|
BindPassword string `json:"bind_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrRequireSetup is returned when config is missing or empty,
|
||||||
|
// signalling that the frontend should start the Setup Wizard.
|
||||||
|
var ErrRequireSetup = errors.New("REQUIRE_SETUP")
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, ErrRequireSetup
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.StoragePath == "" || cfg.JWTSecret == "" {
|
||||||
|
return nil, ErrRequireSetup
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ListenAddr == "" {
|
||||||
|
cfg.ListenAddr = ":4000"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(path string, cfg *Config) error {
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
104
backend/internal/git/git.go
Normal file
104
backend/internal/git/git.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repo wraps a bare directory that is a Git repository.
|
||||||
|
type Repo struct {
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open returns a Repo for an existing directory, initialising Git if needed.
|
||||||
|
func Open(root string) (*Repo, error) {
|
||||||
|
r := &Repo{root: root}
|
||||||
|
if err := r.initIfNeeded(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) initIfNeeded() error {
|
||||||
|
out, _ := r.run("rev-parse", "--is-inside-work-tree")
|
||||||
|
if strings.TrimSpace(out) == "true" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.run("init")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit writes data to path and creates a Git commit authored by author.
|
||||||
|
func (r *Repo) Commit(path, content, message, author, email string) error {
|
||||||
|
full := filepath.Join(r.root, path)
|
||||||
|
if err := writeFile(full, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := r.run("add", path); 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log returns the commit history for a file.
|
||||||
|
func (r *Repo) Log(path string) ([]LogEntry, error) {
|
||||||
|
out, err := r.run("log", "--format=%H|%an|%ae|%ai|%s", "--", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var entries []LogEntry
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "|", 5)
|
||||||
|
if len(parts) == 5 {
|
||||||
|
entries = append(entries, LogEntry{
|
||||||
|
Hash: parts[0],
|
||||||
|
Author: parts[1],
|
||||||
|
Email: parts[2],
|
||||||
|
Date: parts[3],
|
||||||
|
Subject: parts[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff returns the unified diff between two commits for a file.
|
||||||
|
func (r *Repo) Diff(path, fromHash, toHash string) (string, error) {
|
||||||
|
out, err := r.run("diff", fromHash, toHash, "--", path)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show returns the file content at a specific commit.
|
||||||
|
func (r *Repo) Show(hash, path string) (string, error) {
|
||||||
|
return r.run("show", fmt.Sprintf("%s:%s", hash, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Hash string
|
||||||
|
Author string
|
||||||
|
Email string
|
||||||
|
Date string
|
||||||
|
Subject string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) run(args ...string) (string, error) {
|
||||||
|
cmd := exec.Command("git", append([]string{"-C", r.root}, args...)...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("git %v: %w — %s", args, err, stderr.String())
|
||||||
|
}
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
16
backend/internal/graph/resolver.go
Normal file
16
backend/internal/graph/resolver.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// Resolver is the root resolver — all query/mutation methods live here.
|
||||||
|
// Fields are populated by NewServer via dependency injection.
|
||||||
|
type Resolver struct {
|
||||||
|
cfg interface{} // *config.Config — placeholder until gqlgen generation
|
||||||
|
auth interface{} // *auth.Manager
|
||||||
|
storage interface{} // *storage.Store
|
||||||
|
git interface{} // *git.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Run `go generate ./...` after adding gqlgen to go.mod to generate
|
||||||
|
// the type-safe resolver stubs from schema.graphql.
|
||||||
|
//
|
||||||
|
// The generated code lands in graph/generated.go (gitignored for now).
|
||||||
|
// Implement each method on *Resolver once the stubs exist.
|
||||||
87
backend/internal/graph/schema.graphql
Normal file
87
backend/internal/graph/schema.graphql
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
type Query {
|
||||||
|
# Returns REQUIRE_SETUP if config is missing.
|
||||||
|
systemStatus: SystemStatus!
|
||||||
|
|
||||||
|
# Fetch a document by its slug path.
|
||||||
|
document(slug: String!): Document
|
||||||
|
|
||||||
|
# List documents under an optional path prefix.
|
||||||
|
documents(prefix: String): [DocumentMeta!]!
|
||||||
|
|
||||||
|
# Commit history for a document.
|
||||||
|
history(slug: String!): [CommitEntry!]!
|
||||||
|
|
||||||
|
# Unified diff between two commits for a document.
|
||||||
|
diff(slug: String!, fromHash: String!, toHash: String!): String!
|
||||||
|
|
||||||
|
# Raw content of a document at a specific commit.
|
||||||
|
documentAtCommit(slug: String!, hash: String!): String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
# First-run setup.
|
||||||
|
setup(input: SetupInput!): Boolean!
|
||||||
|
|
||||||
|
# Authenticate and receive a bearer token.
|
||||||
|
login(username: String!, password: String!): String!
|
||||||
|
|
||||||
|
# Invalidate the current session.
|
||||||
|
logout: Boolean!
|
||||||
|
|
||||||
|
# Save (create or update) a document.
|
||||||
|
saveDocument(input: SaveDocumentInput!): Document!
|
||||||
|
|
||||||
|
# Delete a document.
|
||||||
|
deleteDocument(slug: String!): Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum SystemStatus {
|
||||||
|
OK
|
||||||
|
REQUIRE_SETUP
|
||||||
|
}
|
||||||
|
|
||||||
|
type Document {
|
||||||
|
slug: String!
|
||||||
|
content: String!
|
||||||
|
meta: DocumentMeta!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentMeta {
|
||||||
|
slug: String!
|
||||||
|
title: String!
|
||||||
|
updatedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommitEntry {
|
||||||
|
hash: String!
|
||||||
|
author: String!
|
||||||
|
email: String!
|
||||||
|
date: String!
|
||||||
|
subject: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Inputs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
input SetupInput {
|
||||||
|
storagePath: String!
|
||||||
|
adminUser: String!
|
||||||
|
adminPass: String!
|
||||||
|
ldap: LDAPInput
|
||||||
|
jwtSecret: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input LDAPInput {
|
||||||
|
host: String!
|
||||||
|
port: Int!
|
||||||
|
baseDN: String!
|
||||||
|
bindDN: String!
|
||||||
|
bindPassword: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input SaveDocumentInput {
|
||||||
|
slug: String!
|
||||||
|
content: String!
|
||||||
|
commitMessage: String!
|
||||||
|
}
|
||||||
28
backend/internal/graph/server.go
Normal file
28
backend/internal/graph/server.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/brasse-b/archivum/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewServer wires up the HTTP handler for the GraphQL endpoint.
|
||||||
|
// Replace the stub handler with the gqlgen-generated handler once
|
||||||
|
// `go generate ./...` has been run.
|
||||||
|
func NewServer(cfg *config.Config) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Placeholder — replace with generated gqlgen handler.
|
||||||
|
mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"systemStatus":"REQUIRE_SETUP"}}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simple health check.
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
70
backend/internal/storage/storage.go
Normal file
70
backend/internal/storage/storage.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store manages AsciiDoc files on disk.
|
||||||
|
type Store struct {
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(root string) (*Store, error) {
|
||||||
|
if err := os.MkdirAll(root, 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{root: root}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns the content of a document by its slug path (e.g. "guides/install").
|
||||||
|
func (s *Store) Read(slug string) (string, error) {
|
||||||
|
data, err := os.ReadFile(s.filePath(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write persists content to disk, creating parent directories as needed.
|
||||||
|
func (s *Store) Write(slug, content string) error {
|
||||||
|
path := s.filePath(slug)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a document from disk.
|
||||||
|
func (s *Store) Delete(slug string) error {
|
||||||
|
return os.Remove(s.filePath(slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all document slugs under an optional prefix.
|
||||||
|
func (s *Store) List(prefix string) ([]string, error) {
|
||||||
|
base := filepath.Join(s.root, filepath.FromSlash(prefix))
|
||||||
|
var slugs []string
|
||||||
|
err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() && strings.HasSuffix(path, ".adoc") {
|
||||||
|
rel, _ := filepath.Rel(s.root, path)
|
||||||
|
slug := strings.TrimSuffix(filepath.ToSlash(rel), ".adoc")
|
||||||
|
slugs = append(slugs, slug)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return slugs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) filePath(slug string) string {
|
||||||
|
return filepath.Join(s.root, filepath.FromSlash(slug)+".adoc")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("document not found")
|
||||||
42
docker/Dockerfile
Normal file
42
docker/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ── Stage 1: Build frontend ───────────────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: Build backend ────────────────────────────────────────────────────
|
||||||
|
FROM golang:1.22-alpine AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY backend/ ./
|
||||||
|
# CGO disabled — pure Go SQLite (modernc.org/sqlite).
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /archivum ./cmd/server
|
||||||
|
|
||||||
|
# ── Stage 3: Runtime image ────────────────────────────────────────────────────
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
# Allow PUID/PGID override at runtime.
|
||||||
|
ARG PUID=1000
|
||||||
|
ARG PGID=1000
|
||||||
|
|
||||||
|
RUN addgroup -g "${PGID}" archivum \
|
||||||
|
&& adduser -u "${PUID}" -G archivum -s /sbin/nologin -D archivum
|
||||||
|
|
||||||
|
# Embed frontend into the binary's working directory.
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /srv/archivum/ui
|
||||||
|
COPY --from=backend-builder /archivum /usr/local/bin/archivum
|
||||||
|
|
||||||
|
# Volumes: config and data live outside the image.
|
||||||
|
VOLUME ["/config", "/data"]
|
||||||
|
|
||||||
|
ENV DOCKER_PATH=/config
|
||||||
|
|
||||||
|
USER archivum
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/archivum"]
|
||||||
17
docker/docker-compose.yml
Normal file
17
docker/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
archivum:
|
||||||
|
image: archivum:latest
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
container_name: archivum
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:4000"
|
||||||
|
volumes:
|
||||||
|
- ${DOCKER_PATH:-./data}/config:/config
|
||||||
|
- ${DOCKER_PATH:-./data}/data:/data
|
||||||
|
environment:
|
||||||
|
DOCKER_PATH: /config
|
||||||
|
PUID: ${PUID:-1000}
|
||||||
|
PGID: ${PGID:-1000}
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#1e293b" />
|
||||||
|
<link rel="icon" type="image/ico" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<title>Archivum</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface-900 text-slate-100 antialiased">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "archivum",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"type-check": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^2.4.0",
|
||||||
|
"@tiptap/pm": "^2.4.0",
|
||||||
|
"@tiptap/starter-kit": "^2.4.0",
|
||||||
|
"@tiptap/vue-3": "^2.4.0",
|
||||||
|
"asciidoctor": "^3.0.4",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
|
"graphql": "^16.9.0",
|
||||||
|
"graphql-request": "^6.1.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@codemirror/lang-markdown": "^6.2.5",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.3.1",
|
||||||
|
"vite-plugin-pwa": "^0.20.0",
|
||||||
|
"vue-tsc": "^2.0.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/src/App.vue
Normal file
23
frontend/src/App.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
|
||||||
|
const app = useAppStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// If backend signals REQUIRE_SETUP, redirect to wizard.
|
||||||
|
app.checkStatus().then(() => {
|
||||||
|
if (app.requiresSetup) {
|
||||||
|
router.replace('/setup')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const showLayout = computed(() => !app.requiresSetup)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout v-if="showLayout" />
|
||||||
|
<router-view v-else />
|
||||||
|
</template>
|
||||||
23
frontend/src/assets/main.css
Normal file
23
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', ui-sans-serif, system-ui;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AsciiDoc rendered output */
|
||||||
|
.adoc-content h1 { @apply text-2xl font-bold mb-4 mt-6; }
|
||||||
|
.adoc-content h2 { @apply text-xl font-semibold mb-3 mt-5; }
|
||||||
|
.adoc-content h3 { @apply text-lg font-medium mb-2 mt-4; }
|
||||||
|
.adoc-content p { @apply mb-3 leading-relaxed; }
|
||||||
|
.adoc-content pre { @apply bg-surface-800 rounded-lg p-4 overflow-x-auto font-mono text-sm mb-4; }
|
||||||
|
.adoc-content code { @apply font-mono text-sm bg-surface-800 px-1 rounded; }
|
||||||
|
.adoc-content ul { @apply list-disc list-inside mb-3 space-y-1; }
|
||||||
|
.adoc-content ol { @apply list-decimal list-inside mb-3 space-y-1; }
|
||||||
|
.adoc-content blockquote { @apply border-l-4 border-slate-500 pl-4 italic text-slate-400 mb-3; }
|
||||||
|
.adoc-content table { @apply w-full text-sm border-collapse mb-4; }
|
||||||
|
.adoc-content th { @apply bg-surface-800 px-3 py-2 text-left border border-slate-700; }
|
||||||
|
.adoc-content td { @apply px-3 py-2 border border-slate-700; }
|
||||||
|
}
|
||||||
164
frontend/src/bridge/asciidoc-bridge.ts
Normal file
164
frontend/src/bridge/asciidoc-bridge.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
|
const asciidoctor = Asciidoctor()
|
||||||
|
|
||||||
|
// ── AsciiDoc → TipTap ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function toTipTap(adoc: string): JSONContent {
|
||||||
|
if (!adoc.trim()) {
|
||||||
|
return { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = asciidoctor.load(adoc, { safe: 'safe' })
|
||||||
|
const blocks = (doc.getBlocks?.() ?? []) as AsciidoctorBlock[]
|
||||||
|
const content = blocks.flatMap(convertBlock).filter(Boolean) as JSONContent[]
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'doc',
|
||||||
|
content: content.length ? content : [{ type: 'paragraph' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TipTap → AsciiDoc ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function fromTipTap(json: JSONContent): string {
|
||||||
|
if (!json.content?.length) return ''
|
||||||
|
return json.content.map(nodeToAdoc).join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal converters ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 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':
|
||||||
|
case 'literal': {
|
||||||
|
const lang = block.getSourceLanguage?.() ?? ''
|
||||||
|
return {
|
||||||
|
type: 'codeBlock',
|
||||||
|
attrs: { language: lang || null },
|
||||||
|
content: [{ type: 'text', text: block.getSource?.() ?? '' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ulist':
|
||||||
|
return {
|
||||||
|
type: 'bulletList',
|
||||||
|
content: (block.getItems?.() ?? []).map((item) => ({
|
||||||
|
type: 'listItem',
|
||||||
|
content: [{ type: 'paragraph', content: parseInline(item.getContent?.() ?? '') }],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'olist':
|
||||||
|
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) {
|
||||||
|
case 'paragraph':
|
||||||
|
return inlineToAdoc(node.content ?? [])
|
||||||
|
|
||||||
|
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':
|
||||||
|
return (node.content ?? [])
|
||||||
|
.map((li) => `* ${inlineToAdoc(li.content?.[0]?.content ?? [])}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
case 'orderedList':
|
||||||
|
return (node.content ?? [])
|
||||||
|
.map((li) => `. ${inlineToAdoc(li.content?.[0]?.content ?? [])}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
case 'blockquote':
|
||||||
|
return `[quote]\n____\n${(node.content ?? []).map(nodeToAdoc).join('\n')}\n____`
|
||||||
|
|
||||||
|
case 'horizontalRule':
|
||||||
|
return "'''"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return inlineToAdoc(node.content ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineToAdoc(nodes: JSONContent[]): string {
|
||||||
|
return nodes
|
||||||
|
.map((n) => {
|
||||||
|
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('')
|
||||||
|
}
|
||||||
47
frontend/src/components/editor/SourceEditor.vue
Normal file
47
frontend/src/components/editor/SourceEditor.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
import { EditorView, basicSetup } from 'codemirror'
|
||||||
|
import { EditorState } from '@codemirror/state'
|
||||||
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
|
||||||
|
const model = defineModel<string>({ required: true })
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
let view: EditorView | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!container.value) return
|
||||||
|
|
||||||
|
view = new EditorView({
|
||||||
|
parent: container.value,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: model.value,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
markdown(),
|
||||||
|
oneDark,
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
model.value = update.state.doc.toString()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync external model changes into CodeMirror.
|
||||||
|
watch(model, (adoc) => {
|
||||||
|
if (!view) return
|
||||||
|
const current = view.state.doc.toString()
|
||||||
|
if (current !== adoc) {
|
||||||
|
view.dispatch({ changes: { from: 0, to: current.length, insert: adoc } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => view?.destroy())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="container" class="h-full font-mono text-sm" />
|
||||||
|
</template>
|
||||||
34
frontend/src/components/editor/VisualEditor.vue
Normal file
34
frontend/src/components/editor/VisualEditor.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import { toTipTap, fromTipTap } from '@/bridge/asciidoc-bridge'
|
||||||
|
|
||||||
|
const model = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [StarterKit],
|
||||||
|
content: toTipTap(model.value),
|
||||||
|
onUpdate({ editor }) {
|
||||||
|
model.value = fromTipTap(editor.getJSON())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync external changes (e.g. switching from SourceEditor) into TipTap.
|
||||||
|
watch(model, (adoc) => {
|
||||||
|
if (!editor.value) return
|
||||||
|
const json = toTipTap(adoc)
|
||||||
|
const current = JSON.stringify(editor.value.getJSON())
|
||||||
|
if (JSON.stringify(json) !== current) {
|
||||||
|
editor.value.commands.setContent(json, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => editor.value?.destroy())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-4 prose prose-invert max-w-none">
|
||||||
|
<EditorContent :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
56
frontend/src/components/layout/AppLayout.vue
Normal file
56
frontend/src/components/layout/AppLayout.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import Sidebar from './Sidebar.vue'
|
||||||
|
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen overflow-hidden bg-surface-900 text-slate-100">
|
||||||
|
<!-- Mobile overlay -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="sidebarOpen"
|
||||||
|
class="fixed inset-0 z-20 bg-black/60 lg:hidden"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Sidebar — slide-over on mobile, static on desktop -->
|
||||||
|
<aside
|
||||||
|
:class="[
|
||||||
|
'fixed inset-y-0 left-0 z-30 w-64 flex-shrink-0 bg-surface-800 border-r border-slate-700 transform transition-transform duration-200',
|
||||||
|
'lg:static lg:translate-x-0',
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Sidebar @close="sidebarOpen = false" />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||||
|
<!-- Mobile top bar -->
|
||||||
|
<header class="flex items-center gap-3 p-3 border-b border-slate-700 lg:hidden">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded hover:bg-surface-700"
|
||||||
|
aria-label="Open navigation"
|
||||||
|
@click="sidebarOpen = true"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="font-semibold">Archivum</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
</style>
|
||||||
59
frontend/src/components/layout/Sidebar.vue
Normal file
59
frontend/src/components/layout/Sidebar.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { gql } from '@/lib/gql'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
interface DocMeta { slug: string; title: string }
|
||||||
|
|
||||||
|
const docs = ref<DocMeta[]>([])
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const data = await gql<{ documents: DocMeta[] }>(`{ documents { slug title } }`)
|
||||||
|
docs.value = data.documents
|
||||||
|
})
|
||||||
|
|
||||||
|
function navigate(slug: string) {
|
||||||
|
router.push(`/doc/${slug}`)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||||
|
<router-link to="/" class="font-bold text-lg" @click="emit('close')">Archivum</router-link>
|
||||||
|
<button class="lg:hidden p-1 rounded hover:bg-surface-700" @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>
|
||||||
|
|
||||||
|
<!-- Document tree -->
|
||||||
|
<nav class="flex-1 overflow-y-auto py-2">
|
||||||
|
<button
|
||||||
|
v-for="doc in docs"
|
||||||
|
:key="doc.slug"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm hover:bg-surface-700 truncate"
|
||||||
|
@click="navigate(doc.slug)"
|
||||||
|
>
|
||||||
|
{{ doc.title }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- New document button -->
|
||||||
|
<div class="p-3 border-t border-slate-700">
|
||||||
|
<router-link
|
||||||
|
to="/doc/new"
|
||||||
|
class="block w-full text-center py-2 rounded bg-blue-700 hover:bg-blue-600 text-sm font-medium"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
+ New document
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
134
frontend/src/components/wizard/SetupWizard.vue
Normal file
134
frontend/src/components/wizard/SetupWizard.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { gql } from '@/lib/gql'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const app = useAppStore()
|
||||||
|
|
||||||
|
const step = ref(1)
|
||||||
|
const error = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
storagePath: '/data/wiki',
|
||||||
|
adminUser: 'admin',
|
||||||
|
adminPass: '',
|
||||||
|
jwtSecret: crypto.randomUUID().replace(/-/g, ''),
|
||||||
|
ldapEnabled: false,
|
||||||
|
ldap: {
|
||||||
|
host: '',
|
||||||
|
port: 389,
|
||||||
|
baseDN: '',
|
||||||
|
bindDN: '',
|
||||||
|
bindPassword: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await gql(
|
||||||
|
`mutation Setup($i: SetupInput!) { setup(input: $i) }`,
|
||||||
|
{
|
||||||
|
i: {
|
||||||
|
storagePath: form.storagePath,
|
||||||
|
adminUser: form.adminUser,
|
||||||
|
adminPass: form.adminPass,
|
||||||
|
jwtSecret: form.jwtSecret,
|
||||||
|
ldap: form.ldapEnabled ? form.ldap : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app.requiresSetup = false
|
||||||
|
await app.login(form.adminUser, form.adminPass)
|
||||||
|
router.replace('/')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-surface-900 px-4">
|
||||||
|
<div class="w-full max-w-md bg-surface-800 rounded-2xl p-8 shadow-xl">
|
||||||
|
<h1 class="text-2xl font-bold mb-1">Welcome to Archivum</h1>
|
||||||
|
<p class="text-slate-400 text-sm mb-6">Complete setup to get started.</p>
|
||||||
|
|
||||||
|
<!-- Step 1: Storage & Admin -->
|
||||||
|
<form v-if="step === 1" class="space-y-4" @submit.prevent="step = 2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Storage path</label>
|
||||||
|
<input v-model="form.storagePath" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Admin username</label>
|
||||||
|
<input v-model="form.adminUser" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Admin password</label>
|
||||||
|
<input v-model="form.adminPass" type="password" required class="input" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary w-full">Next →</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Step 2: LDAP (optional) -->
|
||||||
|
<form v-else-if="step === 2" class="space-y-4" @submit.prevent="submit">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input v-model="form.ldapEnabled" type="checkbox" class="rounded" />
|
||||||
|
<span class="text-sm">Enable LDAP authentication</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<template v-if="form.ldapEnabled">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Host</label>
|
||||||
|
<input v-model="form.ldap.host" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Port</label>
|
||||||
|
<input v-model.number="form.ldap.port" type="number" required class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Base DN</label>
|
||||||
|
<input v-model="form.ldap.baseDN" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Bind DN</label>
|
||||||
|
<input v-model="form.ldap.bindDN" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Bind password</label>
|
||||||
|
<input v-model="form.ldap.bindPassword" type="password" required class="input" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="button" class="btn-secondary flex-1" @click="step = 1">← Back</button>
|
||||||
|
<button type="submit" class="btn-primary flex-1" :disabled="submitting">
|
||||||
|
{{ submitting ? 'Setting up…' : 'Finish setup' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
@apply w-full bg-surface-900 border border-slate-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-blue-500;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply py-2 rounded-lg bg-blue-600 hover:bg-blue-500 font-medium text-sm disabled:opacity-50 transition;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply py-2 rounded-lg bg-surface-900 hover:bg-surface-700 font-medium text-sm transition;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
frontend/src/lib/gql.ts
Normal file
24
frontend/src/lib/gql.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Minimal GraphQL client — swap for graphql-request if preferred.
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? '/graphql'
|
||||||
|
|
||||||
|
export async function gql<T = unknown>(
|
||||||
|
query: string,
|
||||||
|
variables?: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.errors?.length) {
|
||||||
|
throw new Error(json.errors[0].message)
|
||||||
|
}
|
||||||
|
return json.data as T
|
||||||
|
}
|
||||||
10
frontend/src/main.ts
Normal file
10
frontend/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { router } from '@/router'
|
||||||
|
import App from '@/App.vue'
|
||||||
|
import '@/assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
27
frontend/src/router/index.ts
Normal file
27
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import HomeView from '@/views/HomeView.vue'
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/doc/:slug(.*)',
|
||||||
|
name: 'document',
|
||||||
|
component: () => import('@/views/DocumentView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/setup',
|
||||||
|
name: 'setup',
|
||||||
|
component: () => import('@/components/wizard/SetupWizard.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
36
frontend/src/stores/app.ts
Normal file
36
frontend/src/stores/app.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { gql } from '@/lib/gql'
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', () => {
|
||||||
|
const requiresSetup = ref(false)
|
||||||
|
const token = ref<string | null>(localStorage.getItem('token'))
|
||||||
|
const username = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const data = await gql<{ systemStatus: string }>(`{ systemStatus }`)
|
||||||
|
requiresSetup.value = data.systemStatus === 'REQUIRE_SETUP'
|
||||||
|
} catch {
|
||||||
|
requiresSetup.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(user: string, password: string) {
|
||||||
|
const data = await gql<{ login: string }>(
|
||||||
|
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`,
|
||||||
|
{ u: user, p: password },
|
||||||
|
)
|
||||||
|
token.value = data.login
|
||||||
|
username.value = user
|
||||||
|
localStorage.setItem('token', data.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = null
|
||||||
|
username.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requiresSetup, token, username, checkStatus, login, logout }
|
||||||
|
})
|
||||||
76
frontend/src/views/DocumentView.vue
Normal file
76
frontend/src/views/DocumentView.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { gql } from '@/lib/gql'
|
||||||
|
import VisualEditor from '@/components/editor/VisualEditor.vue'
|
||||||
|
import SourceEditor from '@/components/editor/SourceEditor.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = computed(() => route.params.slug as string)
|
||||||
|
|
||||||
|
const content = ref('')
|
||||||
|
const editorMode = ref<'visual' | 'source'>('visual')
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const commitMsg = ref('Update document')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await gql<{ document: { content: string } }>(
|
||||||
|
`query Doc($s: String!) { document(slug: $s) { content } }`,
|
||||||
|
{ s: slug.value },
|
||||||
|
)
|
||||||
|
content.value = data.document?.content ?? ''
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await gql(
|
||||||
|
`mutation Save($i: SaveDocumentInput!) { saveDocument(input: $i) { slug } }`,
|
||||||
|
{ i: { slug: slug.value, content: content.value, commitMessage: commitMsg.value } },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center gap-2 p-3 border-b border-slate-700 bg-surface-800 flex-wrap">
|
||||||
|
<button
|
||||||
|
:class="['px-3 py-1 rounded text-sm', editorMode === 'visual' ? 'bg-blue-600' : 'bg-surface-900 hover:bg-surface-700']"
|
||||||
|
@click="editorMode = 'visual'"
|
||||||
|
>Visual</button>
|
||||||
|
<button
|
||||||
|
:class="['px-3 py-1 rounded text-sm', editorMode === 'source' ? 'bg-blue-600' : 'bg-surface-900 hover:bg-surface-700']"
|
||||||
|
@click="editorMode = 'source'"
|
||||||
|
>Source</button>
|
||||||
|
|
||||||
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="commitMsg"
|
||||||
|
class="bg-surface-900 border border-slate-600 rounded px-2 py-1 text-sm w-64"
|
||||||
|
placeholder="Commit message"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="px-4 py-1 bg-green-700 hover:bg-green-600 rounded text-sm disabled:opacity-50"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="save"
|
||||||
|
>{{ saving ? 'Saving…' : 'Save' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<div v-if="loading" class="p-6 text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" />
|
||||||
|
<SourceEditor v-else v-model="content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
49
frontend/src/views/HomeView.vue
Normal file
49
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { gql } from '@/lib/gql'
|
||||||
|
|
||||||
|
interface DocMeta {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = ref<DocMeta[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await gql<{ documents: DocMeta[] }>(`{
|
||||||
|
documents { slug title updatedAt }
|
||||||
|
}`)
|
||||||
|
docs.value = data.documents
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 max-w-3xl mx-auto">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Archivum</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
|
||||||
|
<ul v-else class="space-y-2">
|
||||||
|
<li v-if="docs.length === 0" class="text-slate-500">No documents yet.</li>
|
||||||
|
<li
|
||||||
|
v-for="doc in docs"
|
||||||
|
:key="doc.slug"
|
||||||
|
class="rounded-lg bg-surface-800 hover:bg-surface-700 transition"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="`/doc/${doc.slug}`"
|
||||||
|
class="flex items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ doc.title }}</span>
|
||||||
|
<span class="text-xs text-slate-500">{{ doc.updatedAt }}</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
23
frontend/tailwind.config.js
Normal file
23
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||||
|
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
surface: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
46
frontend/vite.config.ts
Normal file
46
frontend/vite.config.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Archivum',
|
||||||
|
short_name: 'Archivum',
|
||||||
|
description: 'AsciiDoc wiki with Git history',
|
||||||
|
theme_color: '#1e293b',
|
||||||
|
background_color: '#0f172a',
|
||||||
|
display: 'standalone',
|
||||||
|
icons: [
|
||||||
|
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /\/graphql$/,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: { cacheName: 'api-cache' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, 'src') },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/graphql': { target: 'http://localhost:4000', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user