26 Commits
Author SHA1 Message Date
SirBlob 5d3681bd3f ci: install clippy component for backend job 2026-07-19 18:52:55 -04:00
SirBlob 40555d7d6a ci: pin typst clone to v0.15.1 to match Dockerfile 2026-07-19 18:50:22 -04:00
SirBlob a399636db9 Update Typst Version to Latest 2026-07-19 12:15:55 -04:00
SirBlob a2b7b5871a Fix svelte-check type errors in FileTree and space page 2026-07-19 12:02:33 -04:00
SirBlob 7de21ffcd5 Publish images from Gitea only 2026-07-18 23:28:02 -04:00
SirBlob 83f2503081 Document desktop API additions 2026-07-18 20:07:50 -04:00
SirBlob 0ac2b21591 Remove runner setup notes from README 2026-07-18 18:57:13 -04:00
SirBlob f77d23fab4 Default Docker builds to amd64 2026-07-18 18:56:45 -04:00
SirBlob 1b84df88c6 Add Gitea Actions workflows 2026-07-18 18:22:24 -04:00
SirBlob e6a5dbd90c Add account files to desktop API 2026-07-18 18:04:20 -04:00
SirBlob 3fe3544223 Add cloud documents, folders, and shared items to desktop API 2026-07-18 16:55:45 -04:00
SirBlob 039c88d4d0 Release v1.5.0 2026-07-18 15:07:13 -04:00
SirBlob be6dce4d4a Add prebuilt image to README 2026-07-18 15:06:12 -04:00
SirBlob 630b668760 Add desktop sync API 2026-07-18 15:00:31 -04:00
SirBlob 690d504535 Docker Image Builds 2026-07-03 22:36:51 -04:00
SirBlob 428a8d021e Support remote image URLs in Typst compiler 2026-06-14 21:03:52 -04:00
SirBlob 1afa80f332 Add HTML render format to API and update version to 1.4.8 2026-06-14 13:11:13 -04:00
SirBlob 9644361bad Add HTML render format to API and update version to 1.4.8 2026-06-14 13:08:33 -04:00
SirBlob 68ffa14622 Update Version: 1.4.7 2026-05-31 18:05:00 -04:00
SirBlob bbd7be86a5 Added Typst Spaces and Internal Packages 2026-05-31 18:03:04 -04:00
SirBlob c1fdb5a1b7 Updated API 422 Error Response 2026-05-31 15:06:52 -04:00
SirBlob 18738c399d Update Version: 1.4.5 2026-05-21 21:17:10 -04:00
SirBlob c42ae39bb8 Fixed Sharing Issue and added Shared Drive Folder 2026-05-21 20:38:53 -04:00
SirBlob e9155be432 Fix Model and Menu Styles 2026-05-21 19:53:19 -04:00
SirBlob 5563c0279c API Keys and Docs 2026-05-21 19:35:02 -04:00
SirBlob 13dff1fb3b Preview Images Update 2026-05-14 19:27:41 -04:00
51 changed files with 6221 additions and 297 deletions
+68
View File
@@ -0,0 +1,68 @@
name: CI
on:
push:
branches:
- main
- dev
pull_request:
workflow_dispatch:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Type check
run: bun run check
- name: Build
run: bun run build
backend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
apt-get install -y pkg-config libssl-dev
- name: Clone Typst compiler
run: |
git clone https://github.com/typst/typst.git typst
git -C typst checkout 9dfd3a08500b7896045f907433cf7b4b02434fad
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
server/target
key: cargo-${{ hashFiles('server/Cargo.lock') }}
restore-keys: cargo-
- name: Check
run: cargo check --manifest-path server/Cargo.toml
- name: Clippy
run: cargo clippy --manifest-path server/Cargo.toml -- -D warnings
continue-on-error: true
+72
View File
@@ -0,0 +1,72 @@
name: Build and Publish Docker Image
on:
push:
branches:
- main
tags:
- "v*"
release:
types: [published]
workflow_dispatch:
inputs:
platforms:
description: "Target platforms, comma-separated. Adding an arm target needs a privileged runner."
required: false
default: "linux/amd64"
env:
REGISTRY: ghcr.io
IMAGE_NAME: sirblobby/typstdrive
DEFAULT_PLATFORMS: "linux/amd64"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Resolve target platforms
id: platforms
run: echo "value=${{ github.event.inputs.platforms || env.DEFAULT_PLATFORMS }}" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
if: contains(steps.platforms.outputs.value, 'arm')
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: ${{ steps.platforms.outputs.value }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
+18 -3
View File
@@ -1,5 +1,7 @@
# Build Frontend
FROM oven/bun:alpine AS frontend-builder
# Frontend output is static, arch-independent assets, so build it natively on the
# build host (no emulation) regardless of the target platform.
FROM --platform=$BUILDPLATFORM oven/bun:alpine AS frontend-builder
WORKDIR /app
COPY package.json ./
RUN bun install
@@ -7,10 +9,12 @@ COPY . .
RUN bun run build
# Build Backend
# Built for the target platform (under QEMU emulation for non-native arches).
FROM rust:alpine AS backend-builder
WORKDIR /app
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig git
RUN git clone --depth=1 https://github.com/typst/typst.git typst
RUN git clone https://github.com/typst/typst.git typst \
&& git -C typst checkout 9dfd3a08500b7896045f907433cf7b4b02434fad
COPY server/Cargo.* server/
COPY server/src server/src
WORKDIR /app/server
@@ -18,10 +22,21 @@ RUN cargo build --release
# Final Runtime Image
FROM alpine:3.19
# Provided automatically by buildx (e.g. "amd64", "arm64").
ARG TARGETARCH
WORKDIR /app
RUN apk add --no-cache libgcc openssl pandoc curl sqlite
RUN mkdir -p /data
RUN curl -L https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-alpine-x64 -o /usr/local/bin/tinymist && chmod +x /usr/local/bin/tinymist
# Install tinymist for the target architecture.
RUN case "$TARGETARCH" in \
amd64) TINYMIST_TRIPLE="x86_64-unknown-linux-musl" ;; \
arm64) TINYMIST_TRIPLE="aarch64-unknown-linux-musl" ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" && exit 1 ;; \
esac && \
curl -fL "https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-${TINYMIST_TRIPLE}.tar.gz" -o /tmp/tinymist.tar.gz && \
tar -xzf /tmp/tinymist.tar.gz -C /usr/local/bin --strip-components=1 "tinymist-${TINYMIST_TRIPLE}/tinymist" && \
chmod +x /usr/local/bin/tinymist && \
rm /tmp/tinymist.tar.gz
COPY --from=frontend-builder /app/build /app/build
COPY --from=backend-builder /app/server/target/release/server /app/server
+109 -9
View File
@@ -1,7 +1,7 @@
# TypstDrive
[![Version](https://img.shields.io/badge/version-1.4.0-blue.svg)](https://github.com/your-username/typstdrive)
[![Typst Version](https://img.shields.io/badge/Typst-0.14.2-239dad?logo=typst&logoColor=white)](https://typst.app/)
[![Version](https://img.shields.io/badge/version-1.5.0-blue.svg)](https://github.com/sirblobby/typstdrive)
[![Typst Version](https://img.shields.io/badge/Typst-0.15.1-239dad?logo=typst&logoColor=white)](https://typst.app/)
[![Rust](https://img.shields.io/badge/Rust-1.82+-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![SvelteKit](https://img.shields.io/badge/SvelteKit-5-ff3e00?logo=svelte)](https://kit.svelte.dev/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com/)
@@ -18,10 +18,14 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul
- **Instant Preview**: Compile Typst to SVG on the fly with sub-second latency, featuring interactive document zoom controls and a collapsible preview pane.
- **Customizable Themes**: Choose from multiple editor themes (Catppuccin, Arch Linux, Cerberus) and toggle global dark mode.
- **Export Options**: Export your compiled documents directly to PDF, PNG, SVG, HTML, Markdown, Word, or LaTeX formats using internal conversion and Pandoc integrations.
- **User Authentication & Document Access**: Secure accounts, workspaces, and sharing features via email-based collaborator invitations (Editor or Viewer roles) for all your documents.
- **Document Sharing**: Invite collaborators by email with Editor or Viewer roles. Collaborators' uploaded fonts and images are available to the compiler. A dedicated "Shared with me" folder on the dashboard surfaces all documents others have shared with you. Manage and remove collaborators directly from the Share modal in the editor.
- **Spaces**: Multi-file editor workspaces, each with its own `typst.toml` and any number of `.typ`, `.bib`, and asset files that import and reference one another. The Space editor has full parity with the document editor — formatting tools, font selector, page settings, zoom, themes, presentation mode, and PDF/PNG/SVG/Pandoc export — plus a file tree, per-file real-time collaboration (live cursors), TOML syntax highlighting, and your account's uploaded fonts and images. Create and edit text files like `refs.bib` directly in the browser — everything a full template (e.g. an IEEE paper) needs. Create one from the `+` menu on the dashboard or manage them at `/spaces`.
- **Global Packages**: Publish any Space as an instance-local Typst package, immutably versioned and importable everywhere as `@typstdrive/<name>:<version>` (e.g. `#import "@typstdrive/charged-ieee:0.1.4": ieee`). The name, version, and entrypoint are read from the Space's `typst.toml`. Browse published packages at `/packages`.
- **Public REST API**: Programmatically render Typst documents to PNG, PDF, or HTML via `POST /v1/render`. Compilation failures return a `422` with a JSON body detailing each Typst error, including its message and source line and column. Manage API keys from the Settings panel, with a live usage chart supporting 1-hour, 1-day, and 1-week views. Full API reference available at `/api-docs`.
- **Admin System**: First-run setup wizard creates an admin account. Admins can manage all users, create new accounts with temporary passwords, toggle admin privileges, and delete accounts from the Settings panel.
- **Presentation Mode**: Turn your documents into instant slideshows with built-in slide controls and a live drawing/annotation tool overlay.
- **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents.
- **Desktop Sync API**: A dedicated API under `/api/desktop` lets [Typst Desktop](https://github.com/SirBlobby/typst-desktop) browse your folders, documents, Spaces, shared items, and uploaded assets, and keep them in sync locally. Device-token authentication, role-aware permissions, and hash-based conflict detection.
## Fonts & Images
@@ -62,6 +66,45 @@ Uploaded images can be referenced natively using the `#image` function in Typst.
#image("logo.png", width: 50%)
```
You can also reference remote images directly by their `http://` or `https://` URL — TypstDrive fetches them at compile time.
```typst
#image("https://example.com/logo.png", width: 50%)
```
## Desktop Sync API
The desktop app authenticates with a **device token** rather than a session cookie. Sign in once with `POST /api/desktop/auth/login`, then send the returned token as `Authorization: Bearer <token>` on every request. Tokens are stored hashed and can be revoked from the app by signing out.
| Method | Endpoint | Description |
|---|---|---|
| `POST` | `/api/desktop/auth/login` | Exchange email and password for a device token. |
| `POST` | `/api/desktop/auth/logout` | Revoke the current device token. |
| `GET` | `/api/desktop/auth/me` | Account the token belongs to. |
| `GET` | `/api/desktop/spaces` | Spaces the account owns or collaborates on. |
| `POST` | `/api/desktop/spaces` | Create a Space. |
| `GET` | `/api/desktop/spaces/{id}` | Full Space contents in one response. |
| `DELETE` | `/api/desktop/spaces/{id}` | Delete a Space. |
| `GET` | `/api/desktop/spaces/{id}/manifest` | Every file with its content hash, for change detection. |
| `GET` | `/api/desktop/spaces/{id}/file?path=` | Read one file. |
| `PUT` | `/api/desktop/spaces/{id}/file` | Write one file. |
| `DELETE` | `/api/desktop/spaces/{id}/file?path=` | Delete one file. |
| `GET` | `/api/desktop/folders` | Every folder the account owns. |
| `GET` | `/api/desktop/documents?folder_id=` | Documents in a folder, or at the root. |
| `GET` | `/api/desktop/documents/{id}` | Read a document, with the caller's role. |
| `PUT` | `/api/desktop/documents/{id}` | Write a document. |
| `GET` | `/api/desktop/shared` | Documents and Spaces shared with the account. |
| `GET` | `/api/desktop/files?folder_id=` | Uploaded images and fonts in a folder. |
| `GET` | `/api/desktop/files/{id}` | Read an uploaded file, base64-encoded. |
### Permissions
Every response carries the caller's `role` for the item. Owners and editors may write; viewers are refused with `403`. Space endpoints require owner or editor access, so a read-only Space is not writable from the desktop app.
### Conflict Detection
A write sends the `base_hash` the client last saw. If the file on the server no longer matches that hash, the write is rejected with `409` and a body containing the server's current content, so the client can merge instead of overwriting. Text files are stored in the same Yjs format the web editor uses, so a desktop push and a browser edit stay compatible.
## Self-Hosting
TypstDrive is completely self-hostable. A Docker image packages both the Rust backend and the SvelteKit frontend into a single container.
@@ -73,21 +116,60 @@ TypstDrive is completely self-hostable. A Docker image packages both the Rust ba
### Getting Started
1. Clone the repository:
1. Pull the image:
```bash
git clone https://github.com/your-username/typstdrive.git
cd typstdrive
docker pull ghcr.io/sirblobby/typstdrive:latest
```
2. Start the application:
2. Save this as `docker-compose.yml`:
```yaml
services:
app:
image: ghcr.io/sirblobby/typstdrive:latest
container_name: typstdrive
restart: unless-stopped
ports:
- "3000:3000"
environment:
- DATABASE_URL=sqlite:///data/typstdrive.db?mode=rwc
- DB_TYPE=sqlite
# Generate with: openssl rand -hex 64
- COOKIE_SECRET=your-64-plus-byte-secret-here
- ALLOW_REGISTRATION=false
- RUST_LOG=info
volumes:
- appdata:/data
volumes:
appdata:
```
3. Start it:
```bash
docker compose up -d
```
3. Open your browser and navigate to `http://localhost:3000`.
4. Open your browser and navigate to `http://localhost:3000`.
On first launch with no users in the database, you will be redirected to the **Setup** page to create the initial admin account.
To update, pull the new image and recreate the container:
```bash
docker compose pull
docker compose up -d
```
### Building from Source
To build the image yourself instead of pulling it, clone the repository and use the bundled compose file, which builds from the local `Dockerfile`:
```bash
git clone https://github.com/sirblobby/typstdrive.git
cd typstdrive
docker compose up -d
```
### Data Storage
By default, TypstDrive uses **SQLite** — no separate database container required. All data is stored in a single file persisted via the `appdata` Docker volume.
@@ -134,6 +216,24 @@ The first account created via the setup wizard is automatically an administrator
- **Toggle Admin** — promote or demote any other user
- **Delete User** — permanently remove any account other than your own
## Continuous Integration
Workflows live in `.gitea/workflows` and run on Gitea Actions.
| Workflow | Trigger | Purpose |
|---|---|---|
| `ci.yml` | push to `main` or `dev`, pull requests | Type checks and builds the frontend, then checks the backend. |
| `docker-publish.yml` | push to `main`, `v*` tags, releases | Builds the image and pushes it to the registry, always updating the `latest` tag. |
The publish workflow needs two repository secrets, since Gitea's built-in token only grants access to its own registry:
| Secret | Value |
|---|---|
| `REGISTRY_USERNAME` | Your GitHub username. |
| `REGISTRY_TOKEN` | A classic GitHub personal access token with the `write:packages` and `read:packages` scopes. Fine-grained tokens cannot publish to `ghcr.io`. |
Set the image name with the `IMAGE_NAME` variable at the top of the workflow if you publish somewhere other than `ghcr.io/sirblobby/typstdrive`.
## Contributing & Local Development
Clone the official Typst compiler into the `typst/` folder before building the backend:
@@ -169,5 +269,5 @@ The frontend dev server proxies API calls to `localhost:3000` automatically.
</p>
<p align="center">
<img src="preview/dashboard.png" alt="Dashboard view" width="49%">
<img src="preview/register.png" alt="Authentication view" width="49%">
<img src="preview/login.png" alt="Authentication view" width="49%">
</p>
+20 -16
View File
@@ -1,7 +1,7 @@
{
"name": "typstdrive",
"private": true,
"version": "1.4.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite dev --host",
@@ -14,32 +14,36 @@
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.56.1",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.2",
"svelte": "^5.55.1",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.2",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"svelte": "^5.56.6",
"svelte-check": "^4.7.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^7.3.2",
"vite": "^7.3.6",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.6.0"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lint": "^6.9.5",
"@codemirror/lsp-client": "^6.2.2",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.0",
"@iconify/svelte": "^5.2.1",
"@codemirror/lint": "^6.9.7",
"@codemirror/lsp-client": "^6.2.5",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@iconify/svelte": "^5.2.2",
"chart.js": "^4.5.1",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0",
"highlight.js": "^11.11.1",
"y-codemirror.next": "^0.3.5",
"y-websocket": "^3.0.0",
"yjs": "^13.6.30"
"yjs": "^13.6.31"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 464 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

+8 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "server"
version = "1.4.0"
version = "1.5.0"
edition = "2021"
[dependencies]
@@ -20,16 +20,21 @@ argon2 = "0.5"
futures-util = "0.3"
ecow = "0.2"
typst = { version = "0.14.2", path = "../typst/crates/typst" }
typst = { version = "0.15.1", path = "../typst/crates/typst" }
typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
typst-pdf = { path = "../typst/crates/typst-pdf" }
typst-render = { path = "../typst/crates/typst-render" }
typst-svg = { path = "../typst/crates/typst-svg" }
typst-html = { path = "../typst/crates/typst-html" }
typst-layout = { path = "../typst/crates/typst-layout" }
yrs = "0.18.8"
yrs-axum = "0.8"
typst-assets = { version = "0.14.2", features = ["fonts"] }
typst-assets = { version = "0.15.1", features = ["fonts"] }
tokio-stream = "0.1.18"
tempfile = "3.27.0"
sha2 = "0.10"
base64 = "0.22"
toml = "0.8"
ureq = "2.12"
+236
View File
@@ -0,0 +1,236 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use std::collections::HashMap;
use axum_extra::extract::cookie::SignedCookieJar;
use sha2::{Sha256, Digest};
use uuid::Uuid;
use crate::{
models::{ApiKeyView, CreateApiKeyRequest, UsagePoint},
AppState,
};
fn get_user_id(jar: &SignedCookieJar) -> Option<String> {
jar.get("session_user_id").map(|c| c.value().to_string())
}
pub fn hash_key(key: &str) -> String {
format!("{:x}", Sha256::digest(key.as_bytes()))
}
fn generate_api_key() -> String {
format!(
"td_{}{}",
Uuid::new_v4().to_string().replace("-", ""),
Uuid::new_v4().to_string().replace("-", "")
)
}
pub async fn list_keys(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<ApiKeyView>>, (StatusCode, String)> {
let user_id = get_user_id(&jar)
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let keys = sqlx::query_as::<_, ApiKeyView>(
"SELECT id, name, key_prefix, created_at, last_used_at, rate_limit FROM api_keys WHERE user_id = ? ORDER BY created_at DESC"
)
.bind(&user_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(keys))
}
pub async fn create_key(
State(state): State<AppState>,
jar: SignedCookieJar,
Json(payload): Json<CreateApiKeyRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
let user_id = get_user_id(&jar)
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
if payload.name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Key name cannot be empty".to_string()));
}
let existing: Option<(i64,)> = sqlx::query_as("SELECT COUNT(*) FROM api_keys WHERE user_id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some((count,)) = existing {
if count >= 10 {
return Err((StatusCode::CONFLICT, "Maximum of 10 API keys per account".to_string()));
}
}
let key = generate_api_key();
let hash = hash_key(&key);
let prefix = key[..11].to_string(); // "td_" + 8 hex chars
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, created_at) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(&id)
.bind(&user_id)
.bind(&payload.name)
.bind(&hash)
.bind(&prefix)
.bind(&now)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::CREATED, Json(serde_json::json!({
"id": id,
"name": payload.name,
"key": key,
"prefix": prefix,
"created_at": now,
"rate_limit": 60,
}))))
}
pub async fn get_aggregate_usage(
State(state): State<AppState>,
jar: SignedCookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<UsagePoint>>, (StatusCode, String)> {
let user_id = get_user_id(&jar)
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let period = params.get("period").map(|s| s.as_str()).unwrap_or("1week");
let usage = match period {
"1hr" => {
let cutoff = (chrono::Utc::now() - chrono::TimeDelta::hours(1))
.format("%Y-%m-%d %H:%M")
.to_string();
sqlx::query_as::<_, UsagePoint>(
"SELECT minute as date, SUM(count) as count
FROM api_key_usage_detail
WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND minute >= ?
GROUP BY minute
ORDER BY minute ASC"
)
.bind(&user_id)
.bind(&cutoff)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
}
"1day" => {
let cutoff = (chrono::Utc::now() - chrono::TimeDelta::hours(24))
.format("%Y-%m-%d %H:%M")
.to_string();
sqlx::query_as::<_, UsagePoint>(
"SELECT SUBSTR(minute, 1, 13) as date, SUM(count) as count
FROM api_key_usage_detail
WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND minute >= ?
GROUP BY SUBSTR(minute, 1, 13)
ORDER BY date ASC"
)
.bind(&user_id)
.bind(&cutoff)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
}
_ => {
let cutoff = (chrono::Utc::now() - chrono::TimeDelta::days(6))
.format("%Y-%m-%d")
.to_string();
sqlx::query_as::<_, UsagePoint>(
"SELECT date, SUM(count) as count
FROM api_key_usage
WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND date >= ?
GROUP BY date
ORDER BY date ASC"
)
.bind(&user_id)
.bind(&cutoff)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
}
};
Ok(Json(usage))
}
pub async fn regenerate_key(
State(state): State<AppState>,
jar: SignedCookieJar,
Path(key_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let user_id = get_user_id(&jar)
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let row: Option<(String,)> = sqlx::query_as(
"SELECT name FROM api_keys WHERE id = ? AND user_id = ?"
)
.bind(&key_id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (name,) = row.ok_or((StatusCode::NOT_FOUND, "API key not found".to_string()))?;
let new_key = generate_api_key();
let new_hash = hash_key(&new_key);
let new_prefix = new_key[..11].to_string();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE api_keys SET key_hash = ?, key_prefix = ?, created_at = ?, last_used_at = NULL WHERE id = ? AND user_id = ?"
)
.bind(&new_hash)
.bind(&new_prefix)
.bind(&now)
.bind(&key_id)
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"id": key_id,
"name": name,
"key": new_key,
"prefix": new_prefix,
"created_at": now,
"rate_limit": 60,
})))
}
pub async fn delete_key(
State(state): State<AppState>,
jar: SignedCookieJar,
Path(key_id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = get_user_id(&jar)
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let result = sqlx::query("DELETE FROM api_keys WHERE id = ? AND user_id = ?")
.bind(&key_id)
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "API key not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
-2
View File
@@ -11,8 +11,6 @@ use crate::{
AppState,
};
const USER_FIELDS: &str = "id, username, email, password_hash, is_admin";
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
+78 -2
View File
@@ -8,7 +8,7 @@ use serde::Deserialize;
use uuid::Uuid;
use crate::{
models::{Collaborator, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest},
models::{Collaborator, CollaboratorView, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest},
AppState,
};
@@ -32,7 +32,7 @@ pub async fn invite_collaborator(
return Err((StatusCode::FORBIDDEN, "Only the owner can invite collaborators".to_string()));
}
let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash FROM users WHERE email = ?")
let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE email = ?")
.bind(&payload.email)
.fetch_optional(&state.db)
.await
@@ -103,6 +103,82 @@ pub async fn accept_invite(
Ok(Json(collab))
}
pub async fn list_collaborators(
State(state): State<AppState>,
Path(doc_id): Path<String>,
jar: SignedCookieJar,
) -> Result<Json<Vec<CollaboratorView>>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Only owner or collaborators on the document can see the list
let has_access = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM documents WHERE id = ? AND owner_id = ? \
UNION ALL SELECT COUNT(*) FROM collaborators WHERE document_id = ? AND user_id = ?"
)
.bind(&doc_id).bind(&user_id).bind(&doc_id).bind(&user_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.into_iter().sum::<i64>() > 0;
if !has_access {
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
}
let collaborators = sqlx::query_as::<_, CollaboratorView>(
"SELECT c.id, c.user_id, u.username, u.email, c.role, c.created_at \
FROM collaborators c \
INNER JOIN users u ON u.id = c.user_id \
WHERE c.document_id = ? \
ORDER BY c.created_at ASC"
)
.bind(&doc_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(collaborators))
}
pub async fn remove_collaborator(
State(state): State<AppState>,
Path((doc_id, collab_id)): Path<(String, String)>,
jar: SignedCookieJar,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
// Only the document owner can remove collaborators
let is_owner = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM documents WHERE id = ? AND owner_id = ?"
)
.bind(&doc_id)
.bind(&user_id)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? > 0;
if !is_owner {
return Err((StatusCode::FORBIDDEN, "Only the document owner can remove collaborators".to_string()));
}
let result = sqlx::query(
"DELETE FROM collaborators WHERE id = ? AND document_id = ?"
)
.bind(&collab_id)
.bind(&doc_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Collaborator not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn get_comments(
State(state): State<AppState>,
Path(doc_id): Path<String>,
+90 -31
View File
@@ -3,9 +3,12 @@ use serde::Serialize;
use std::collections::HashMap;
use typst::diag::{SourceDiagnostic, Warned};
use typst::layout::{Frame, FrameItem};
use typst::utils::Scalar;
use typst_html::{HtmlDocument, HtmlOptions};
use typst_layout::PagedDocument;
use typst_pdf::{pdf, PdfOptions};
use typst_render::render;
use typst_render::{render, RenderOptions};
use typst_svg::SvgOptions;
#[derive(Serialize, Clone)]
pub struct DocumentStats {
@@ -49,6 +52,28 @@ fn extract_frame_text(frame: &Frame, text: &mut String) {
}
}
pub struct ProjectInput {
pub entrypoint: String,
pub files: HashMap<String, Vec<u8>>,
pub packages: HashMap<String, HashMap<String, Vec<u8>>>,
}
impl ProjectInput {
pub fn single(text: String, files: HashMap<String, Vec<u8>>) -> Self {
let mut project_files = files;
project_files.insert("main.typ".to_string(), text.into_bytes());
Self {
entrypoint: "main.typ".to_string(),
files: project_files,
packages: HashMap::new(),
}
}
fn into_world(self, enable_html: bool) -> MemoryWorld {
MemoryWorld::new_project(self.entrypoint, self.files, self.packages, enable_html)
}
}
pub struct TypstCompiler;
impl TypstCompiler {
@@ -58,22 +83,26 @@ impl TypstCompiler {
pub fn compile_svg(
&self,
text: String,
files: HashMap<String, Vec<u8>>,
input: ProjectInput,
) -> Result<
(Vec<String>, String, DocumentStats),
Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>,
> {
let world = MemoryWorld::new(text, files);
let world = input.into_world(false);
match typst::compile::<PagedDocument>(&world) {
Warned {
output: Ok(doc),
warnings: _,
} => {
let stats = extract_stats(&doc);
let svgs = doc.pages().iter().map(typst_svg::svg).collect();
let options = SvgOptions::default();
let svgs = doc
.pages()
.iter()
.map(|page| typst_svg::svg(page, &options))
.collect();
let thumbnail = if let Some(page) = doc.pages().first() {
typst_svg::svg(page)
typst_svg::svg(page, &options)
} else {
String::new()
};
@@ -83,15 +112,11 @@ impl TypstCompiler {
output: Err(errors),
warnings: _,
} => {
use typst::World;
use typst::WorldExt;
let diag = errors
.into_iter()
.map(|d| {
let range = d
.span
.id()
.and_then(|id| world.source(id).ok())
.and_then(|s| s.range(d.span));
let range = world.range(d.span);
(d, range)
})
.collect();
@@ -102,10 +127,9 @@ impl TypstCompiler {
pub fn export_pdf(
&self,
text: String,
files: HashMap<String, Vec<u8>>,
input: ProjectInput,
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = MemoryWorld::new(text, files);
let world = input.into_world(false);
match typst::compile::<PagedDocument>(&world) {
Warned {
output: Ok(doc),
@@ -121,15 +145,11 @@ impl TypstCompiler {
output: Err(errors),
warnings: _,
} => {
use typst::World;
use typst::WorldExt;
Err(errors
.into_iter()
.map(|d| {
let range = d
.span
.id()
.and_then(|id| world.source(id).ok())
.and_then(|s| s.range(d.span));
let range = world.range(d.span);
(d, range)
})
.collect())
@@ -139,17 +159,20 @@ impl TypstCompiler {
pub fn export_png(
&self,
text: String,
files: HashMap<String, Vec<u8>>,
input: ProjectInput,
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = MemoryWorld::new(text, files);
let world = input.into_world(false);
match typst::compile::<PagedDocument>(&world) {
Warned {
output: Ok(doc),
warnings: _,
} => {
if let Some(page) = doc.pages().first() {
let pixmap = render(page, 2.0);
let options = RenderOptions {
pixel_per_pt: Scalar::new(2.0),
..RenderOptions::default()
};
let pixmap = render(page, &options);
if let Ok(encoded) = pixmap.encode_png() {
return Ok(encoded);
}
@@ -160,15 +183,51 @@ impl TypstCompiler {
output: Err(errors),
warnings: _,
} => {
use typst::World;
use typst::WorldExt;
Err(errors
.into_iter()
.map(|d| {
let range = d
.span
.id()
.and_then(|id| world.source(id).ok())
.and_then(|s| s.range(d.span));
let range = world.range(d.span);
(d, range)
})
.collect())
}
}
}
pub fn export_html(
&self,
input: ProjectInput,
) -> Result<String, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = input.into_world(true);
let document = match typst::compile::<HtmlDocument>(&world) {
Warned {
output: Ok(document),
warnings: _,
} => document,
Warned {
output: Err(errors),
warnings: _,
} => {
use typst::WorldExt;
return Err(errors
.into_iter()
.map(|d| {
let range = world.range(d.span);
(d, range)
})
.collect());
}
};
match typst_html::html(&document, &HtmlOptions::default()) {
Ok(html) => Ok(html),
Err(errors) => {
use typst::WorldExt;
Err(errors
.into_iter()
.map(|d| {
let range = world.range(d.span);
(d, range)
})
.collect())
+92
View File
@@ -76,6 +76,97 @@ pub async fn init_schema(pool: &AnyPool) {
content TEXT NOT NULL,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
rate_limit INTEGER NOT NULL DEFAULT 60
)",
"CREATE TABLE IF NOT EXISTS api_render_cache (
id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL UNIQUE,
format TEXT NOT NULL,
data BYTEA NOT NULL,
created_at TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS api_key_usage (
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
date TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(key_id, date)
)",
"CREATE TABLE IF NOT EXISTS api_key_usage_detail (
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
minute TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(key_id, minute)
)",
"CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
folder_id TEXT REFERENCES folders(id),
name TEXT NOT NULL,
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
thumbnail_svg TEXT,
public_role TEXT DEFAULT NULL,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"CREATE TABLE IF NOT EXISTS space_files (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'text',
content BYTEA,
mime_type TEXT NOT NULL DEFAULT 'text/plain',
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
UNIQUE(space_id, path)
)",
"CREATE TABLE IF NOT EXISTS space_collaborators (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
UNIQUE(space_id, user_id)
)",
"CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
namespace TEXT NOT NULL DEFAULT 'typstdrive',
name TEXT NOT NULL,
description TEXT,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
UNIQUE(namespace, name)
)",
"CREATE TABLE IF NOT EXISTS package_versions (
id TEXT PRIMARY KEY,
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
version TEXT NOT NULL,
entrypoint TEXT NOT NULL DEFAULT 'lib.typ',
manifest BYTEA,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
UNIQUE(package_id, version)
)",
"CREATE TABLE IF NOT EXISTS package_files (
id TEXT PRIMARY KEY,
version_id TEXT NOT NULL REFERENCES package_versions(id) ON DELETE CASCADE,
path TEXT NOT NULL,
data BYTEA NOT NULL,
UNIQUE(version_id, path)
)",
"CREATE TABLE IF NOT EXISTS device_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_used_at TEXT
)",
];
for stmt in &statements {
@@ -90,6 +181,7 @@ pub async fn init_schema(pool: &AnyPool) {
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
"ALTER TABLE space_files ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
];
for stmt in &migrations {
sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default());
+92
View File
@@ -81,6 +81,97 @@ pub async fn init_schema(pool: &AnyPool) {
content TEXT NOT NULL,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
rate_limit INTEGER NOT NULL DEFAULT 60
)",
"CREATE TABLE IF NOT EXISTS api_render_cache (
id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL UNIQUE,
format TEXT NOT NULL,
data BLOB NOT NULL,
created_at TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS api_key_usage (
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
date TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(key_id, date)
)",
"CREATE TABLE IF NOT EXISTS api_key_usage_detail (
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
minute TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(key_id, minute)
)",
"CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
folder_id TEXT REFERENCES folders(id),
name TEXT NOT NULL,
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
thumbnail_svg TEXT,
public_role TEXT DEFAULT NULL,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"CREATE TABLE IF NOT EXISTS space_files (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'text',
content BLOB,
mime_type TEXT NOT NULL DEFAULT 'text/plain',
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
UNIQUE(space_id, path)
)",
"CREATE TABLE IF NOT EXISTS space_collaborators (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
UNIQUE(space_id, user_id)
)",
"CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
namespace TEXT NOT NULL DEFAULT 'typstdrive',
name TEXT NOT NULL,
description TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
UNIQUE(namespace, name)
)",
"CREATE TABLE IF NOT EXISTS package_versions (
id TEXT PRIMARY KEY,
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
version TEXT NOT NULL,
entrypoint TEXT NOT NULL DEFAULT 'lib.typ',
manifest BLOB,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
UNIQUE(package_id, version)
)",
"CREATE TABLE IF NOT EXISTS package_files (
id TEXT PRIMARY KEY,
version_id TEXT NOT NULL REFERENCES package_versions(id) ON DELETE CASCADE,
path TEXT NOT NULL,
data BLOB NOT NULL,
UNIQUE(version_id, path)
)",
"CREATE TABLE IF NOT EXISTS device_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_used_at TEXT
)",
];
for stmt in &statements {
@@ -94,6 +185,7 @@ pub async fn init_schema(pool: &AnyPool) {
let migrations = [
"ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
"ALTER TABLE space_files ADD COLUMN updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
];
for stmt in &migrations {
let _ = sqlx::query(stmt).execute(pool).await;
File diff suppressed because it is too large Load Diff
+22
View File
@@ -17,6 +17,28 @@ pub struct ListDocsQuery {
pub folder_id: Option<String>,
}
pub async fn list_shared_documents(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<Document>>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let docs = sqlx::query_as::<_, Document>(
"SELECT d.id, d.owner_id, d.folder_id, d.title, d.content, d.thumbnail_svg, \
d.public_role, d.created_at, d.updated_at, c.role as effective_role \
FROM documents d \
INNER JOIN collaborators c ON c.document_id = d.id AND c.user_id = ? \
ORDER BY d.updated_at DESC"
)
.bind(&user_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(docs))
}
pub async fn list_documents(
axum::extract::Query(query): axum::extract::Query<ListDocsQuery>,
State(state): State<AppState>,
+181 -49
View File
@@ -50,11 +50,29 @@ impl Stream for ViewerFilterStream {
#[derive(Deserialize)]
pub struct CompileRequest {
pub text: String,
#[serde(default)]
pub text: Option<String>,
pub document_id: Option<String>,
pub space_id: Option<String>,
#[serde(default)]
pub files: Option<std::collections::HashMap<String, String>>,
}
use crate::compiler::DocumentStats;
use crate::compiler::{DocumentStats, ProjectInput};
fn map_diagnostics(
diags: Vec<(typst::diag::SourceDiagnostic, Option<std::ops::Range<usize>>)>,
) -> Vec<Diagnostic> {
diags
.into_iter()
.map(|(d, range)| Diagnostic {
message: d.message.to_string(),
severity: format!("{:?}", d.severity),
from: range.as_ref().map(|r| r.start),
to: range.as_ref().map(|r| r.end),
})
.collect()
}
#[derive(Serialize)]
pub struct CompileResponse {
@@ -79,34 +97,59 @@ pub async fn yjs_handler(
) -> impl IntoResponse {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let doc_info = sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
)
.bind(&id)
.fetch_optional(&state.db)
.await;
let mut is_viewer = true;
if let Ok(Some(ref d)) = doc_info {
if let Some(uid) = &user_id_opt {
if &d.owner_id == uid {
is_viewer = false;
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ? AND role = 'editor'")
.bind(&id)
.bind(uid)
let mut initial_content: Option<Vec<u8>> = None;
// (table, row_id) the autosave task persists into; None means no persistence.
let mut save_target: Option<(&'static str, String)> = None;
if let Some(rest) = id.strip_prefix("space:") {
if let Some((space_id, file_id)) = rest.split_once(':') {
if let Some((_space, role)) = crate::spaces::space_role(&state, space_id, &user_id_opt).await {
is_viewer = role == "viewer";
if let Ok(Some((content,))) = sqlx::query_as::<_, (Option<Vec<u8>>,)>(
"SELECT content FROM space_files WHERE id = ? AND space_id = ?"
)
.bind(file_id)
.bind(space_id)
.fetch_optional(&state.db)
.await
{
is_viewer = false;
{
initial_content = content;
}
save_target = Some(("space_files", file_id.to_string()));
}
}
if is_viewer {
if let Some(pr) = &d.public_role {
if pr == "editor" {
} else {
let doc_info = sqlx::query_as::<_, Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
)
.bind(&id)
.fetch_optional(&state.db)
.await;
if let Ok(Some(ref d)) = doc_info {
if let Some(uid) = &user_id_opt {
if &d.owner_id == uid {
is_viewer = false;
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ? AND role = 'editor'")
.bind(&id)
.bind(uid)
.fetch_optional(&state.db)
.await
{
is_viewer = false;
}
}
if is_viewer {
if let Some(pr) = &d.public_role {
if pr == "editor" {
is_viewer = false;
}
}
}
initial_content = d.content.clone();
}
save_target = Some(("documents", id.clone()));
}
let mut bcast_map = state.bcast_map.lock().await;
@@ -115,11 +158,9 @@ pub async fn yjs_handler(
} else {
let ydoc = Doc::new();
if let Ok(Some(db_doc)) = doc_info {
if let Some(content) = db_doc.content {
if let Ok(update) = Update::decode_v1(&content) {
ydoc.transact_mut().apply_update(update);
}
if let Some(content) = initial_content {
if let Ok(update) = Update::decode_v1(&content) {
ydoc.transact_mut().apply_update(update);
}
}
@@ -127,22 +168,28 @@ pub async fn yjs_handler(
let new_bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 10).await);
bcast_map.insert(id.clone(), new_bcast.clone());
let save_db = state.db.clone();
let save_id = id.clone();
let save_awareness = awareness.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
interval.tick().await;
let doc = save_awareness.read().await;
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
let _ = sqlx::query("UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(content)
.bind(&save_id)
.execute(&save_db)
.await;
}
});
if let Some((table, row_id)) = save_target {
let save_db = state.db.clone();
let save_awareness = awareness.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
interval.tick().await;
let doc = save_awareness.read().await;
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
let query = if table == "space_files" {
"UPDATE space_files SET content = ? WHERE id = ?"
} else {
"UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
};
let _ = sqlx::query(query)
.bind(content)
.bind(&row_id)
.execute(&save_db)
.await;
}
});
}
new_bcast
};
@@ -175,6 +222,54 @@ pub async fn compile_handler(
let mut can_save_thumbnail = false;
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
if let Some(space_id) = &payload.space_id {
let (space, role) = match crate::spaces::space_role(&state, space_id, &user_id_opt).await {
Some(v) => v,
None => {
return Json(CompileResponse {
svgs: None,
errors: Some(vec![Diagnostic {
message: "Unauthorized".to_string(),
severity: "Error".to_string(),
from: None,
to: None,
}]),
stats: None,
});
}
};
let overrides = payload.files.clone().unwrap_or_default();
let input = crate::spaces::assemble_project(&state, &space, overrides).await;
let can_save = role == "owner" || role == "editor";
let compiler = state.compiler.lock().await;
let result = compiler.compile_svg(input);
drop(compiler);
return match result {
Ok((svgs, thumbnail, stats)) => {
if can_save {
let _ = sqlx::query("UPDATE spaces SET thumbnail_svg = ? WHERE id = ?")
.bind(&thumbnail)
.bind(&space.id)
.execute(&state.db)
.await;
}
Json(CompileResponse {
svgs: Some(svgs),
errors: None,
stats: Some(stats),
})
}
Err(diags) => Json(CompileResponse {
svgs: None,
errors: Some(map_diagnostics(diags)),
stats: None,
}),
};
}
if let Some(doc_id) = &payload.document_id {
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>(
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
@@ -208,7 +303,7 @@ pub async fn compile_handler(
if has_access {
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
.bind(doc.owner_id)
.bind(&doc.owner_id)
.fetch_all(&state.db)
.await
{
@@ -216,12 +311,25 @@ pub async fn compile_handler(
files_map.insert(name, data);
}
}
// Also include files uploaded by collaborators specifically for this document
if let Ok(collab_files) = sqlx::query_as::<_, (String, Vec<u8>)>(
"SELECT name, data FROM files WHERE document_id = ? AND owner_id != ?"
)
.bind(doc_id)
.bind(&doc.owner_id)
.fetch_all(&state.db)
.await
{
for (name, data) in collab_files {
files_map.insert(name, data);
}
}
}
}
}
let compiler = state.compiler.lock().await;
match compiler.compile_svg(payload.text, files_map) {
match compiler.compile_svg(ProjectInput::single(payload.text.clone().unwrap_or_default(), files_map)) {
Ok((svgs, thumbnail, stats)) => {
if let Some(doc_id) = &payload.document_id {
if can_save_thumbnail {
@@ -298,7 +406,7 @@ pub async fn export_handler(
if has_access {
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
.bind(doc.owner_id)
.bind(&doc.owner_id)
.fetch_all(&state.db)
.await
{
@@ -306,14 +414,38 @@ pub async fn export_handler(
files_map.insert(name, data);
}
}
if let Ok(collab_files) = sqlx::query_as::<_, (String, Vec<u8>)>(
"SELECT name, data FROM files WHERE document_id = ? AND owner_id != ?"
)
.bind(doc_id)
.bind(&doc.owner_id)
.fetch_all(&state.db)
.await
{
for (name, data) in collab_files {
files_map.insert(name, data);
}
}
}
}
}
let input = if let Some(space_id) = &payload.space_id {
match crate::spaces::space_role(&state, space_id, &user_id_opt).await {
Some((space, _)) => {
let overrides = payload.files.clone().unwrap_or_default();
crate::spaces::assemble_project(&state, &space, overrides).await
}
None => return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
}
} else {
ProjectInput::single(payload.text.clone().unwrap_or_default(), files_map)
};
let compiler = state.compiler.lock().await;
match format.as_str() {
"pdf" => match compiler.export_pdf(payload.text, files_map.clone()) {
"pdf" => match compiler.export_pdf(input) {
Ok(bytes) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/pdf")],
@@ -322,7 +454,7 @@ pub async fn export_handler(
.into_response(),
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
},
"png" => match compiler.export_png(payload.text, files_map.clone()) {
"png" => match compiler.export_png(input) {
Ok(bytes) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "image/png")],
@@ -331,7 +463,7 @@ pub async fn export_handler(
.into_response(),
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
},
"svg" => match compiler.compile_svg(payload.text, files_map.clone()) {
"svg" => match compiler.compile_svg(input) {
Ok((svgs, _, _)) => {
let mut combined = String::new();
for svg in svgs {
@@ -383,7 +515,7 @@ pub async fn pandoc_export_handler(
};
let mut stdin = child.stdin.take().unwrap();
let text = payload.text.clone();
let text = payload.text.clone().unwrap_or_default();
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(text.as_bytes()).await;
+46 -2
View File
@@ -13,21 +13,28 @@ use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod admin;
mod api_keys;
mod auth;
mod compiler;
mod db;
mod desktop;
mod docs;
mod folders;
mod files;
mod handlers;
mod models;
mod packages;
mod public_api;
mod setup;
mod spaces;
mod world;
mod collab;
use compiler::TypstCompiler;
use handlers::{compile_handler, export_handler, yjs_handler};
pub type RateLimiterMap = Arc<Mutex<HashMap<String, (u32, std::time::Instant)>>>;
#[derive(Clone)]
pub struct AppState {
pub compiler: Arc<Mutex<TypstCompiler>>,
@@ -35,6 +42,7 @@ pub struct AppState {
pub db: AnyPool,
pub key: Key,
pub registration_enabled: bool,
pub rate_limiter: RateLimiterMap,
}
impl axum::extract::FromRef<AppState> for Key {
@@ -84,6 +92,7 @@ async fn main() {
db,
key,
registration_enabled,
rate_limiter: Arc::new(Mutex::new(HashMap::new())),
};
let api_routes = Router::new()
@@ -107,14 +116,48 @@ async fn main() {
.route("/files", get(files::list_files).post(files::upload_file_global))
.route("/files/{id}", delete(files::delete_file).patch(files::update_file))
.route("/files/{id}/data", get(files::get_file_data))
.route("/docs/shared", get(docs::list_shared_documents))
.route("/docs", get(docs::list_documents).post(docs::create_document))
.route("/docs/accept-invite", get(collab::accept_invite))
.route("/docs/{id}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document))
.route("/docs/{id}/files", post(docs::upload_file))
.route("/docs/{id}/collaborators", get(collab::list_collaborators))
.route("/docs/{id}/collaborators/{collab_id}", delete(collab::remove_collaborator))
.route("/docs/{id}/invite", post(collab::invite_collaborator))
.route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment))
.route("/docs/{id}/versions", get(collab::get_versions).post(collab::create_version))
.route("/comments/{id}", patch(collab::update_comment).delete(collab::delete_comment));
.route("/comments/{id}", patch(collab::update_comment).delete(collab::delete_comment))
.route("/keys", get(api_keys::list_keys).post(api_keys::create_key))
.route("/keys/usage", get(api_keys::get_aggregate_usage))
.route("/keys/{id}", delete(api_keys::delete_key))
.route("/keys/{id}/regenerate", post(api_keys::regenerate_key))
.route("/spaces/shared", get(spaces::list_shared_spaces))
.route("/spaces", get(spaces::list_spaces).post(spaces::create_space))
.route("/spaces/{id}", get(spaces::get_space).delete(spaces::delete_space).patch(spaces::update_space))
.route("/spaces/{id}/files", get(spaces::list_space_files).post(spaces::create_space_file))
.route("/spaces/{id}/files/upload", post(spaces::upload_space_file))
.route("/spaces/{id}/files/{fid}", get(spaces::get_space_file).patch(spaces::update_space_file).delete(spaces::delete_space_file))
.route("/packages", get(packages::list_packages))
.route("/packages/publish", post(packages::publish_package))
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package));
let desktop_routes = Router::new()
.route("/auth/login", post(desktop::login))
.route("/auth/logout", post(desktop::logout))
.route("/auth/me", get(desktop::me))
.route("/spaces", get(desktop::list_spaces).post(desktop::create_space))
.route("/spaces/{id}", get(desktop::pull_space).delete(desktop::delete_space))
.route("/spaces/{id}/manifest", get(desktop::get_manifest))
.route("/folders", get(desktop::list_folders))
.route("/documents", get(desktop::list_documents))
.route("/documents/{id}", get(desktop::pull_document).put(desktop::push_document))
.route("/shared", get(desktop::list_shared))
.route("/files", get(desktop::list_account_files))
.route("/files/{id}", get(desktop::pull_account_file))
.route("/spaces/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file));
let v1_routes = Router::new()
.route("/render", post(public_api::render_handler));
let yjs_routes = Router::new()
.route("/{id}", get(yjs_handler));
@@ -122,7 +165,8 @@ async fn main() {
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../build".to_string());
let app = Router::new()
.nest("/api", api_routes.layer(TraceLayer::new_for_http()))
.nest("/api", api_routes.nest("/desktop", desktop_routes).layer(TraceLayer::new_for_http()))
.nest("/v1", v1_routes.layer(TraceLayer::new_for_http()))
.nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http()))
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir))))
.with_state(state);
+118
View File
@@ -72,6 +72,93 @@ pub struct Document {
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Space {
pub id: String,
pub owner_id: String,
pub folder_id: Option<String>,
pub name: String,
pub entrypoint: String,
pub thumbnail_svg: Option<String>,
pub public_role: Option<String>,
#[serde(default)]
#[sqlx(default)]
pub effective_role: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct SpaceFile {
pub id: String,
pub space_id: String,
pub path: String,
pub kind: String,
#[serde(skip_serializing)]
#[sqlx(default)]
pub content: Option<Vec<u8>>,
pub mime_type: String,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Package {
pub id: String,
pub owner_id: String,
pub namespace: String,
pub name: String,
pub description: Option<String>,
pub created_at: String,
#[serde(default)]
#[sqlx(default)]
pub owner_name: Option<String>,
#[serde(default)]
#[sqlx(default)]
pub latest_version: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct PackageVersion {
pub id: String,
pub package_id: String,
pub version: String,
pub entrypoint: String,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateSpaceRequest {
pub name: String,
pub folder_id: Option<String>,
pub template: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateSpaceRequest {
pub name: Option<String>,
pub folder_id: Option<String>,
pub entrypoint: Option<String>,
pub public_role: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateSpaceFileRequest {
pub path: String,
pub kind: Option<String>,
pub content: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateSpaceFileRequest {
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PublishPackageRequest {
pub space_id: String,
pub version: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterRequest {
pub username: String,
@@ -133,6 +220,16 @@ pub struct Collaborator {
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct CollaboratorView {
pub id: String,
pub user_id: String,
pub username: String,
pub email: String,
pub role: String,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Invitation {
pub id: String,
@@ -195,6 +292,27 @@ pub struct AdminCreateUserRequest {
pub is_admin: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct UsagePoint {
pub date: String,
pub count: i64,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct ApiKeyView {
pub id: String,
pub name: String,
pub key_prefix: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub rate_limit: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateApiKeyRequest {
pub name: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SetupRequest {
pub username: String,
+244
View File
@@ -0,0 +1,244 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use axum_extra::extract::cookie::SignedCookieJar;
use serde::Deserialize;
use uuid::Uuid;
use crate::{
models::{Package, PackageVersion, PublishPackageRequest, Space},
spaces::decode_text_blob,
AppState,
};
#[derive(Deserialize)]
struct Manifest {
package: PackageMeta,
}
#[derive(Deserialize)]
struct PackageMeta {
name: String,
version: String,
entrypoint: Option<String>,
description: Option<String>,
}
fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
fn is_valid_version(version: &str) -> bool {
let parts: Vec<&str> = version.split('.').collect();
parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}
pub async fn publish_package(
State(state): State<AppState>,
jar: SignedCookieJar,
Json(payload): Json<PublishPackageRequest>,
) -> Result<Json<Package>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let space = sqlx::query_as::<_, Space>(
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ? AND owner_id = ?"
)
.bind(&payload.space_id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Space not found".to_string()))?;
let files = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
"SELECT path, kind, content FROM space_files WHERE space_id = ?"
)
.bind(&space.id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut snapshot: Vec<(String, Vec<u8>)> = Vec::new();
let mut manifest_text: Option<String> = None;
for (path, kind, content) in files {
let bytes = if kind == "binary" {
content.unwrap_or_default()
} else {
decode_text_blob(&content.unwrap_or_default()).into_bytes()
};
if path == "typst.toml" {
manifest_text = Some(String::from_utf8_lossy(&bytes).to_string());
}
snapshot.push((path, bytes));
}
let manifest_text = manifest_text
.ok_or((StatusCode::BAD_REQUEST, "Space has no typst.toml manifest".to_string()))?;
let manifest: Manifest = toml::from_str(&manifest_text)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid typst.toml: {}", e)))?;
let name = manifest.package.name.trim().to_string();
let version = payload.version.unwrap_or(manifest.package.version).trim().to_string();
let entrypoint = manifest.package.entrypoint.unwrap_or_else(|| "lib.typ".to_string());
if !is_valid_name(&name) {
return Err((StatusCode::BAD_REQUEST, "Invalid package name (lowercase letters, digits, '-' and '_' only)".to_string()));
}
if !is_valid_version(&version) {
return Err((StatusCode::BAD_REQUEST, "Version must be in the form major.minor.patch".to_string()));
}
let existing = sqlx::query_as::<_, Package>(
"SELECT id, owner_id, namespace, name, description, created_at FROM packages WHERE namespace = 'typstdrive' AND name = ?"
)
.bind(&name)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let package = match existing {
Some(pkg) => {
if pkg.owner_id != user_id {
return Err((StatusCode::FORBIDDEN, "A package with this name is owned by another user".to_string()));
}
pkg
}
None => {
let package_id = Uuid::new_v4().to_string();
sqlx::query_as::<_, Package>(
"INSERT INTO packages (id, owner_id, namespace, name, description) VALUES (?, ?, 'typstdrive', ?, ?) RETURNING id, owner_id, namespace, name, description, created_at"
)
.bind(&package_id)
.bind(&user_id)
.bind(&name)
.bind(&manifest.package.description)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
}
};
let version_exists = sqlx::query_as::<_, (String,)>(
"SELECT id FROM package_versions WHERE package_id = ? AND version = ?"
)
.bind(&package.id)
.bind(&version)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if version_exists.is_some() {
return Err((StatusCode::CONFLICT, format!("Version {} already published; versions are immutable", version)));
}
let version_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO package_versions (id, package_id, version, entrypoint, manifest) VALUES (?, ?, ?, ?, ?)"
)
.bind(&version_id)
.bind(&package.id)
.bind(&version)
.bind(&entrypoint)
.bind(manifest_text.into_bytes())
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
for (path, data) in snapshot {
let _ = sqlx::query(
"INSERT INTO package_files (id, version_id, path, data) VALUES (?, ?, ?, ?)"
)
.bind(Uuid::new_v4().to_string())
.bind(&version_id)
.bind(&path)
.bind(&data)
.execute(&state.db)
.await;
}
Ok(Json(package))
}
pub async fn list_packages(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<Package>>, (StatusCode, String)> {
jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let packages = sqlx::query_as::<_, Package>(
"SELECT p.id, p.owner_id, p.namespace, p.name, p.description, p.created_at, \
u.username as owner_name, \
(SELECT v.version FROM package_versions v WHERE v.package_id = p.id ORDER BY v.created_at DESC LIMIT 1) as latest_version \
FROM packages p JOIN users u ON u.id = p.owner_id \
ORDER BY p.name ASC"
)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(packages))
}
pub async fn list_versions(
State(state): State<AppState>,
Path(name): Path<String>,
jar: SignedCookieJar,
) -> Result<Json<Vec<PackageVersion>>, (StatusCode, String)> {
jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let versions = sqlx::query_as::<_, PackageVersion>(
"SELECT v.id, v.package_id, v.version, v.entrypoint, v.created_at \
FROM package_versions v JOIN packages p ON p.id = v.package_id \
WHERE p.namespace = 'typstdrive' AND p.name = ? ORDER BY v.created_at DESC"
)
.bind(&name)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(versions))
}
pub async fn delete_package(
State(state): State<AppState>,
Path(name): Path<String>,
jar: SignedCookieJar,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let is_admin = sqlx::query_as::<_, (i64,)>("SELECT is_admin FROM users WHERE id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map(|(a,)| a != 0)
.unwrap_or(false);
let result = if is_admin {
sqlx::query("DELETE FROM packages WHERE namespace = 'typstdrive' AND name = ?")
.bind(&name)
.execute(&state.db)
.await
} else {
sqlx::query("DELETE FROM packages WHERE namespace = 'typstdrive' AND name = ? AND owner_id = ?")
.bind(&name)
.bind(&user_id)
.execute(&state.db)
.await
}
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Package not found or unauthorized".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
+291
View File
@@ -0,0 +1,291 @@
use axum::{
extract::State,
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
use std::collections::HashMap;
use uuid::Uuid;
use crate::{api_keys::hash_key, compiler::ProjectInput, AppState};
#[derive(Deserialize)]
pub struct RenderRequest {
pub code: String,
pub format: String,
pub files: Option<Vec<InlineFile>>,
}
#[derive(Deserialize)]
pub struct InlineFile {
pub name: String,
pub data: String, // base64-encoded
}
#[derive(Serialize)]
struct CompileErrorDetail {
message: String,
severity: String,
line: Option<usize>,
column: Option<usize>,
}
#[derive(Serialize)]
struct CompileErrorResponse {
error: String,
details: Vec<CompileErrorDetail>,
}
fn line_and_column(code: &str, offset: usize) -> (usize, usize) {
let mut line = 1;
let mut column = 1;
for (index, character) in code.char_indices() {
if index >= offset {
break;
}
if character == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
fn compute_cache_key(format: &str, code: &str, files: &Option<Vec<InlineFile>>) -> String {
let mut hasher = Sha256::new();
hasher.update(format.as_bytes());
hasher.update(b"\x00");
hasher.update(code.as_bytes());
if let Some(files) = files {
let mut pairs: Vec<_> = files.iter().map(|f| (f.name.as_str(), f.data.as_str())).collect();
pairs.sort_by_key(|(n, _)| *n);
for (name, data) in pairs {
hasher.update(b"\x01");
hasher.update(name.as_bytes());
hasher.update(data.as_bytes());
}
}
format!("{:x}", hasher.finalize())
}
pub async fn render_handler(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(payload): Json<RenderRequest>,
) -> impl IntoResponse {
// Extract Bearer token
let api_key = match headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.filter(|v| v.starts_with("Bearer "))
.map(|v| v[7..].to_string())
{
Some(k) => k,
None => return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header. Use: Authorization: Bearer <api-key>").into_response(),
};
if payload.format != "png" && payload.format != "pdf" && payload.format != "html" {
return (StatusCode::BAD_REQUEST, "Invalid format. Must be 'png', 'pdf', or 'html'").into_response();
}
if payload.code.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "code cannot be empty").into_response();
}
let key_hash = hash_key(&api_key);
let key_row = sqlx::query_as::<_, (String, String, i64)>(
"SELECT id, user_id, rate_limit FROM api_keys WHERE key_hash = ?"
)
.bind(&key_hash)
.fetch_optional(&state.db)
.await;
let (key_id, user_id, rate_limit) = match key_row {
Ok(Some(row)) => row,
Ok(None) => return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response(),
Err(e) => {
let msg = format!("Database error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();
}
};
// Rate limiting: fixed window of 60 seconds
{
let mut limiter = state.rate_limiter.lock().await;
let now = std::time::Instant::now();
let window = std::time::Duration::from_secs(60);
let entry = limiter.entry(key_id.clone()).or_insert((0u32, now));
if now.duration_since(entry.1) > window {
entry.0 = 1;
entry.1 = now;
} else if entry.0 < rate_limit as u32 {
entry.0 += 1;
} else {
return (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded. Max requests per minute reached.").into_response();
}
}
let now = chrono::Utc::now();
let now_str = now.to_rfc3339();
let today = now.format("%Y-%m-%d").to_string();
let _ = sqlx::query("UPDATE api_keys SET last_used_at = ? WHERE id = ?")
.bind(&now_str)
.bind(&key_id)
.execute(&state.db)
.await;
let _ = sqlx::query(
"INSERT INTO api_key_usage (key_id, date, count) VALUES (?, ?, 1) \
ON CONFLICT (key_id, date) DO UPDATE SET count = api_key_usage.count + 1"
)
.bind(&key_id)
.bind(&today)
.execute(&state.db)
.await;
let minute_str = now.format("%Y-%m-%d %H:%M").to_string();
let _ = sqlx::query(
"INSERT INTO api_key_usage_detail (key_id, minute, count) VALUES (?, ?, 1) \
ON CONFLICT (key_id, minute) DO UPDATE SET count = api_key_usage_detail.count + 1"
)
.bind(&key_id)
.bind(&minute_str)
.execute(&state.db)
.await;
let cutoff_minute = (chrono::Utc::now() - chrono::TimeDelta::hours(25))
.format("%Y-%m-%d %H:%M")
.to_string();
let _ = sqlx::query("DELETE FROM api_key_usage_detail WHERE minute < ?")
.bind(&cutoff_minute)
.execute(&state.db)
.await;
// Check cache
let cache_key = compute_cache_key(&payload.format, &payload.code, &payload.files);
let content_type: &'static str = match payload.format.as_str() {
"pdf" => "application/pdf",
"html" => "text/html; charset=utf-8",
_ => "image/png",
};
if let Ok(Some((data, created_at))) = sqlx::query_as::<_, (Vec<u8>, String)>(
"SELECT data, created_at FROM api_render_cache WHERE content_hash = ? AND format = ?"
)
.bind(&cache_key)
.bind(&payload.format)
.fetch_optional(&state.db)
.await
{
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&created_at) {
let age = chrono::Utc::now().signed_duration_since(parsed.with_timezone(&chrono::Utc));
if age.num_seconds() < 3600 {
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response();
}
}
}
// Load user's account files
let mut files_map: HashMap<String, Vec<u8>> = HashMap::new();
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>(
"SELECT name, data FROM files WHERE owner_id = ?"
)
.bind(&user_id)
.fetch_all(&state.db)
.await
{
for (name, data) in files {
files_map.insert(name, data);
}
}
// Inline files override account files
if let Some(inline_files) = &payload.files {
for f in inline_files {
if let Ok(decoded) = BASE64.decode(&f.data) {
files_map.insert(f.name.clone(), decoded);
}
}
}
// Compile
let compiler = state.compiler.lock().await;
let result = match payload.format.as_str() {
"pdf" => compiler.export_pdf(ProjectInput::single(payload.code.clone(), files_map)),
"png" => compiler.export_png(ProjectInput::single(payload.code.clone(), files_map)),
"html" => compiler
.export_html(ProjectInput::single(payload.code.clone(), files_map))
.map(|html| html.into_bytes()),
_ => unreachable!(),
};
drop(compiler);
match result {
Ok(data) => {
// Store in cache (ignore errors — concurrent inserts are fine)
let cache_id = Uuid::new_v4().to_string();
let _ = sqlx::query(
"INSERT INTO api_render_cache (id, content_hash, format, data, created_at) VALUES (?, ?, ?, ?, ?)"
)
.bind(&cache_id)
.bind(&cache_key)
.bind(&payload.format)
.bind(&data)
.bind(&now_str)
.execute(&state.db)
.await;
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response()
}
Err(diagnostics) => {
let details: Vec<CompileErrorDetail> = diagnostics
.into_iter()
.map(|(diagnostic, range)| {
let (line, column) = match range.as_ref() {
Some(range) => {
let (line, column) = line_and_column(&payload.code, range.start);
(Some(line), Some(column))
}
None => (None, None),
};
CompileErrorDetail {
message: diagnostic.message.to_string(),
severity: format!("{:?}", diagnostic.severity).to_lowercase(),
line,
column,
}
})
.collect();
let summary = details
.iter()
.map(|detail| match (detail.line, detail.column) {
(Some(line), Some(column)) => {
format!("{} (line {}, column {})", detail.message, line, column)
}
_ => detail.message.clone(),
})
.collect::<Vec<_>>()
.join("; ");
let error = if summary.is_empty() {
"Typst compilation failed.".to_string()
} else {
format!("Typst compilation failed: {}", summary)
};
(
StatusCode::UNPROCESSABLE_ENTITY,
Json(CompileErrorResponse { error, details }),
)
.into_response()
}
}
}
+555
View File
@@ -0,0 +1,555 @@
use axum::{
extract::{Path, Query, State, Multipart},
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use axum_extra::extract::cookie::SignedCookieJar;
use std::collections::HashMap;
use uuid::Uuid;
use yrs::{Doc, GetString, ReadTxn, StateVector, Text, Transact};
use yrs::updates::decoder::Decode;
use yrs::Update;
use crate::{
compiler::ProjectInput,
models::{
CreateSpaceFileRequest, CreateSpaceRequest, Space, SpaceFile, UpdateSpaceFileRequest,
UpdateSpaceRequest,
},
AppState,
};
const TEXT_NAME: &str = "typst";
pub fn encode_text_blob(text: &str) -> Vec<u8> {
let doc = Doc::new();
let handle = doc.get_or_insert_text(TEXT_NAME);
handle.insert(&mut doc.transact_mut(), 0, text);
let bytes = doc.transact().encode_state_as_update_v1(&StateVector::default());
bytes
}
pub fn decode_text_blob(blob: &[u8]) -> String {
let doc = Doc::new();
if let Ok(update) = Update::decode_v1(blob) {
doc.transact_mut().apply_update(update);
}
let handle = doc.get_or_insert_text(TEXT_NAME);
let text = handle.get_string(&doc.transact());
text
}
fn is_text_path(path: &str) -> bool {
let lower = path.to_lowercase();
[".typ", ".toml", ".bib", ".csl", ".yml", ".yaml", ".json", ".md", ".txt", ".csv"]
.iter()
.any(|ext| lower.ends_with(ext))
}
fn default_manifest(name: &str) -> String {
format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nentrypoint = \"main.typ\"\nauthors = [\"Anonymous\"]\nlicense = \"MIT\"\ndescription = \"\"\n"
)
}
fn slugify(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let trimmed = slug.trim_matches('-').replace("--", "-");
if trimmed.is_empty() {
"my-space".to_string()
} else {
trimmed
}
}
pub async fn space_role(
state: &AppState,
space_id: &str,
user_id_opt: &Option<String>,
) -> Option<(Space, String)> {
let space = sqlx::query_as::<_, Space>(
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ?"
)
.bind(space_id)
.fetch_optional(&state.db)
.await
.ok()??;
if let Some(uid) = user_id_opt {
if &space.owner_id == uid {
return Some((space, "owner".to_string()));
}
if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>(
"SELECT role FROM space_collaborators WHERE space_id = ? AND user_id = ?",
)
.bind(space_id)
.bind(uid)
.fetch_optional(&state.db)
.await
{
return Some((space, role));
}
}
if let Some(pr) = space.public_role.clone() {
if pr == "viewer" || pr == "editor" {
return Some((space, pr));
}
}
None
}
pub async fn load_local_packages(state: &AppState) -> HashMap<String, HashMap<String, Vec<u8>>> {
let mut packages: HashMap<String, HashMap<String, Vec<u8>>> = HashMap::new();
let rows = sqlx::query_as::<_, (String, String, String, Vec<u8>)>(
"SELECT p.name, v.version, f.path, f.data \
FROM package_files f \
JOIN package_versions v ON v.id = f.version_id \
JOIN packages p ON p.id = v.package_id",
)
.fetch_all(&state.db)
.await
.unwrap_or_default();
for (name, version, path, data) in rows {
let key = format!("{}:{}", name, version);
packages.entry(key).or_default().insert(path, data);
}
packages
}
pub async fn assemble_project(
state: &AppState,
space: &Space,
overrides: HashMap<String, String>,
) -> ProjectInput {
let mut files: HashMap<String, Vec<u8>> = HashMap::new();
// Account-level uploaded files (fonts, images) come first as a base layer so
// they are available inside spaces; space files below override them by name.
if let Ok(account_files) = sqlx::query_as::<_, (String, Vec<u8>)>(
"SELECT name, data FROM files WHERE owner_id = ?",
)
.bind(&space.owner_id)
.fetch_all(&state.db)
.await
{
for (name, data) in account_files {
files.insert(name, data);
}
}
let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
"SELECT path, kind, content FROM space_files WHERE space_id = ?",
)
.bind(&space.id)
.fetch_all(&state.db)
.await
.unwrap_or_default();
for (path, kind, content) in rows {
if let Some(live) = overrides.get(&path) {
files.insert(path, live.clone().into_bytes());
} else if kind == "binary" {
files.insert(path, content.unwrap_or_default());
} else {
files.insert(path, decode_text_blob(&content.unwrap_or_default()).into_bytes());
}
}
for (path, content) in overrides {
files.entry(path).or_insert_with(|| content.into_bytes());
}
ProjectInput {
entrypoint: space.entrypoint.clone(),
files,
packages: load_local_packages(state).await,
}
}
#[derive(serde::Deserialize)]
pub struct ListSpacesQuery {
pub folder_id: Option<String>,
}
pub async fn list_spaces(
Query(query): Query<ListSpacesQuery>,
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<Space>>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let spaces = if let Some(folder_id) = query.folder_id {
sqlx::query_as::<_, Space>(
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC"
)
.bind(&user_id)
.bind(&folder_id)
.fetch_all(&state.db)
.await
} else {
sqlx::query_as::<_, Space>(
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC"
)
.bind(&user_id)
.fetch_all(&state.db)
.await
}
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(spaces))
}
pub async fn list_shared_spaces(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<Space>>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let spaces = sqlx::query_as::<_, Space>(
"SELECT s.id, s.owner_id, s.folder_id, s.name, s.entrypoint, s.thumbnail_svg, \
s.public_role, s.created_at, s.updated_at, c.role as effective_role \
FROM spaces s \
INNER JOIN space_collaborators c ON c.space_id = s.id AND c.user_id = ? \
ORDER BY s.updated_at DESC"
)
.bind(&user_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(spaces))
}
pub async fn create_space(
State(state): State<AppState>,
jar: SignedCookieJar,
Json(payload): Json<CreateSpaceRequest>,
) -> Result<Json<Space>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let space_id = Uuid::new_v4().to_string();
let space = sqlx::query_as::<_, Space>(
"INSERT INTO spaces (id, owner_id, folder_id, name, entrypoint) VALUES (?, ?, ?, ?, 'main.typ') RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at"
)
.bind(&space_id)
.bind(&user_id)
.bind(&payload.folder_id)
.bind(&payload.name)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let seeds = [
("typst.toml", default_manifest(&slugify(&payload.name))),
("main.typ", "= New Space\n\nStart writing here.\n".to_string()),
];
for (path, content) in seeds {
let _ = sqlx::query(
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, 'text', ?, 'text/plain')"
)
.bind(Uuid::new_v4().to_string())
.bind(&space_id)
.bind(path)
.bind(encode_text_blob(&content))
.execute(&state.db)
.await;
}
Ok(Json(space))
}
pub async fn get_space(
State(state): State<AppState>,
Path(id): Path<String>,
jar: SignedCookieJar,
) -> Result<Json<Space>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (mut space, role) = space_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
space.effective_role = Some(role);
Ok(Json(space))
}
pub async fn update_space(
State(state): State<AppState>,
Path(id): Path<String>,
jar: SignedCookieJar,
Json(payload): Json<UpdateSpaceRequest>,
) -> Result<Json<Space>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let mut space = sqlx::query_as::<_, Space>(
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ? AND owner_id = ?"
)
.bind(&id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Space not found".to_string()))?;
if let Some(name) = payload.name {
space.name = name;
}
if let Some(entrypoint) = payload.entrypoint {
space.entrypoint = entrypoint;
}
if let Some(folder_id) = payload.folder_id {
space.folder_id = if folder_id.is_empty() { None } else { Some(folder_id) };
}
if let Some(public_role) = payload.public_role {
space.public_role = if public_role == "none" || public_role.is_empty() {
None
} else {
Some(public_role)
};
}
let space = sqlx::query_as::<_, Space>(
"UPDATE spaces SET name = ?, entrypoint = ?, folder_id = ?, public_role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at"
)
.bind(&space.name)
.bind(&space.entrypoint)
.bind(&space.folder_id)
.bind(&space.public_role)
.bind(&id)
.bind(&user_id)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(space))
}
pub async fn delete_space(
State(state): State<AppState>,
Path(id): Path<String>,
jar: SignedCookieJar,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let _ = sqlx::query("DELETE FROM space_files WHERE space_id = ?")
.bind(&id)
.execute(&state.db)
.await;
let result = sqlx::query("DELETE FROM spaces WHERE id = ? AND owner_id = ?")
.bind(&id)
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Space not found or unauthorized".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_space_files(
State(state): State<AppState>,
Path(id): Path<String>,
jar: SignedCookieJar,
) -> Result<Json<Vec<SpaceFile>>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
space_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
let files = sqlx::query_as::<_, SpaceFile>(
"SELECT id, space_id, path, kind, mime_type, created_at FROM space_files WHERE space_id = ? ORDER BY path ASC"
)
.bind(&id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(files))
}
pub async fn create_space_file(
State(state): State<AppState>,
Path(id): Path<String>,
jar: SignedCookieJar,
Json(payload): Json<CreateSpaceFileRequest>,
) -> Result<Json<SpaceFile>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (_, role) = space_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" {
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
}
let kind = payload.kind.unwrap_or_else(|| "text".to_string());
let content = payload.content.unwrap_or_default();
let file_id = Uuid::new_v4().to_string();
let file = sqlx::query_as::<_, SpaceFile>(
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, 'text/plain') RETURNING id, space_id, path, kind, mime_type, created_at"
)
.bind(&file_id)
.bind(&id)
.bind(&payload.path)
.bind(&kind)
.bind(encode_text_blob(&content))
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(file))
}
pub async fn upload_space_file(
State(state): State<AppState>,
Path(id): Path<String>,
jar: SignedCookieJar,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (_, role) = space_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" {
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
}
let mut uploaded = vec![];
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
let path = field.file_name().unwrap_or("unnamed").to_string();
let mime_type = field.content_type().unwrap_or("application/octet-stream").to_string();
let data = field.bytes().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?.to_vec();
let (kind, content) = if is_text_path(&path) {
let text = String::from_utf8_lossy(&data).to_string();
("text", encode_text_blob(&text))
} else {
("binary", data)
};
let _ = sqlx::query(
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, ?) \
ON CONFLICT (space_id, path) DO UPDATE SET content = excluded.content, kind = excluded.kind, mime_type = excluded.mime_type"
)
.bind(Uuid::new_v4().to_string())
.bind(&id)
.bind(&path)
.bind(kind)
.bind(content)
.bind(&mime_type)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
uploaded.push(path);
}
Ok(Json(serde_json::json!({ "files": uploaded })))
}
pub async fn get_space_file(
State(state): State<AppState>,
Path((id, file_id)): Path<(String, String)>,
jar: SignedCookieJar,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
space_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
let file = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
"SELECT kind, mime_type, content FROM space_files WHERE id = ? AND space_id = ?"
)
.bind(&file_id)
.bind(&id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
let (kind, mime_type, content) = file;
let bytes = content.unwrap_or_default();
if kind == "binary" {
Ok(([(header::CONTENT_TYPE, mime_type)], bytes))
} else {
Ok(([(header::CONTENT_TYPE, "text/plain".to_string())], decode_text_blob(&bytes).into_bytes()))
}
}
pub async fn update_space_file(
State(state): State<AppState>,
Path((id, file_id)): Path<(String, String)>,
jar: SignedCookieJar,
Json(payload): Json<UpdateSpaceFileRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (_, role) = space_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" {
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
}
let result = sqlx::query("UPDATE space_files SET path = ? WHERE id = ? AND space_id = ?")
.bind(&payload.path)
.bind(&file_id)
.bind(&id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn delete_space_file(
State(state): State<AppState>,
Path((id, file_id)): Path<(String, String)>,
jar: SignedCookieJar,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (_, role) = space_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" {
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
}
let result = sqlx::query("DELETE FROM space_files WHERE id = ? AND space_id = ?")
.bind(&file_id)
.bind(&id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
+97 -39
View File
@@ -1,5 +1,6 @@
use chrono::Datelike;
use std::collections::HashMap;
use std::io::Read;
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Duration};
@@ -13,20 +14,64 @@ use typst_kit::packages::SystemPackages;
pub struct MemoryWorld {
library: typst::utils::LazyHash<Library>,
main: FileId,
source: Source,
files: HashMap<String, Vec<u8>>,
local_packages: HashMap<String, HashMap<String, Vec<u8>>>,
book: typst::utils::LazyHash<FontBook>,
fonts: Vec<Font>,
packages: SystemPackages,
}
const LOCAL_NAMESPACE: &str = "typstdrive";
const REMOTE_FETCH_TIMEOUT_SECS: u64 = 15;
const REMOTE_MAX_BYTES: u64 = 50 * 1024 * 1024;
fn normalize_path(path: &str) -> String {
path.trim_start_matches('/').replace('\\', "/")
}
fn remote_url(path: &str) -> Option<String> {
for scheme in ["https", "http"] {
let prefix = format!("{scheme}:/");
if let Some(rest) = path.strip_prefix(&prefix) {
let host_and_path = rest.trim_start_matches('/');
return Some(format!("{scheme}://{host_and_path}"));
}
}
None
}
fn fetch_remote(url: &str) -> FileResult<Vec<u8>> {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(REMOTE_FETCH_TIMEOUT_SECS))
.build();
let response = agent
.get(url)
.call()
.map_err(|e| FileError::Other(Some(format!("failed to fetch {url}: {e}").into())))?;
let mut bytes = Vec::new();
response
.into_reader()
.take(REMOTE_MAX_BYTES)
.read_to_end(&mut bytes)
.map_err(|e| FileError::Other(Some(format!("failed to read {url}: {e}").into())))?;
Ok(bytes)
}
impl MemoryWorld {
pub fn new(text: String, files: HashMap<String, Vec<u8>>) -> Self {
pub fn new_project(
entrypoint: String,
files: HashMap<String, Vec<u8>>,
local_packages: HashMap<String, HashMap<String, Vec<u8>>>,
enable_html: bool,
) -> Self {
let main = FileId::new(RootedPath::new(
VirtualRoot::Project,
VirtualPath::new("main.typ").unwrap(),
VirtualPath::new(&entrypoint).unwrap_or_else(|_| VirtualPath::new("main.typ").unwrap()),
));
let source = Source::new(main, text);
let downloader = SystemDownloader::new("TypstDrive (typst-kit)");
let packages = SystemPackages::new(downloader);
@@ -53,16 +98,55 @@ impl MemoryWorld {
}
}
let library = if enable_html {
Library::builder()
.with_features([typst::Feature::Html].into_iter().collect())
.build()
} else {
Library::builder().build()
};
Self {
library: typst::utils::LazyHash::new(Library::builder().build()),
library: typst::utils::LazyHash::new(library),
main,
source,
files,
local_packages,
book: typst::utils::LazyHash::new(book),
fonts,
packages,
}
}
fn load_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
let path = normalize_path(id.vpath().get_without_slash());
if let VirtualRoot::Package(package) = id.root() {
if package.namespace.as_str() == LOCAL_NAMESPACE {
let key = format!("{}:{}", package.name, package.version);
return self
.local_packages
.get(&key)
.and_then(|files| files.get(&path))
.cloned()
.ok_or_else(|| FileError::NotFound(path.clone().into()));
}
let root = self
.packages
.obtain(package)
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
return root.load(id.vpath()).map(|bytes| bytes.to_vec());
}
if let Some(url) = remote_url(&path) {
return fetch_remote(&url);
}
self.files
.get(&path)
.cloned()
.ok_or_else(|| FileError::NotFound(path.into()))
}
}
impl World for MemoryWorld {
@@ -79,42 +163,16 @@ impl World for MemoryWorld {
}
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.main {
Ok(self.source.clone())
} else if let VirtualRoot::Package(package) = id.root() {
let root = self
.packages
.obtain(package)
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
let data = root.load(id.vpath())?;
let text = std::str::from_utf8(&data)
.map_err(|_| FileError::InvalidUtf8)?
.to_owned();
Ok(Source::new(id, text))
} else {
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
}
let data = self.load_bytes(id)?;
let text = std::str::from_utf8(&data)
.map_err(|_| FileError::InvalidUtf8)?
.to_owned();
Ok(Source::new(id, text))
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
if id == self.main {
Ok(Bytes::from_string(self.source.text().to_string()))
} else if let VirtualRoot::Package(package) = id.root() {
let root = self
.packages
.obtain(package)
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
root.load(id.vpath())
} else if let Some(data) = self.files.get(
&id.vpath()
.get_without_slash()
.to_string()
.replace("\\", "/"),
) {
Ok(Bytes::new(data.clone()))
} else {
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
}
let data = self.load_bytes(id)?;
Ok(Bytes::new(data))
}
fn font(&self, index: usize) -> Option<Font> {
@@ -96,7 +96,6 @@
</script>
<div class="fixed right-0 top-0 bottom-0 w-80 bg-[var(--theme-bg)] backdrop-blur-xl border-l shadow-2xl flex flex-col z-[70] transform transition-transform duration-300 border-[var(--theme-border)]">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
<div class="flex items-center gap-2">
<Icon icon="mdi:comment-text-multiple-outline" class="text-lg" />
@@ -108,7 +107,6 @@
</button>
</div>
<!-- Feed -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
{#if loading}
<div class="flex justify-center items-center h-full">
@@ -137,7 +135,6 @@
</div>
</div>
<!-- Actions -->
<div class="flex opacity-0 group-hover:opacity-100 transition-opacity gap-1">
{#if $userStore?.id === comment.user_id}
<button onclick={() => deleteComment(comment.id)} class="p-1 hover:text-red-500 rounded hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors" title="Delete">
@@ -155,7 +152,6 @@
{/if}
</div>
<!-- Input Area -->
<div class="p-4 border-t bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
<div class="relative">
<textarea
+38 -22
View File
@@ -5,7 +5,8 @@
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { autocompletion, snippetCompletion, type CompletionContext } from '@codemirror/autocomplete';
import { typst, TypstParser, typstHighlight } from 'codemirror-lang-typst';
import { Language } from '@codemirror/language';
import { Language, StreamLanguage } from '@codemirror/language';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { yCollab } from 'y-codemirror.next';
import { text, provider } from '../ts/yjs-setup';
import { getThemeExtension } from '../ts/themes';
@@ -14,6 +15,20 @@
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
import { setDiagnostics, lintGutter } from '@codemirror/lint';
let {
ytext = undefined,
awarenessProvider = undefined,
lspDocId = undefined,
enableLsp = true,
filePath = undefined
}: {
ytext?: any;
awarenessProvider?: any;
lspDocId?: string;
enableLsp?: boolean;
filePath?: string;
} = $props();
let editorContainer: HTMLElement;
let view: EditorView;
let themeCompartment = new Compartment();
@@ -136,7 +151,9 @@
}
onMount(() => {
if (!text || !provider) return;
const activeText = ytext ?? text;
const activeProvider = awarenessProvider ?? provider;
if (!activeText || !activeProvider) return;
themeStore.subscribe(t => { currentTheme = t; })();
darkModeStore.subscribe(d => { isDark = d; })();
@@ -150,16 +167,20 @@
'typst'
);
const isToml = (filePath ?? '').toLowerCase().endsWith('.toml');
const languageExtension = isToml ? StreamLanguage.define(toml) : myLang;
const completionExtensions = isToml ? [] : [autocompletion({ override: [typstCompletions] })];
state = EditorState.create({
doc: text.toString(),
doc: activeText.toString(),
extensions: [
lineNumbers(),
lintGutter(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab] as any),
myLang,
yCollab(text, provider.awareness),
autocompletion({ override: [typstCompletions] }),
languageExtension,
yCollab(activeText, activeProvider.awareness),
...completionExtensions,
themeCompartment.of(getThemeExtension(currentTheme as any, isDark)),
lspCompartment.of([]),
EditorView.lineWrapping,
@@ -219,7 +240,7 @@
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const docId = $page.params.id;
const docId = lspDocId ?? $page.params.id;
let lsHandlers: ((value: string) => void)[] = [];
let lspInitialized = false;
@@ -248,9 +269,6 @@
const msg = JSON.parse(data);
if (msg.type === 'init') {
lspInitialized = true;
// Recreate the client because the backend started a completely new LSP process
// which requires a fresh 'initialize' handshake.
client = new LSPClient({
rootUri: msg.rootUri,
timeout: 10000,
@@ -262,9 +280,7 @@
});
return;
}
} catch (err) {
// Fallthrough
}
} catch (err) {}
}
let processedData = data;
@@ -281,18 +297,18 @@
for (let h of lsHandlers) h(processedData);
};
lsSocket.onopen = () => {
// Waiting for init message from server
};
lsSocket.onopen = () => {};
}
connectLsp();
if (enableLsp && docId) {
connectLsp();
unsubscribeLspReconnect = triggerLspReconnect.subscribe((val) => {
if (val > 0) {
connectLsp();
}
});
unsubscribeLspReconnect = triggerLspReconnect.subscribe((val) => {
if (val > 0) {
connectLsp();
}
});
}
});
onDestroy(() => {
@@ -14,7 +14,6 @@
let isDrawing = false;
let currentPath = $state<{x: number, y: number}[]>([]);
// New feature states
let tool = $state<'pen' | 'highlighter' | 'eraser' | 'laser'>('laser');
let selectedColor = $state('#ef4444');
let showGrid = $state(false);
@@ -23,7 +22,6 @@
const colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#ffffff', '#000000'];
// Map from slide index to image data url so we can persist drawings when switching slides
let drawings = $state<Record<number, string>>({});
let undoStack = $state<Record<number, string[]>>({});
let redoStack = $state<Record<number, string[]>>({});
@@ -41,7 +39,6 @@
}
onMount(() => {
// Find the preview svgs from the main DOM
const previewContainers = document.querySelectorAll('.preview-container svg');
const svgStrings: string[] = [];
previewContainers.forEach(container => {
@@ -49,7 +46,6 @@
});
svgs = svgStrings;
// Request fullscreen
const el = document.getElementById('presentation-container');
if (el && el.requestFullscreen) {
el.requestFullscreen().catch(err => console.error(err));
@@ -104,7 +100,6 @@
$effect(() => {
if (canvas && currentSlide !== undefined && !showGrid) {
// Resize canvas to match the svg
const svgEl = document.getElementById('presentation-svg')?.querySelector('svg');
if (svgEl) {
const rect = svgEl.getBoundingClientRect();
@@ -126,7 +121,6 @@
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Load previous drawing if any
if (drawings[currentSlide]) {
const img = new Image();
img.onload = () => {
@@ -302,7 +296,6 @@
<div id="presentation-container" class="fixed inset-0 z-[100] flex flex-col items-center justify-center">
<!-- Laser Pointer Overlay -->
{#if tool === 'laser' && laserPos.visible && !showGrid}
<div
class="pointer-events-none fixed z-[150] w-3 h-3 bg-red-500 rounded-full shadow-[0_0_15px_5px_rgba(239,68,68,0.8)] -translate-x-1/2 -translate-y-1/2"
@@ -310,7 +303,6 @@
></div>
{/if}
<!-- Thumbnail Grid UI -->
{#if showGrid}
<div class="absolute inset-0 z-[50] p-8 overflow-y-auto">
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6 max-w-7xl mx-auto pb-24">
@@ -331,7 +323,6 @@
</div>
{/if}
<!-- Top toolbar -->
<div class="absolute top-0 inset-x-0 h-16 bg-gradient-to-b from-black/80 to-transparent flex items-center justify-between px-6 transition-opacity duration-300 z-[110] {uiVisible || showGrid ? 'opacity-100' : 'opacity-0'}">
<div class="flex items-center gap-4 bg-zinc-900/80 backdrop-blur-md px-4 py-2 rounded-xl border shadow-2xl border-[var(--theme-border)]">
<button class="p-2 rounded-lg transition-colors {tool === 'laser' ? 'bg-red-500/20 text-red-400' : 'text-gray-300 hover:bg-white/10'}" onclick={() => tool = 'laser'} title="Laser Pointer">
@@ -383,19 +374,16 @@
</div>
</div>
<!-- Slide Area -->
<div class="relative w-full h-full flex items-center justify-center p-8">
{#if svgs.length > 0 && !showGrid}
<div id="presentation-svg" class="relative max-h-full max-w-full shadow-2xl flex items-center justify-center bg-[var(--theme-bg)] text-[var(--theme-text)]">
{@html svgs[currentSlide]}
<!-- Drawing Canvas Overlay -->
<canvas
bind:this={canvas}
class="absolute inset-0 z-10 touch-none pointer-events-none"
></canvas>
<!-- Active Stroke Canvas Overlay -->
<canvas
bind:this={activeCanvas}
class="absolute inset-0 z-20 touch-none {tool === 'laser' ? 'cursor-none' : 'cursor-crosshair'}"
@@ -413,7 +401,6 @@
{/if}
</div>
<!-- Bottom Navigation -->
{#if svgs.length > 0}
<div class="absolute bottom-6 flex items-center gap-4 bg-zinc-900/80 backdrop-blur-md px-6 py-3 rounded-full border shadow-2xl transition-opacity duration-300 z-[110] {uiVisible || showGrid ? 'opacity-100' : 'opacity-0'} border-[var(--theme-border)]">
{#if svgs.length > 1}
@@ -0,0 +1,76 @@
<script lang="ts">
import Icon from '@iconify/svelte';
let {
spaceId,
onClose
}: {
spaceId: string;
onClose: () => void;
} = $props();
let version = $state('');
let publishing = $state(false);
let error = $state('');
let success = $state('');
async function publish() {
publishing = true;
error = '';
success = '';
try {
const res = await fetch('/api/packages/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id: spaceId, version: version.trim() || undefined })
});
if (!res.ok) {
error = await res.text();
} else {
const pkg = await res.json();
success = `Published @typstdrive/${pkg.name}`;
}
} catch (e) {
error = 'Network error while publishing.';
}
publishing = false;
}
</script>
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={onClose} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md p-6" onclick={(e) => e.stopPropagation()} role="presentation">
<div class="flex items-center gap-2 mb-4">
<Icon icon="mdi:package-variant-closed" class="text-2xl text-purple-500" />
<h2 class="text-lg font-bold">Publish as Package</h2>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Snapshots this space's files into an immutable package version, importable instance-wide as
<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/&lt;name&gt;:&lt;version&gt;</code>.
The name, version and entrypoint come from your <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">typst.toml</code>.
</p>
<label class="block text-sm font-medium mb-1" for="pkg-version">Version override (optional)</label>
<input
id="pkg-version"
bind:value={version}
placeholder="e.g. 0.1.0 (defaults to typst.toml)"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-white/10 bg-transparent text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-purple-500/40"
/>
{#if error}
<div class="text-sm text-red-600 dark:text-red-400 mb-3 break-words">{error}</div>
{/if}
{#if success}
<div class="text-sm text-green-600 dark:text-green-400 mb-3">{success}</div>
{/if}
<div class="flex justify-end gap-2">
<button onclick={onClose} class="px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10">Close</button>
<button onclick={publish} disabled={publishing} class="px-4 py-2 text-sm rounded-lg bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2">
{#if publishing}<Icon icon="mdi:loading" class="animate-spin" />{/if}
Publish
</button>
</div>
</div>
</div>
+67 -7
View File
@@ -5,15 +5,41 @@
let { onClose, docId = undefined } = $props<{ onClose: () => void, docId?: string }>();
type CollaboratorView = { id: string; user_id: string; username: string; email: string; role: string; created_at: string };
let link = $state('');
let copied = $state(false);
let role = $state('editor');
let collaborators = $state<CollaboratorView[]>([]);
let collabLoading = $state(false);
let removingId = $state<string | null>(null);
let inviteEmail = $state('');
let inviteRole = $state('editor');
let inviteStatus = $state<'idle' | 'loading' | 'success' | 'error'>('idle');
let inviteMessage = $state('');
async function loadCollaborators() {
if (!docId) return;
collabLoading = true;
try {
const res = await fetch(`/api/docs/${docId}/collaborators`);
if (res.ok) collaborators = await res.json();
} catch {}
collabLoading = false;
}
async function removeCollaborator(collab: CollaboratorView) {
if (!docId) return;
removingId = collab.id;
try {
const res = await fetch(`/api/docs/${docId}/collaborators/${collab.id}`, { method: 'DELETE' });
if (res.ok) collaborators = collaborators.filter(c => c.id !== collab.id);
} catch {}
removingId = null;
}
async function inviteUser(e: Event) {
e.preventDefault();
if (!docId || !inviteEmail.trim()) return;
@@ -24,9 +50,7 @@
try {
const res = await fetch(`/api/docs/${docId}/invite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole })
});
@@ -34,25 +58,23 @@
inviteStatus = 'success';
inviteMessage = 'User invited successfully!';
inviteEmail = '';
loadCollaborators();
} else {
const text = await res.text();
inviteStatus = 'error';
inviteMessage = text || 'Failed to invite user';
}
} catch (err) {
console.error(err);
inviteStatus = 'error';
inviteMessage = 'Network error occurred';
}
}
onMount(() => {
const baseUrl = window.location.origin;
const docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href;
link = `${docUrl}?role=${role}`;
loadCollaborators();
});
$effect(() => {
@@ -125,6 +147,44 @@
{/if}
</div>
{#if collabLoading}
<div class="flex items-center gap-2 text-sm text-gray-400 dark:text-gray-500 py-1">
<Icon icon="mdi:loading" class="animate-spin text-base" />
Loading collaborators...
</div>
{:else if collaborators.length > 0}
<div class="h-px bg-gray-200 dark:bg-zinc-800/50"></div>
<div class="space-y-2">
<div class="text-sm font-semibold text-[var(--theme-text)]" style="color: var(--theme-text);">People with access</div>
{#each collaborators as collab (collab.id)}
<div class="flex items-center gap-3 py-1.5">
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 text-sm font-bold flex-shrink-0">
{collab.username[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{collab.username}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{collab.email}</p>
</div>
<span class="text-xs font-semibold px-2 py-0.5 rounded-full flex-shrink-0 {collab.role === 'editor' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300' : 'bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-400'}">
{collab.role}
</span>
<button
onclick={() => removeCollaborator(collab)}
disabled={removingId === collab.id}
title="Remove collaborator"
class="flex-shrink-0 p-1 rounded text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{#if removingId === collab.id}
<Icon icon="mdi:loading" class="text-base animate-spin" />
{:else}
<Icon icon="mdi:close" class="text-base" />
{/if}
</button>
</div>
{/each}
</div>
{/if}
<div class="h-px bg-gray-200 dark:bg-zinc-800/50"></div>
<div class="space-y-3">
+1 -2
View File
@@ -414,7 +414,6 @@
{title}
</h1>
<!-- Sync status removed from here and moved to Footer -->
</div>
@@ -716,7 +715,7 @@
</header>
{#if isShareModalOpen}
<ShareModal onClose={() => (isShareModalOpen = false)} />
<ShareModal {docId} onClose={() => (isShareModalOpen = false)} />
{/if}
{#if isPageSettingsOpen}
@@ -55,7 +55,6 @@
</script>
<div class="fixed right-0 top-0 bottom-0 w-80 bg-[var(--theme-bg)] backdrop-blur-xl border-l shadow-2xl flex flex-col z-[70] transform transition-transform duration-300 border-[var(--theme-border)]">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b bg-gray-50/50 bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
<div class="flex items-center gap-2">
<Icon icon="mdi:history" class="text-lg" />
@@ -67,7 +66,6 @@
</button>
</div>
<!-- Feed -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
{#if loading}
<div class="flex justify-center items-center h-full">
@@ -13,9 +13,9 @@
}
</script>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
<div tabindex="-1" class="bg-white/80 dark:bg-black/40 backdrop-blur-xl rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-doc-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="flex justify-between items-center p-5 border-b border-gray-100 dark:border-white/10">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
<div tabindex="-1" class="bg-[var(--theme-bg)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-doc-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="flex justify-between items-center p-5 border-b border-gray-200 dark:border-white/10">
<h2 id="create-doc-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:file-document-plus" class="text-blue-500 text-xl" />
Create Document
@@ -33,7 +33,7 @@
type="text"
required
bind:value={newDocTitle}
class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
class="w-full bg-black/5 dark:bg-white/5 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Untitled Document"
/>
</div>
@@ -13,9 +13,9 @@
}
</script>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
<div tabindex="-1" class="bg-white/80 dark:bg-black/40 backdrop-blur-xl rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-folder-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="flex justify-between items-center p-5 border-b border-gray-100 dark:border-white/10">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
<div tabindex="-1" class="bg-[var(--theme-bg)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-folder-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="flex justify-between items-center p-5 border-b border-gray-200 dark:border-white/10">
<h2 id="create-folder-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:folder-plus" class="text-yellow-500 text-xl" />
Create Folder
@@ -33,7 +33,7 @@
type="text"
required
bind:value={newFolderName}
class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
class="w-full bg-black/5 dark:bg-white/5 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="New Folder"
/>
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import Icon from '@iconify/svelte';
let { createSpace, onClose } = $props<{
createSpace: (name: string) => void,
onClose: () => void
}>();
let newSpaceName = $state('Untitled Space');
function onSubmit(e: Event) {
e.preventDefault();
createSpace(newSpaceName);
}
</script>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
<div tabindex="-1" class="bg-[var(--theme-bg)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-space-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="flex justify-between items-center p-5 border-b border-gray-200 dark:border-white/10">
<h2 id="create-space-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:folder-multiple-plus" class="text-blue-500 text-xl" />
Create Space
</h2>
<button onclick={onClose} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
<Icon icon="mdi:close" class="text-xl" />
</button>
</div>
<form onsubmit={onSubmit} class="p-5 space-y-4">
<div class="space-y-2">
<label for="space-name-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Space Name</label>
<input
id="space-name-input"
type="text"
required
bind:value={newSpaceName}
class="w-full bg-black/5 dark:bg-white/5 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Untitled Space"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">A multi-file workspace, seeded with a <code class="font-mono">typst.toml</code> and <code class="font-mono">main.typ</code>.</p>
</div>
<div class="pt-4 flex justify-end gap-3">
<button type="button" onclick={onClose} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
Cancel
</button>
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm flex items-center gap-2">
<Icon icon="mdi:plus" class="text-lg" />
Create
</button>
</div>
</form>
</div>
</div>
+6 -7
View File
@@ -21,7 +21,6 @@
} else {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
// If there's less than 200px below the button, open upwards
if (window.innerHeight - rect.bottom < 200) {
dropUp = true;
} else {
@@ -70,21 +69,21 @@
</button>
{#if activeMenu === doc.id}
<div class="absolute right-0 {dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
<button onclick={(e) => { e.stopPropagation(); openInfo(doc, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
<div class="absolute right-0 {dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
<button onclick={(e) => { e.stopPropagation(); openInfo(doc, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:information-outline" class="text-lg text-blue-500" />
View Info
</button>
<button onclick={(e) => { e.stopPropagation(); openRename(doc.id, doc.title, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
<button onclick={(e) => { e.stopPropagation(); openRename(doc.id, doc.title, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
Rename
</button>
<button onclick={(e) => { e.stopPropagation(); shareItem(doc); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
<button onclick={(e) => { e.stopPropagation(); shareItem(doc); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:share-variant-outline" class="text-lg text-green-500" />
Share
</button>
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
<button onclick={(e) => { e.stopPropagation(); deleteDoc(doc.id, doc.title); }} class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 flex items-center gap-2">
<div class="h-px bg-gray-200 dark:bg-white/10 my-1"></div>
<button onclick={(e) => { e.stopPropagation(); deleteDoc(doc.id, doc.title); }} class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-500/10 flex items-center gap-2">
<Icon icon="mdi:trash-can-outline" class="text-lg" />
Delete
</button>
@@ -23,7 +23,17 @@
</div>
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
<a href="/spaces" class="text-sm font-medium text-gray-600 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Spaces">
<Icon icon="mdi:folder-multiple-outline" class="text-xl" />
</a>
<a href="/packages" class="text-sm font-medium text-gray-600 hover:text-purple-600 dark:text-gray-300 dark:hover:text-purple-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Packages">
<Icon icon="mdi:package-variant-closed" class="text-xl" />
</a>
<a href="/api-docs" class="text-sm font-medium text-gray-600 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="API Docs">
<Icon icon="mdi:api" class="text-xl" />
</a>
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-gray-600 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Typst Docs">
<Icon icon="mdi:book-open-page-variant-outline" class="text-xl" />
</a>
@@ -0,0 +1,85 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { goto } from '$app/navigation';
let { space, activeMenu, setActiveMenu, openInfo, openRename, deleteSpace } = $props<{
space: any;
activeMenu: string | null;
setActiveMenu: (id: string | null) => void;
openInfo: (space: any) => void;
openRename: (id: string, name: string) => void;
deleteSpace: (id: string, name: string) => void;
}>();
let dropUp = $state(false);
function toggleMenu(e: MouseEvent) {
e.stopPropagation();
if (activeMenu === space.id) {
setActiveMenu(null);
} else {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
dropUp = window.innerHeight - rect.bottom < 200;
setActiveMenu(space.id);
}
}
</script>
<div
class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 flex flex-col hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500/50 transition-all duration-200 relative group transform hover:-translate-y-1 cursor-pointer overflow-visible"
role="button"
tabindex="0"
onclick={() => goto(`/space/${space.id}`)}
onkeydown={(e) => e.key === 'Enter' && goto(`/space/${space.id}`)}
>
<div class="h-40 w-full bg-gray-50 dark:bg-black/40 rounded-t-xl overflow-hidden flex items-center justify-center border-b border-gray-100 dark:border-white/10 relative pointer-events-none">
{#if space.thumbnail_svg}
<div class="w-full h-full flex items-start justify-center bg-white transition-transform duration-300 group-hover:scale-110">
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(space.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
</div>
{:else}
<div class="p-4 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full transition-transform duration-300 group-hover:scale-110">
<Icon icon="mdi:folder-multiple-outline" class="text-4xl" />
</div>
{/if}
</div>
<div class="p-4 flex flex-col flex-grow">
<div class="flex items-start justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate pr-2 pointer-events-none" title={space.name}>{space.name}</h3>
<div class="relative action-menu-container">
<button aria-label="Space actions" onclick={toggleMenu} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors p-1 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 pointer-events-auto">
<Icon icon="mdi:dots-vertical" class="text-xl" />
</button>
{#if activeMenu === space.id}
<div class="absolute right-0 {dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
<button onclick={(e) => { e.stopPropagation(); openInfo(space); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:information-outline" class="text-lg text-blue-500" />
View Info
</button>
<button onclick={(e) => { e.stopPropagation(); openRename(space.id, space.name); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
Rename
</button>
<button onclick={(e) => { e.stopPropagation(); goto(`/space/${space.id}`); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:open-in-new" class="text-lg text-green-500" />
Open
</button>
<div class="h-px bg-gray-200 dark:bg-white/10 my-1"></div>
<button onclick={(e) => { e.stopPropagation(); deleteSpace(space.id, space.name); }} class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-500/10 flex items-center gap-2">
<Icon icon="mdi:trash-can-outline" class="text-lg" />
Delete
</button>
</div>
{/if}
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-2 pointer-events-none">
<Icon icon="mdi:clock-outline" class="text-sm" />
Edited {new Date(space.updated_at.endsWith('Z') ? space.updated_at : space.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
</div>
</div>
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import Icon from '@iconify/svelte';
interface SpaceFile {
id: string;
path: string;
kind: string;
}
let {
files = [],
activeFileId = '',
entrypoint = 'main.typ',
readOnly = false,
onSelect,
onCreate,
onUpload,
onRename,
onDelete,
onSetEntry
}: {
files?: SpaceFile[];
activeFileId?: string;
entrypoint?: string;
readOnly?: boolean;
onSelect: (file: SpaceFile) => void;
onCreate: (path: string) => void;
onUpload: (fileList: FileList) => void;
onRename: (file: SpaceFile, path: string) => void;
onDelete: (file: SpaceFile) => void;
onSetEntry: (file: SpaceFile) => void;
} = $props();
let fileInput: HTMLInputElement = $state()!;
function iconFor(path: string): string {
const lower = path.toLowerCase();
if (lower.endsWith('.typ')) return 'mdi:language-markdown-outline';
if (lower.endsWith('.toml')) return 'mdi:cog-outline';
if (lower.endsWith('.bib')) return 'mdi:book-open-variant';
if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.svg') || lower.endsWith('.gif')) return 'mdi:image-outline';
if (lower.endsWith('.ttf') || lower.endsWith('.otf')) return 'mdi:format-font';
return 'mdi:file-outline';
}
function handleCreate() {
const path = prompt('New file path (e.g. chapter.typ, refs.bib):');
if (path && path.trim()) onCreate(path.trim());
}
function handleRename(file: SpaceFile) {
const path = prompt('Rename file to:', file.path);
if (path && path.trim() && path.trim() !== file.path) onRename(file, path.trim());
}
</script>
<div class="h-full flex flex-col bg-[var(--theme-bg)] border-r border-gray-200 dark:border-white/10">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Files</span>
{#if !readOnly}
<div class="flex items-center gap-1">
<button onclick={handleCreate} title="New file" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-gray-300">
<Icon icon="mdi:file-plus-outline" class="text-lg" />
</button>
<button onclick={() => fileInput.click()} title="Upload file" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-gray-300">
<Icon icon="mdi:upload" class="text-lg" />
</button>
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={(e) => { const t = e.target as HTMLInputElement; if (t.files) onUpload(t.files); t.value = ''; }} />
</div>
{/if}
</div>
<div class="flex-1 overflow-y-auto py-1">
{#each files as file (file.id)}
<div class="group flex items-center gap-1 px-2 py-1.5 text-sm cursor-pointer {activeFileId === file.id ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5'}">
<button class="flex items-center gap-2 flex-1 min-w-0 text-left" onclick={() => onSelect(file)}>
<Icon icon={iconFor(file.path)} class="text-base flex-shrink-0" />
<span class="truncate">{file.path}</span>
{#if file.path === entrypoint}
<span title="Entrypoint">
<Icon icon="mdi:star" class="text-amber-500 text-xs flex-shrink-0" />
</span>
{/if}
</button>
{#if !readOnly}
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{#if file.kind === 'text' && file.path.toLowerCase().endsWith('.typ') && file.path !== entrypoint}
<button onclick={() => onSetEntry(file)} title="Set as entrypoint" class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-white/10 text-gray-500">
<Icon icon="mdi:star-outline" class="text-sm" />
</button>
{/if}
<button onclick={() => handleRename(file)} title="Rename" class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-white/10 text-gray-500">
<Icon icon="mdi:pencil-outline" class="text-sm" />
</button>
<button onclick={() => onDelete(file)} title="Delete" class="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-500 hover:text-red-600">
<Icon icon="mdi:trash-can-outline" class="text-sm" />
</button>
</div>
{/if}
</div>
{/each}
</div>
</div>
@@ -0,0 +1,523 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Icon from '@iconify/svelte';
import { undo, redo } from '@codemirror/commands';
import {
connectedUsers,
darkModeStore,
editorViewStore,
documentZoomStore,
previewOpenStore
} from '../../ts/store';
import { exportSpace } from '../../ts/typst-api';
import ThemePicker from '../ThemePicker.svelte';
import PageSettingsModal from '../PageSettingsModal.svelte';
import PresentationMode from '../PresentationMode.svelte';
let {
spaceName = 'Space',
spaceId,
entrypoint = 'main.typ',
role = 'owner',
activeText = null,
activePath = '',
getAllText,
onPublish,
onFilesChanged
}: {
spaceName?: string;
spaceId: string;
entrypoint?: string;
role?: string;
activeText?: any;
activePath?: string;
getAllText: () => Record<string, string>;
onPublish: () => void;
onFilesChanged: () => void;
} = $props();
let isViewer = $derived(role === 'viewer');
let uploadedFonts = $state<string[]>([]);
$effect(() => {
fetch('/api/fonts')
.then((res) => res.json())
.then((data) => { uploadedFonts = Array.isArray(data) ? data : []; })
.catch(() => {});
});
let isPageSettingsOpen = $state(false);
let isPresentationOpen = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
let activeMenu = $state<string | null>(null);
let showInfoModal = $state(false);
let showRenameModal = $state(false);
let showDeleteModal = $state(false);
let renameName = $state('');
$effect(() => { renameName = spaceName; });
function safeName() {
return spaceName.replace(/[^a-z0-9_-]/gi, '_');
}
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
if (format === 'typ') {
const content = activeText ? activeText.toString() : '';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(activePath || 'main').replace(/\.[^.]+$/, '')}.typ`;
a.click();
URL.revokeObjectURL(url);
return;
}
exportSpace(spaceId, getAllText(), format, safeName()).catch((e) => {
console.error(`Export to ${format} failed:`, e);
alert(`Failed to export as ${format.toUpperCase()}`);
});
}
function handlePrint() {
fetch(`/api/export/pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id: spaceId, files: getAllText() })
})
.then((res) => {
if (!res.ok) throw new Error('Print failed');
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
iframe.src = url;
document.body.appendChild(iframe);
iframe.onload = () => setTimeout(() => iframe.contentWindow?.print(), 100);
})
.catch((e) => {
console.error('Print failed:', e);
alert('Failed to print');
});
}
function handlePandocExport(format: string) {
const files = getAllText();
const content = files[entrypoint] ?? (activeText ? activeText.toString() : '');
fetch(`/api/export/pandoc/${format}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content })
})
.then((res) => {
if (!res.ok) throw new Error('Export failed');
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
let ext = format;
if (format === 'latex') ext = 'tex';
if (format === 'markdown') ext = 'md';
a.download = `${safeName()}.${ext}`;
a.click();
URL.revokeObjectURL(url);
})
.catch((err) => {
console.error(err);
alert(`Failed to export as ${format}`);
});
}
function applyFormat(prefix: string, suffix: string, defaultText: string = '') {
const view = $editorViewStore;
if (!view) return;
const selection = view.state.selection.main;
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
const replacement = prefix + (selectedText || defaultText) + suffix;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: replacement },
selection: { anchor: selection.from + prefix.length, head: selection.from + prefix.length + (selectedText || defaultText).length }
});
view.focus();
}
function insertTypstConfig(setting: string, value: string) {
if (!activeText) return;
const content = activeText.toString();
const regex = new RegExp(`^#set\\s+${setting}\\s*\\(([^)]*)\\)`, 'm');
const match = content.match(regex);
const [propKey, ...propValParts] = value.split(':');
const propKeyTrimmed = propKey.trim();
const propValTrimmed = propValParts.join(':').trim();
if (match) {
const index = match.index!;
const oldArgs = match[1];
const propRegex = new RegExp(`${propKeyTrimmed}\\s*:\\s*(?:\\([^)]*\\)|"[^"]*"|[^,)]+)`);
let newArgs;
if (propRegex.test(oldArgs)) {
newArgs = oldArgs.replace(propRegex, `${propKeyTrimmed}: ${propValTrimmed}`);
} else {
newArgs = oldArgs.trim() ? `${oldArgs}, ${propKeyTrimmed}: ${propValTrimmed}` : `${propKeyTrimmed}: ${propValTrimmed}`;
}
activeText.delete(index, match[0].length);
activeText.insert(index, `#set ${setting}(${newArgs})`);
} else {
activeText.insert(0, `#set ${setting}(${value})\n`);
}
}
function handlePageSettings(settings: Record<string, string>, docSettings: Record<string, string>) {
if (!activeText) return;
if (Object.keys(settings).length > 0) {
const args = Object.entries(settings).map(([k, v]) => `${k}: ${v}`).join(', ');
const regex = new RegExp(`^#set\\s+page\\s*\\(([^)]*)\\)`, 'm');
const match = activeText.toString().match(regex);
if (match) {
activeText.delete(match.index!, match[0].length);
activeText.insert(match.index!, `#set page(${args})`);
} else {
activeText.insert(0, `#set page(${args})\n`);
}
}
if (Object.keys(docSettings).length > 0) {
const docArgs = Object.entries(docSettings).map(([k, v]) => `${k}: ${v}`).join(', ');
const docRegex = new RegExp(`^#set\\s+document\\s*\\(([^)]*)\\)`, 'm');
const docMatch = activeText.toString().match(docRegex);
if (docMatch) {
activeText.delete(docMatch.index!, docMatch[0].length);
activeText.insert(docMatch.index!, `#set document(${docArgs})`);
} else {
activeText.insert(0, `#set document(${docArgs})\n`);
}
}
}
function parseSettings() {
if (!activeText) return {};
const content = activeText.toString();
const settings: Record<string, string> = {};
const pageMatch = content.match(/^#set\s+page\s*\(([^)]*)\)/m);
if (pageMatch) {
for (const arg of pageMatch[1].split(',').map((s: string) => s.trim())) {
const [k, ...vParts] = arg.split(':').map((s: string) => s.trim());
if (k && vParts.length) {
let v = vParts.join(':').trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
settings[k] = v;
}
}
}
return settings;
}
function handleUpload(e: Event) {
const target = e.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return;
const file = target.files[0];
const form = new FormData();
form.append('file', file);
fetch(`/api/spaces/${spaceId}/files/upload`, { method: 'POST', body: form })
.then((res) => res.json())
.then(() => {
const lower = file.name.toLowerCase();
const view = $editorViewStore;
if (view) {
const selection = view.state.selection.main;
let replacement = '';
if (lower.endsWith('.ttf') || lower.endsWith('.otf')) {
replacement = `// Font ${file.name} uploaded — use #set text(font: "Family Name")\n`;
} else {
replacement = `#image("${file.name}")\n`;
}
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: replacement },
selection: { anchor: selection.from + replacement.length }
});
view.focus();
}
onFilesChanged();
})
.catch((err) => {
console.error(err);
alert('Failed to upload file');
});
target.value = '';
}
function getInitials(name: string) {
return name.substring(0, 2).toUpperCase();
}
function submitRename(e: Event) {
e.preventDefault();
if (renameName && renameName !== spaceName) {
fetch(`/api/spaces/${spaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameName })
}).then((res) => { if (res.ok) window.location.reload(); });
}
showRenameModal = false;
}
function confirmDelete() {
fetch(`/api/spaces/${spaceId}`, { method: 'DELETE' }).then((res) => {
if (res.ok) goto('/spaces');
});
}
function handleWindowClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.action-menu-container')) activeMenu = null;
}
function handleUndo() { activeMenu = null; if ($editorViewStore) undo($editorViewStore); }
function handleRedo() { activeMenu = null; if ($editorViewStore) redo($editorViewStore); }
</script>
<svelte:window onclick={handleWindowClick} />
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] select-none w-full relative z-[70]">
<div class="flex items-center justify-between px-4 py-2.5">
<div class="flex items-center gap-3">
<button onclick={() => goto('/spaces')} class="p-1.5 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Spaces">
<Icon icon="mdi:arrow-left" class="text-xl" />
</button>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<Icon icon="mdi:folder-multiple-outline" class="text-blue-500 text-base" />
<h1 class="text-[16px] font-semibold text-gray-900 dark:text-white tracking-tight truncate max-w-[200px] md:max-w-xs" title={spaceName}>{spaceName}</h1>
</div>
<div class="flex items-center gap-0.5 text-[13px] font-medium text-gray-600 dark:text-gray-300 -ml-1 action-menu-container">
<div class="relative">
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'file' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">File</button>
{#if activeMenu === 'file'}
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100] max-h-[calc(100vh-8rem)] overflow-y-auto">
<button onclick={() => { activeMenu = null; goto('/spaces'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">All Spaces</button>
<button onclick={() => { activeMenu = null; showInfoModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Space Info</button>
{#if !isViewer}
<div class="h-px bg-[var(--theme-border)] my-1"></div>
<button onclick={() => { activeMenu = null; renameName = spaceName; showRenameModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Rename</button>
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Page Settings</button>
{#if role === 'owner'}
<button onclick={() => { activeMenu = null; onPublish(); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Publish as Package</button>
{/if}
{/if}
<div class="h-px bg-[var(--theme-border)] my-1"></div>
<div class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wider opacity-60">Download</div>
<button onclick={() => { activeMenu = null; handlePrint(); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:printer" /> Print</button>
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:code-braces" /> active .typ</button>
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:file-pdf-box" /> .pdf document</button>
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:image" /> .png image</button>
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:svg" /> .svg graphics</button>
<div class="h-px bg-[var(--theme-border)] my-1"></div>
<div class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wider opacity-60">Export (Pandoc)</div>
<button onclick={() => { activeMenu = null; handlePandocExport('docx'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">Word (.docx)</button>
<button onclick={() => { activeMenu = null; handlePandocExport('latex'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">LaTeX (.tex)</button>
<button onclick={() => { activeMenu = null; handlePandocExport('markdown'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">Markdown (.md)</button>
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">HTML (.html)</button>
{#if role === 'owner'}
<div class="h-px bg-[var(--theme-border)] my-1"></div>
<button onclick={() => { activeMenu = null; showDeleteModal = true; }} class="w-full text-left px-4 py-1.5 text-sm text-red-500 hover:bg-red-500/10">Delete Space</button>
{/if}
</div>
{/if}
</div>
{#if !isViewer}
<div class="relative">
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'edit' ? null : 'edit'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'edit' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">Edit</button>
{#if activeMenu === 'edit'}
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
<button onclick={handleUndo} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Undo (Ctrl+Z)</button>
<button onclick={handleRedo} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Redo (Ctrl+Y)</button>
</div>
{/if}
</div>
{/if}
<div class="relative">
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'view' ? null : 'view'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'view' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">View</button>
{#if activeMenu === 'view'}
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
<button onclick={() => { activeMenu = null; $previewOpenStore = !$previewOpenStore; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center justify-between">Preview<Icon icon={$previewOpenStore ? 'mdi:check' : ''} class="text-sm" /></button>
<button onclick={() => { activeMenu = null; $darkModeStore = !$darkModeStore; document.documentElement.classList.toggle('dark', $darkModeStore); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center justify-between">Dark Mode<Icon icon={$darkModeStore ? 'mdi:check' : ''} class="text-sm" /></button>
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
{#if $connectedUsers.length > 0}
<div class="flex items-center -space-x-2 mr-2">
{#each $connectedUsers as user}
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-white dark:border-zinc-950 shadow-sm" style="background-color: {user.color}; z-index: {user.isLocal ? 10 : 1};" title={user.name + (user.isLocal ? ' (You)' : '')}>{getInitials(user.name)}</div>
{/each}
</div>
{/if}
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
{#if !isViewer}
<button onclick={() => (isPresentationOpen = true)} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors">
<Icon icon="mdi:presentation-play" class="text-[16px]" /> Present
</button>
{#if role === 'owner'}
<button onclick={onPublish} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-md transition-colors">
<Icon icon="mdi:package-variant-closed" class="text-[16px]" /> Publish
</button>
{/if}
{/if}
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
<button onclick={() => ($previewOpenStore = !$previewOpenStore)} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium transition-colors rounded-md {$previewOpenStore ? 'text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10' : 'text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20'}" title={$previewOpenStore ? 'Hide preview' : 'Show preview'}>
<Icon icon={$previewOpenStore ? 'mdi:eye-off-outline' : 'mdi:eye-outline'} class="text-[16px]" /> Preview
</button>
<button onclick={handlePrint} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors" title="Print">
<Icon icon="mdi:printer" class="text-[16px]" /> Print
</button>
<div class="relative action-menu-container">
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'export' ? null : 'export'; }} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 rounded-md transition-colors {activeMenu === 'export' ? 'ring-2 ring-blue-500/30' : ''}">
<Icon icon="mdi:export-variant" class="text-[16px]" /> Export <Icon icon="mdi:chevron-down" class="text-sm opacity-70" />
</button>
{#if activeMenu === 'export'}
<div class="absolute right-0 top-full mt-1 w-44 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:file-pdf-box" class="text-base text-red-500" /> PDF document</button>
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:code-braces" class="text-base text-purple-500" /> active .typ</button>
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:svg" class="text-base text-orange-500" /> SVG graphics</button>
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:image" class="text-base text-blue-500" /> PNG image</button>
</div>
{/if}
</div>
</div>
</div>
<div class="flex items-center px-4 py-1.5 bg-white/50 dark:bg-black/10 border-t border-gray-200/60 dark:border-white/10 gap-4 overflow-x-auto no-scrollbar">
<div class="flex items-center gap-1">
<button onclick={() => applyFormat('*', '*', 'bold')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Bold"><Icon icon="mdi:format-bold" class="text-lg" /></button>
<button onclick={() => applyFormat('_', '_', 'italic')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Italic"><Icon icon="mdi:format-italic" class="text-lg" /></button>
<button onclick={() => applyFormat('`', '`', 'code')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Code"><Icon icon="mdi:code-tags" class="text-lg" /></button>
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
<button onclick={() => applyFormat('$ ', ' $', 'x = y')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Math (Inline)"><Icon icon="mdi:sigma" class="text-lg" /></button>
<button onclick={() => applyFormat('- ', '', 'List item')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Bullet List"><Icon icon="mdi:format-list-bulleted" class="text-lg" /></button>
<button onclick={() => applyFormat('+ ', '', 'Numbered item')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Numbered List"><Icon icon="mdi:format-list-numbered" class="text-lg" /></button>
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" accept="image/*,.ttf,.otf" />
{#if !isViewer}
<button onclick={() => fileInput?.click()} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Upload Image / Font"><Icon icon="mdi:image-plus" class="text-lg" /></button>
{/if}
</div>
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
<div class="flex items-center gap-2">
<label for="space-font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
<select id="space-font-select" onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)} class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-xs rounded shadow-sm block py-1 pl-2 pr-6 appearance-none cursor-pointer">
<option value="New Computer Modern">Default (New CM)</option>
<option value="Libertinus Serif">Libertinus Serif</option>
<option value="PT Sans">PT Sans</option>
<option value="Roboto">Roboto</option>
{#if uploadedFonts.length > 0}
<optgroup label="Uploaded Fonts">
{#each uploadedFonts as font}
<option value={font}>{font}</option>
{/each}
</optgroup>
{/if}
</select>
</div>
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
{#if !isViewer}
<button onclick={() => (isPageSettingsOpen = true)} class="flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] rounded shadow-sm transition-colors opacity-90 hover:opacity-100">
<Icon icon="mdi:file-document-edit-outline" class="text-sm" /> Page Settings
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
{/if}
<div class="flex items-center gap-1 bg-white dark:bg-black/20 border border-gray-300 dark:border-white/20 rounded shadow-sm overflow-hidden">
<button onclick={() => $documentZoomStore = Math.max(10, $documentZoomStore - 10)} class="px-2 py-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-white dark:hover:bg-white/10 transition-colors" title="Zoom Out"><Icon icon="mdi:minus" class="text-sm" /></button>
<span role="button" tabindex="0" onkeydown={(e) => { if (e.key === 'Enter') $documentZoomStore = 100; }} class="text-[11px] font-semibold text-gray-700 dark:text-gray-200 min-w-[3rem] text-center select-none" ondblclick={() => $documentZoomStore = 100}>{$documentZoomStore}%</span>
<button onclick={() => $documentZoomStore = Math.min(500, $documentZoomStore + 10)} class="px-2 py-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-white dark:hover:bg-white/10 transition-colors" title="Zoom In"><Icon icon="mdi:plus" class="text-sm" /></button>
</div>
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
<ThemePicker />
<div class="flex-grow"></div>
</div>
</header>
{#if isPageSettingsOpen}
<PageSettingsModal onClose={() => (isPageSettingsOpen = false)} onApply={handlePageSettings} currentSettings={parseSettings()} />
{/if}
{#if isPresentationOpen}
<PresentationMode onClose={() => (isPresentationOpen = false)} />
{/if}
{#if showInfoModal}
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onclick={() => showInfoModal = false} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
<div class="p-6 border-b border-gray-100 dark:border-white/10 flex items-center gap-3">
<Icon icon="mdi:folder-multiple-outline" class="text-xl text-blue-500" />
<h3 class="text-lg font-semibold flex-grow truncate">{spaceName}</h3>
</div>
<div class="p-6 space-y-4 text-sm">
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p><p>Space (multi-file)</p></div>
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Entrypoint</p><p class="font-mono">{entrypoint}</p></div>
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Your role</p><p class="capitalize">{role}</p></div>
</div>
<div class="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 flex justify-end">
<button onclick={() => showInfoModal = false} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg">Close</button>
</div>
</div>
</div>
{/if}
{#if showRenameModal}
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onclick={() => showRenameModal = false} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
<form onsubmit={submitRename} class="p-6">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Space</h3>
<input type="text" required bind:value={renameName} class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div class="pt-6 flex justify-end gap-3">
<button type="button" onclick={() => showRenameModal = false} class="px-4 py-2 text-sm font-medium hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg">Cancel</button>
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium">Save</button>
</div>
</form>
</div>
</div>
{/if}
{#if showDeleteModal}
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onclick={() => showDeleteModal = false} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:trash-can-outline" class="text-red-500" /> Delete Space</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm mb-6">Delete this space and all its files? This cannot be undone.</p>
<div class="flex justify-end gap-3">
<button type="button" onclick={() => showDeleteModal = false} class="px-4 py-2 text-sm font-medium hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg">Cancel</button>
<button type="button" onclick={confirmDelete} class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg text-sm font-medium">Delete</button>
</div>
</div>
</div>
</div>
{/if}
+29
View File
@@ -20,6 +20,35 @@ export async function compileTypst(text: string, document_id?: string): Promise<
return await res.json();
}
export async function compileSpace(space_id: string, files: Record<string, string>): Promise<CompileResponse> {
const res = await fetch('/api/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id, files }),
});
return await res.json();
}
export function exportSpace(space_id: string, files: Record<string, string>, format: 'pdf' | 'png' | 'svg', title: string = 'document') {
return fetch(`/api/export/${format}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id, files }),
})
.then((res) => {
if (!res.ok) throw new Error('Export failed');
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title}.${format}`;
a.click();
URL.revokeObjectURL(url);
});
}
export function exportTypst(text: string, format: 'pdf' | 'png' | 'svg', title: string = 'document', document_id?: string) {
const form = document.createElement('form');
form.method = 'POST';
+114
View File
@@ -0,0 +1,114 @@
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { get } from 'svelte/store';
import { userStore } from './auth';
import { connectionStatus, connectedUsers } from './store';
import type { AwarenessUser } from './store';
export interface OpenFile {
fileId: string;
path: string;
doc: Y.Doc;
text: Y.Text;
provider: WebsocketProvider;
}
const userColors = [
'#30bced', '#6eeb83', '#ffbc42', '#ecd444', '#ee6352',
'#9ac2c9', '#8acb88', '#1be7ff', '#ff0054', '#9e0059'
];
const open = new Map<string, OpenFile>();
let spaceId: string | null = null;
const TEXT_NAME = 'typst';
export function setSpace(id: string) {
spaceId = id;
}
export function openFile(fileId: string, path: string): OpenFile {
const existing = open.get(fileId);
if (existing) return existing;
if (!spaceId) throw new Error('Space not set');
const doc = new Y.Doc();
const text = doc.getText(TEXT_NAME);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
connectionStatus.set('connecting');
const provider = new WebsocketProvider(`${protocol}//${host}/yjs`, `space:${spaceId}:${fileId}`, doc);
const user = get(userStore);
const color = userColors[Math.floor(Math.random() * userColors.length)];
provider.awareness.setLocalStateField('user', {
name: user?.username || 'Anonymous',
color,
colorLight: color + '33'
});
provider.on('status', (event: { status: string }) => {
connectionStatus.set(event.status);
});
provider.awareness.on('change', () => {
const states = provider.awareness.getStates();
const localId = provider.awareness.clientID;
const uniqueUsers = new Map<string, AwarenessUser>();
states.forEach((state, clientId) => {
if (state.user) {
const isLocal = clientId === localId;
const userObj = { clientId, ...state.user, isLocal };
if (isLocal) {
uniqueUsers.set(state.user.name, userObj);
} else if (!uniqueUsers.has(state.user.name) || !uniqueUsers.get(state.user.name)!.isLocal) {
uniqueUsers.set(state.user.name, userObj);
}
}
});
connectedUsers.set(Array.from(uniqueUsers.values()));
});
const entry: OpenFile = { fileId, path, doc, text, provider };
open.set(fileId, entry);
return entry;
}
export function getOpenFile(fileId: string): OpenFile | undefined {
return open.get(fileId);
}
export function renameOpenFile(fileId: string, path: string) {
const entry = open.get(fileId);
if (entry) entry.path = path;
}
export function closeFile(fileId: string) {
const entry = open.get(fileId);
if (entry) {
entry.provider.disconnect();
entry.provider.destroy();
entry.doc.destroy();
open.delete(fileId);
}
}
export function getAllText(): Record<string, string> {
const result: Record<string, string> = {};
for (const entry of open.values()) {
result[entry.path] = entry.text.toString();
}
return result;
}
export function cleanupSpace() {
for (const fileId of Array.from(open.keys())) {
closeFile(fileId);
}
spaceId = null;
connectionStatus.set('disconnected');
connectedUsers.set([]);
}
+456
View File
@@ -0,0 +1,456 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Icon from '@iconify/svelte';
import Footer from '$lib/components/Footer.svelte';
import hljs from 'highlight.js/lib/core';
import bash from 'highlight.js/lib/languages/bash';
import javascript from 'highlight.js/lib/languages/javascript';
import python from 'highlight.js/lib/languages/python';
import json from 'highlight.js/lib/languages/json';
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('json', json);
let activeSection = $state('overview');
let copiedSnippet = $state<string | null>(null);
let baseUrl = $derived($page.url.origin);
let curlPng = $derived(`curl -X POST ${baseUrl}/v1/render \\
-H "Authorization: Bearer td_your_api_key_here" \\
-H "Content-Type: application/json" \\
-d '{"code":"#set page(width:200pt,height:80pt)\\nHello, *World*!","format":"png"}' \\
--output hello.png`);
let curlPdf = $derived(`curl -X POST ${baseUrl}/v1/render \\
-H "Authorization: Bearer td_your_api_key_here" \\
-H "Content-Type: application/json" \\
-d '{"code":"= My Report\\n\\nSome body text.","format":"pdf"}' \\
--output report.pdf`);
let curlHtml = $derived(`curl -X POST ${baseUrl}/v1/render \\
-H "Authorization: Bearer td_your_api_key_here" \\
-H "Content-Type: application/json" \\
-d '{"code":"= My Report\\n\\nSome body text.","format":"html"}' \\
--output report.html`);
let jsExample = $derived(`const response = await fetch('${baseUrl}/v1/render', {
method: 'POST',
headers: {
'Authorization': 'Bearer td_your_api_key_here',
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: '#set page(width: 200pt, height: 80pt)\\nHello, *World*!',
format: 'png',
}),
});
if (!response.ok) throw new Error(await response.text());
const blob = await response.blob();
document.querySelector('img').src = URL.createObjectURL(blob);`);
let pythonExample = $derived(`import httpx
response = httpx.post(
"${baseUrl}/v1/render",
headers={"Authorization": "Bearer td_your_api_key_here"},
json={
"code": "= My Report\\\\n\\\\nSome body text.",
"format": "pdf",
},
)
response.raise_for_status()
with open("report.pdf", "wb") as f:
f.write(response.content)`);
let filesExample = $derived(`import base64, httpx
with open("logo.png", "rb") as f:
logo_b64 = base64.b64encode(f.read()).decode()
response = httpx.post(
"${baseUrl}/v1/render",
headers={"Authorization": "Bearer td_your_api_key_here"},
json={
"code": """
#set page(width: 300pt, height: 200pt)
#image("logo.png", width: 80pt)
= My Report
Some body text.
""",
"format": "png",
"files": [{"name": "logo.png", "data": logo_b64}],
},
)
response.raise_for_status()
with open("output.png", "wb") as f:
f.write(response.content)`);
const requestSchemaJson = `{
"code": "string", // Typst markup (required)
"format": "png" | "pdf" | "html", // Output format (required)
"files": [ // Optional inline assets
{
"name": "string", // Filename used in Typst code
"data": "string" // Base64-encoded file content
}
]
}`;
const compileErrorJson = `{
"error": "Typst compilation failed: unknown variable: x (line 3, column 5)",
"details": [
{
"message": "unknown variable: x",
"severity": "error",
"line": 3,
"column": 5
}
]
}`;
// Highlighted versions (derived so they update if baseUrl changes)
let hCurlPng = $derived(hljs.highlight(curlPng, { language: 'bash' }).value);
let hCurlPdf = $derived(hljs.highlight(curlPdf, { language: 'bash' }).value);
let hCurlHtml = $derived(hljs.highlight(curlHtml, { language: 'bash' }).value);
let hJs = $derived(hljs.highlight(jsExample, { language: 'javascript' }).value);
let hPython = $derived(hljs.highlight(pythonExample, { language: 'python' }).value);
let hFiles = $derived(hljs.highlight(filesExample, { language: 'python' }).value);
let hSchema = $derived(hljs.highlight(requestSchemaJson,{ language: 'json' }).value);
let hCompileErr = $derived(hljs.highlight(compileErrorJson, { language: 'json' }).value);
async function copy(id: string, text: string) {
await navigator.clipboard.writeText(text);
copiedSnippet = id;
setTimeout(() => copiedSnippet = null, 2000);
}
const navSections = [
{ id: 'overview', label: 'Overview', icon: 'mdi:book-open-outline' },
{ id: 'auth', label: 'Authentication', icon: 'mdi:key-outline' },
{ id: 'endpoint', label: 'POST /v1/render', icon: 'mdi:api' },
{ id: 'examples', label: 'Examples', icon: 'mdi:code-braces' },
{ id: 'rate-limits', label: 'Rate Limits', icon: 'mdi:speedometer' },
{ id: 'errors', label: 'Error Reference', icon: 'mdi:alert-circle-outline' },
];
</script>
<svelte:head>
<title>API Docs - TypstDrive</title>
<meta name="description" content="TypstDrive Render API documentation." />
</svelte:head>
<style>
:global(.hljs) {
color: #abb2bf;
background: #1e2127;
}
:global(.hljs-comment), :global(.hljs-quote) { color: #5c6370; font-style: italic; }
:global(.hljs-doctag), :global(.hljs-keyword), :global(.hljs-formula) { color: #c678dd; }
:global(.hljs-section), :global(.hljs-name), :global(.hljs-selector-tag),
:global(.hljs-deletion), :global(.hljs-subst) { color: #e06c75; }
:global(.hljs-literal) { color: #56b6c2; }
:global(.hljs-string), :global(.hljs-regexp), :global(.hljs-addition),
:global(.hljs-attribute), :global(.hljs-meta .hljs-string) { color: #98c379; }
:global(.hljs-attr), :global(.hljs-variable), :global(.hljs-template-variable),
:global(.hljs-type), :global(.hljs-selector-class), :global(.hljs-selector-attr),
:global(.hljs-selector-pseudo), :global(.hljs-number) { color: #d19a66; }
:global(.hljs-symbol), :global(.hljs-bullet), :global(.hljs-link),
:global(.hljs-meta), :global(.hljs-selector-id), :global(.hljs-title) { color: #61aeee; }
:global(.hljs-built_in), :global(.hljs-title.class_), :global(.hljs-class .hljs-title) { color: #e6c07b; }
:global(.hljs-emphasis) { font-style: italic; }
:global(.hljs-strong) { font-weight: bold; }
:global(.hljs-link) { text-decoration: underline; }
</style>
<div class="min-h-screen flex flex-col">
<nav class="bg-[var(--theme-bg)] shadow-sm border-b border-gray-200 dark:border-white/10 px-6 py-4 flex justify-between items-center sticky top-0 z-10 transition-colors duration-200 flex-shrink-0">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Icon icon="mdi:api" class="text-blue-600 dark:text-blue-400 text-3xl" />
API Reference
</h1>
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-4 py-2 rounded-lg flex items-center gap-2">
<Icon icon="mdi:arrow-left" class="text-lg" />
Back to Dashboard
</button>
</nav>
<div class="flex flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 gap-8">
<aside class="w-56 flex-shrink-0 hidden md:block">
<nav class="sticky top-24 space-y-1">
{#each navSections as section}
<button
onclick={() => activeSection = section.id}
class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 {activeSection === section.id
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5'}"
>
<Icon icon={section.icon} class="text-lg flex-shrink-0" />
{section.label}
</button>
{/each}
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-white/10">
<a href="/settings" class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition-all duration-150">
<Icon icon="mdi:key-plus" class="text-lg flex-shrink-0" />
Manage API Keys
</a>
</div>
</nav>
</aside>
<main class="flex-1 min-w-0 space-y-6 pb-16">
<div class="md:hidden flex gap-2 flex-wrap">
{#each navSections as section}
<button
onclick={() => activeSection = section.id}
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors {activeSection === section.id ? 'bg-blue-600 text-white' : 'bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300'}"
>
{section.label}
</button>
{/each}
</div>
{#if activeSection === 'overview'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Icon icon="mdi:book-open-outline" class="text-2xl text-blue-500" />
Overview
</h2>
<p class="text-gray-600 dark:text-gray-300 mb-6">
The TypstDrive Render API lets you compile Typst markup into PNG images or PDF documents programmatically.
Authenticate with an API key and POST Typst code — get back binary output.
</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30">
<Icon icon="mdi:image-outline" class="text-2xl text-blue-500 mb-2" />
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">PNG output</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">First page rendered at 2× scale</p>
</div>
<div class="p-4 rounded-xl bg-purple-50 dark:bg-purple-900/10 border border-purple-100 dark:border-purple-800/30">
<Icon icon="mdi:file-pdf-box" class="text-2xl text-purple-500 mb-2" />
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">PDF output</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full multi-page PDF document</p>
</div>
<div class="p-4 rounded-xl bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30">
<Icon icon="mdi:lightning-bolt" class="text-2xl text-green-500 mb-2" />
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">Cached results</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Identical inputs skip recompilation</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-black/30 rounded-xl p-4 border border-gray-200 dark:border-white/10">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Base URL</p>
<code class="font-mono text-sm text-blue-600 dark:text-blue-400">{baseUrl}</code>
</div>
</div>
{/if}
{#if activeSection === 'auth'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Icon icon="mdi:key-outline" class="text-2xl text-blue-500" />
Authentication
</h2>
<p class="text-gray-600 dark:text-gray-300 mb-6">
All requests must include an API key in the <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">Authorization</code> header.
</p>
<div class="space-y-4">
<div>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Header format</p>
<pre class="rounded-xl border border-gray-700 overflow-x-auto"><code class="hljs block px-4 py-3 text-xs font-mono leading-relaxed rounded-xl">{@html hljs.highlight('Authorization: Bearer td_your_api_key_here', { language: 'bash' }).value}</code></pre>
</div>
<div class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-700/30 flex gap-3">
<Icon icon="mdi:information-outline" class="text-amber-500 text-xl flex-shrink-0 mt-0.5" />
<div class="text-sm text-amber-800 dark:text-amber-300">
<p class="font-semibold mb-1">Keep your keys secret</p>
<p>API keys grant access to your account's uploaded files during compilation. Never expose them in client-side code or commit them to version control.</p>
</div>
</div>
<div>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Managing keys</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Create, regenerate, and revoke keys in
<a href="/settings" class="text-blue-600 dark:text-blue-400 hover:underline">Settings → API Keys</a>.
The full key is shown only once at creation time.
</p>
</div>
</div>
</div>
{/if}
{#if activeSection === 'endpoint'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8 space-y-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:api" class="text-2xl text-blue-500" />
POST /v1/render
</h2>
<div>
<div class="flex items-center gap-2 mb-3">
<span class="px-2 py-0.5 text-xs font-bold bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-md">POST</span>
<code class="font-mono text-sm text-gray-800 dark:text-gray-200">/v1/render</code>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Compile Typst markup and return rendered output as PNG, PDF, or HTML.
Results are cached for 1 hour — identical inputs return the cached result without recompiling.
</p>
</div>
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
<div>
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Request headers</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-white/10">
<th class="text-left py-2 pr-4 font-semibold text-gray-700 dark:text-gray-300 w-40">Header</th>
<th class="text-left py-2 font-semibold text-gray-700 dark:text-gray-300">Value</th>
</tr>
</thead>
<tbody class="text-gray-600 dark:text-gray-400">
<tr class="border-b border-gray-100 dark:border-white/5">
<td class="py-2 pr-4 font-mono text-xs">Authorization</td>
<td class="py-2"><code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">Bearer &lt;api-key&gt;</code> — required</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs">Content-Type</td>
<td class="py-2"><code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">application/json</code> — required</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Request body</p>
<pre class="rounded-xl border border-gray-700 overflow-x-auto"><code class="hljs block px-4 py-4 text-xs font-mono leading-relaxed rounded-xl">{@html hSchema}</code></pre>
</div>
<div>
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Response</p>
<div class="p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30 text-sm">
<span class="font-mono text-xs font-bold text-green-700 dark:text-green-400">200 OK</span>
<span class="text-gray-600 dark:text-gray-400 ml-2">Response body with <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">Content-Type: image/png</code>, <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">application/pdf</code>, or <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">text/html</code></span>
</div>
</div>
<div>
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Compilation errors</p>
<div class="p-3 mb-3 rounded-lg bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-800/30 text-sm">
<span class="font-mono text-xs font-bold text-red-700 dark:text-red-400">422 Unprocessable Entity</span>
<span class="text-gray-600 dark:text-gray-400 ml-2">JSON body describing every Typst error. <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">error</code> is a readable summary; <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">details</code> lists each diagnostic with its message, severity, and source line and column.</span>
</div>
<pre class="rounded-xl border border-gray-700 overflow-x-auto"><code class="hljs block px-4 py-4 text-xs font-mono leading-relaxed rounded-xl">{@html hCompileErr}</code></pre>
</div>
<div class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30 text-sm text-blue-800 dark:text-blue-300">
<p class="font-semibold mb-1 flex items-center gap-2"><Icon icon="mdi:folder-account-outline" class="text-base" /> Account files available automatically</p>
<p>Files uploaded to your TypstDrive account are available by filename inside your Typst code. Pass additional files inline via the <code class="font-mono text-xs bg-blue-100 dark:bg-blue-800/40 px-1 rounded">files</code> array to supplement or override them.</p>
</div>
</div>
{/if}
{#if activeSection === 'examples'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8 space-y-8">
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:code-braces" class="text-2xl text-blue-500" />
Examples
</h2>
{#each [
{ id: 'curl-png', label: 'cURL — render PNG', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPng, raw: curlPng },
{ id: 'curl-pdf', label: 'cURL — render PDF', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPdf, raw: curlPdf },
{ id: 'curl-html', label: 'cURL — render HTML', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlHtml, raw: curlHtml },
{ id: 'js', label: 'JavaScript / TypeScript', icon: 'mdi:language-javascript', iconColor: 'text-yellow-400', code: hJs, raw: jsExample },
{ id: 'python', label: 'Python (httpx)', icon: 'mdi:language-python', iconColor: 'text-blue-400', code: hPython, raw: pythonExample},
{ id: 'files', label: 'Python — with inline files', icon: 'mdi:file-image-outline', iconColor: 'text-purple-400', code: hFiles, raw: filesExample },
] as ex}
<div>
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2">
<Icon icon={ex.icon} class="text-lg {ex.iconColor}" />
{ex.label}
</p>
<button onclick={() => copy(ex.id, ex.raw)} class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors px-2 py-1 rounded-md hover:bg-gray-100 dark:hover:bg-white/10">
<Icon icon={copiedSnippet === ex.id ? 'mdi:check' : 'mdi:content-copy'} class="text-sm" />
{copiedSnippet === ex.id ? 'Copied!' : 'Copy'}
</button>
</div>
<pre class="rounded-xl border border-gray-700 overflow-x-auto"><code class="hljs block px-4 py-4 text-xs font-mono leading-relaxed rounded-xl">{@html ex.code}</code></pre>
</div>
{/each}
</div>
{/if}
{#if activeSection === 'rate-limits'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
<Icon icon="mdi:speedometer" class="text-2xl text-blue-500" />
Rate Limits
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="p-4 rounded-xl bg-gray-50 dark:bg-black/30 border border-gray-200 dark:border-white/10">
<p class="text-2xl font-bold text-gray-900 dark:text-white">60</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">requests / minute per key</p>
</div>
<div class="p-4 rounded-xl bg-gray-50 dark:bg-black/30 border border-gray-200 dark:border-white/10">
<p class="text-2xl font-bold text-gray-900 dark:text-white">10</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">API keys per account</p>
</div>
</div>
<div class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-700/30 text-sm text-amber-800 dark:text-amber-300 mb-4">
<p class="font-semibold mb-1">Caching saves quota</p>
<p>Identical inputs (same code + files) skip recompilation and are served from cache for up to 1 hour. Cached responses return instantly and do not consume your rate limit.</p>
</div>
<div>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">When exceeded</p>
<div class="p-3 rounded-lg bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-800/30 text-sm">
<code class="font-mono text-xs font-bold text-red-700 dark:text-red-400">429 Too Many Requests</code>
<span class="text-gray-600 dark:text-gray-400 ml-2">— wait for the current 60-second window to reset.</span>
</div>
</div>
</div>
{/if}
{#if activeSection === 'errors'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
<Icon icon="mdi:alert-circle-outline" class="text-2xl text-blue-500" />
Error Reference
</h2>
<div class="space-y-3">
{#each [
{ code: '400', name: 'Bad Request', desc: 'Invalid format value, empty code, or malformed JSON body.' },
{ code: '401', name: 'Unauthorized', desc: 'Missing or invalid Authorization header, or unknown API key.' },
{ code: '422', name: 'Unprocessable Entity', desc: 'Your Typst code compiled with errors. The JSON body lists each error message with its source line and column.' },
{ code: '429', name: 'Too Many Requests', desc: 'Rate limit exceeded. Wait for the current 60-second window to reset.' },
{ code: '500', name: 'Internal Server Error', desc: 'Unexpected server error. Try again after a short delay.' },
] as err}
<div class="flex items-start gap-4 p-4 rounded-xl border border-gray-100 dark:border-white/10 bg-gray-50 dark:bg-black/20">
<code class="font-mono text-sm font-bold text-gray-800 dark:text-gray-200 flex-shrink-0 w-8">{err.code}</code>
<div>
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">{err.name}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{err.desc}</p>
</div>
</div>
{/each}
</div>
<div class="mt-6 p-4 rounded-xl bg-gray-50 dark:bg-black/30 border border-gray-200 dark:border-white/10">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Error body</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Compilation failures (<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">422</code>) return a JSON body with an <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">error</code> summary and a <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">details</code> array. All other errors return plain text describing the issue.</p>
</div>
</div>
{/if}
</main>
</div>
<Footer />
</div>
+140 -41
View File
@@ -14,6 +14,7 @@
import InfoModal from '$lib/components/dashboard/InfoModal.svelte';
import RenameModal from '$lib/components/dashboard/RenameModal.svelte';
import CreateDocModal from '$lib/components/dashboard/CreateDocModal.svelte';
import CreateSpaceModal from '$lib/components/dashboard/CreateSpaceModal.svelte';
import CreateFolderModal from '$lib/components/dashboard/CreateFolderModal.svelte';
import Footer from '$lib/components/Footer.svelte';
@@ -26,6 +27,7 @@
let newFolderName = $state('');
let loading = $state(true);
let showCreateModal = $state(false);
let showCreateSpaceModal = $state(false);
let newDocTitle = $state('');
let showPlusDropdown = $state(false);
let dragOverFolderId = $state<string | null>(null);
@@ -161,6 +163,7 @@
}
function navigateToBreadcrumb(index: number) {
inSharedDrive = false;
if (index === -1) {
folderPath = [];
currentFolderId = null;
@@ -197,6 +200,27 @@
}
}
function openCreateSpaceModal() {
showPlusDropdown = false;
showCreateSpaceModal = true;
}
async function createSpace(name: string) {
if (!name.trim()) return;
const res = await fetch('/api/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), folder_id: currentFolderId || undefined })
});
if (res.ok) {
const space = await res.json();
showCreateSpaceModal = false;
goto(`/space/${space.id}`);
}
}
async function handleImportUpload(e: Event) {
const target = e.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return;
@@ -314,6 +338,26 @@
}
}
let inSharedDrive = $state(false);
let sharedDocs = $state<any[]>([]);
let sharedDocsLoading = $state(false);
async function loadSharedDocs() {
sharedDocsLoading = true;
try {
const res = await fetch('/api/docs/shared');
if (res.ok) sharedDocs = await res.json();
} catch {}
sharedDocsLoading = false;
}
function enterSharedDrive() {
inSharedDrive = true;
folderPath = [];
currentFolderId = null;
loadSharedDocs();
}
let showShareModal = $state(false);
let shareTarget = $state<any>(null);
@@ -337,7 +381,6 @@
try {
data = JSON.parse(dataString);
} catch (err) {
// For backward compatibility if it's just an id
data = { type: 'document', id: dataString };
}
@@ -389,21 +432,25 @@
</button>
{#if showPlusDropdown}
<div class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-gray-100 dark:border-zinc-700 py-1 z-20">
<button onclick={openCreateModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 flex items-center gap-2">
<div class="absolute right-0 mt-2 w-48 bg-[var(--theme-bg)] rounded-lg shadow-xl border border-gray-200 dark:border-white/10 py-1 z-20">
<button onclick={openCreateModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" />
New Document
</button>
<button onclick={openCreateFolderModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 flex items-center gap-2">
<button onclick={openCreateSpaceModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:folder-multiple-plus" class="text-lg text-indigo-500" />
New Space
</button>
<button onclick={openCreateFolderModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:folder-plus" class="text-lg text-yellow-500" />
New Folder
</button>
<button onclick={() => { showPlusDropdown = false; fileInput?.click(); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 flex items-center gap-2">
<button onclick={() => { showPlusDropdown = false; fileInput?.click(); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:upload" class="text-lg text-green-500" />
Upload File
</button>
<div class="h-px bg-gray-100 dark:bg-zinc-700 my-1"></div>
<button onclick={() => { showPlusDropdown = false; importFileInput?.click(); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 flex items-center gap-2" disabled={isImporting}>
<div class="h-px bg-gray-200 dark:bg-white/10 my-1"></div>
<button onclick={() => { showPlusDropdown = false; importFileInput?.click(); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2" disabled={isImporting}>
{#if isImporting}
<Icon icon="mdi:loading" class="text-lg text-purple-500 animate-spin" />
Importing...
@@ -429,48 +476,86 @@
class="hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === -1 ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : ''}">
<Icon icon="mdi:home" class="text-lg inline-block pb-0.5" /> Home
</button>
{#each folderPath as folder, index}
{#if inSharedDrive}
<Icon icon="mdi:chevron-right" class="text-lg text-gray-400" />
<button
onclick={() => navigateToBreadcrumb(index)}
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = index; }}
ondragleave={() => dragOverBreadcrumbIndex = null}
ondrop={(e) => handleDrop(e, folder.id)}
class="hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === index ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : ''}">
{folder.name}
</button>
{/each}
<span class="font-medium text-purple-600 dark:text-purple-400 flex items-center gap-1 px-2 py-1">
<Icon icon="mdi:folder-account" class="text-base" /> Shared with me
</span>
{:else}
{#each folderPath as folder, index}
<Icon icon="mdi:chevron-right" class="text-lg text-gray-400" />
<button
onclick={() => navigateToBreadcrumb(index)}
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = index; }}
ondragleave={() => dragOverBreadcrumbIndex = null}
ondrop={(e) => handleDrop(e, folder.id)}
class="hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === index ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : ''}">
{folder.name}
</button>
{/each}
{/if}
</div>
{#if loading}
{#if inSharedDrive}
{#if sharedDocsLoading}
<div class="min-h-[50vh] flex items-center justify-center">
<div class="flex flex-col items-center gap-4 text-gray-500 dark:text-gray-400 animate-pulse">
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
<p class="text-lg font-medium">Loading shared documents...</p>
</div>
</div>
{:else if sharedDocs.length === 0}
<div class="min-h-[50vh] flex items-center justify-center">
<div class="text-center p-12 bg-white/50 dark:bg-black/20 rounded-2xl shadow-sm border border-gray-200 dark:border-white/10 max-w-md w-full">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-purple-100/50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400 mb-6">
<Icon icon="mdi:folder-account-outline" class="text-4xl" />
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">No shared documents</h3>
<p class="text-gray-500 dark:text-gray-400">Documents shared with you by other users will appear here.</p>
</div>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{#each sharedDocs as doc}
<DocCard
{doc}
{activeMenu}
setActiveMenu={(id) => activeMenu = id}
{openInfo}
openRename={() => {}}
{shareItem}
deleteDoc={() => {}}
/>
{/each}
</div>
{/if}
{:else if loading}
<div class="min-h-[50vh] flex items-center justify-center">
<div class="flex flex-col items-center gap-4 text-gray-500 dark:text-gray-400 animate-pulse">
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
<p class="text-lg font-medium">Loading your workspace...</p>
</div>
</div>
{:else if documents.length === 0 && folders.length === 0 && files.length === 0 && currentFolderId === null}
<div class="min-h-[50vh] flex items-center justify-center">
<div class="text-center p-12 bg-white/50 dark:bg-black/20 backdrop-blur-sm rounded-2xl shadow-sm border border-gray-200 dark:border-white/10 max-w-md w-full">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-blue-100/50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 mb-6">
<Icon icon="mdi:file-document-outline" class="text-4xl" />
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">No documents yet</h3>
<p class="text-gray-500 dark:text-gray-400 mb-8">Get started by creating your first Typst document. It's fast, collaborative, and beautiful.</p>
<button onclick={openCreateModal} class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg shadow-sm text-base font-medium transition-colors">
<Icon icon="mdi:plus" class="text-xl" />
Create Document
</button>
</div>
</div>
{:else}
{#if folders.length > 0}
{#if currentFolderId === null || folders.length > 0}
<div class="mb-8">
<div class="px-2 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
Folders
</div>
<div class="px-2 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Folders</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{#if currentFolderId === null}
<div
class="flex flex-row items-center p-3 bg-white/50 dark:bg-black/20 border border-gray-200 dark:border-white/10 rounded-xl shadow-sm hover:shadow-md cursor-pointer group transition-all duration-200 hover:-translate-y-0.5 hover:border-purple-300 dark:hover:border-purple-500/30"
role="button"
tabindex="0"
onclick={enterSharedDrive}
onkeydown={(e) => e.key === 'Enter' && enterSharedDrive()}
>
<div class="flex items-center justify-center w-10 h-10 bg-purple-50 dark:bg-purple-500/10 rounded-lg group-hover:scale-105 transition-transform pointer-events-none shrink-0 mr-3">
<Icon icon="mdi:folder-account" class="text-2xl text-purple-500" />
</div>
<span class="font-medium text-gray-900 dark:text-white text-sm truncate w-full pointer-events-none">Shared with me</span>
</div>
{/if}
{#each folders as folder}
<FolderRow
{folder}
@@ -485,7 +570,6 @@
</div>
{/if}
{#if documents.length > 0 || files.length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{#each documents as doc}
@@ -499,17 +583,28 @@
{deleteDoc}
/>
{/each}
{#each files as file}
<FileCard {file} {deleteFile} />
{/each}
</div>
{/if}
{#if documents.length === 0 && folders.length === 0 && files.length === 0}
<div class="min-h-[50vh] flex items-center justify-center">
{#if documents.length === 0 && files.length === 0 && currentFolderId !== null && folders.length === 0}
<div class="min-h-[30vh] flex items-center justify-center">
<p class="text-gray-500 dark:text-gray-400">This folder is empty.</p>
</div>
{:else if documents.length === 0 && files.length === 0 && folders.length === 0 && currentFolderId === null}
<div class="text-center py-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100/50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 mb-4">
<Icon icon="mdi:file-document-outline" class="text-3xl" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-1">No documents yet</h3>
<p class="text-gray-500 dark:text-gray-400 mb-6 text-sm">Create your first Typst document to get started.</p>
<button onclick={openCreateModal} class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg shadow-sm text-sm font-medium transition-colors">
<Icon icon="mdi:plus" class="text-lg" />
Create Document
</button>
</div>
{/if}
{/if}
@@ -534,6 +629,10 @@
<CreateDocModal {createDoc} onClose={() => showCreateModal = false} />
{/if}
{#if showCreateSpaceModal}
<CreateSpaceModal {createSpace} onClose={() => showCreateSpaceModal = false} />
{/if}
{#if showCreateFolderModal}
<CreateFolderModal {createFolder} onClose={() => showCreateFolderModal = false} />
{/if}
-2
View File
@@ -24,7 +24,6 @@
const view = $editorViewStore;
if (!view) return;
// Ensure context menu only triggers on editor
const target = e.target as HTMLElement;
if (!target.closest('.cm-editor') && !target.closest('.cm-content')) return;
@@ -148,7 +147,6 @@
<DocFooter />
</div>
<!-- Custom Context Menu for Editor -->
{#if contextMenu.show}
<div
class="fixed z-[9999] bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-lg shadow-xl border border-[var(--theme-border)] py-1 min-w-[200px] overflow-hidden"
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Icon from '@iconify/svelte';
import Navbar from '$lib/components/dashboard/Navbar.svelte';
interface Package {
id: string;
name: string;
description?: string;
owner_name?: string;
latest_version?: string;
}
let packages = $state<Package[]>([]);
let loading = $state(true);
let copied = $state('');
async function load() {
loading = true;
const res = await fetch('/api/packages');
packages = res.ok ? await res.json() : [];
loading = false;
}
function importSnippet(pkg: Package): string {
return `#import "@typstdrive/${pkg.name}:${pkg.latest_version ?? '0.1.0'}": *`;
}
async function copy(pkg: Package) {
await navigator.clipboard.writeText(importSnippet(pkg));
copied = pkg.id;
setTimeout(() => (copied = ''), 2000);
}
async function remove(pkg: Package) {
if (!confirm(`Delete package "${pkg.name}" and all its versions?`)) return;
const res = await fetch(`/api/packages/${pkg.name}`, { method: 'DELETE' });
if (res.ok) packages = packages.filter((p) => p.id !== pkg.id);
}
onMount(load);
</script>
<svelte:head>
<title>Packages - TypstDrive</title>
</svelte:head>
<div class="min-h-screen bg-gray-50 dark:bg-[var(--theme-bg)]">
<Navbar />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors mb-2 flex items-center gap-1.5">
<Icon icon="mdi:arrow-left" class="text-lg" />
Back to Dashboard
</button>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:package-variant-closed" class="text-purple-500" />
Packages
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Instance-local Typst packages, published from Spaces and importable as
<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/&lt;name&gt;:&lt;version&gt;</code>.
</p>
</div>
{#if loading}
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
{:else if packages.length === 0}
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
<Icon icon="mdi:package-variant" class="text-5xl mx-auto mb-3 opacity-50" />
<p>No packages published yet. Open a Space and use “Publish” to create one.</p>
</div>
{:else}
<div class="space-y-3">
{#each packages as pkg (pkg.id)}
<div class="bg-white dark:bg-black/20 rounded-xl border border-gray-200 dark:border-white/10 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<p class="font-semibold text-gray-900 dark:text-white truncate">@typstdrive/{pkg.name}</p>
{#if pkg.latest_version}
<span class="text-xs font-mono bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-1.5 py-0.5 rounded">v{pkg.latest_version}</span>
{/if}
</div>
{#if pkg.description}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 truncate">{pkg.description}</p>
{/if}
<p class="text-xs text-gray-400 mt-1">by {pkg.owner_name ?? 'unknown'}</p>
<pre class="mt-2 text-xs font-mono bg-gray-50 dark:bg-black/30 border border-gray-100 dark:border-white/5 rounded px-2 py-1 overflow-x-auto">{importSnippet(pkg)}</pre>
</div>
<div class="flex flex-col items-end gap-2 flex-shrink-0">
<button onclick={() => copy(pkg)} class="text-xs px-2 py-1 rounded-md bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 flex items-center gap-1">
<Icon icon={copied === pkg.id ? 'mdi:check' : 'mdi:content-copy'} class="text-sm" />
{copied === pkg.id ? 'Copied' : 'Copy'}
</button>
<button onclick={() => remove(pkg)} title="Delete" class="text-xs px-2 py-1 rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-1">
<Icon icon="mdi:trash-can-outline" class="text-sm" /> Delete
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
-2
View File
@@ -36,7 +36,6 @@
return;
}
// Auto login after successful registration using email
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -65,7 +64,6 @@
<div class="min-h-screen flex flex-col relative overflow-hidden">
<div class="flex-grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10">
<!-- Background decorative elements -->
<div class="absolute -top-40 right-20 w-96 h-96 bg-emerald-400/20 dark:bg-emerald-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
<div class="absolute top-40 -left-20 w-96 h-96 bg-teal-400/20 dark:bg-teal-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
<div class="absolute -bottom-40 right-40 w-96 h-96 bg-blue-400/20 dark:bg-blue-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
+422 -7
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { userStore } from '$lib/ts/auth';
import Icon from '@iconify/svelte';
import ThemePicker from '$lib/components/ThemePicker.svelte';
import Footer from '$lib/components/Footer.svelte';
import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale, Filler, Tooltip } from 'chart.js';
Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearScale, Filler, Tooltip);
type AdminUser = {
id: string;
@@ -14,6 +16,15 @@
created_at: string;
};
type ApiKey = {
id: string;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
rate_limit: number;
};
let activeSection = $state('account');
let username = $state('');
@@ -37,6 +48,28 @@
let deletingUserId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let apiKeys = $state<ApiKey[]>([]);
let apiKeysLoading = $state(false);
let apiKeysError = $state('');
let showCreateKeyForm = $state(false);
let createKeyName = $state('');
let createKeyError = $state('');
let createKeyLoading = $state(false);
let newlyCreatedKey = $state<{ key: string; name: string } | null>(null);
let confirmDeleteKeyId = $state<string | null>(null);
let deletingKeyId = $state<string | null>(null);
let copiedKey = $state(false);
let confirmRegenerateId = $state<string | null>(null);
let regeneratingKeyId = $state<string | null>(null);
type UsagePoint = { date: string; count: number };
type UsagePeriod = '1hr' | '1day' | '1week';
let usageData = $state<UsagePoint[]>([]);
let usageLoading = $state(false);
let usagePeriod = $state<UsagePeriod>('1week');
let chartCanvas = $state<HTMLCanvasElement | null>(null);
let chartInstance: Chart | null = null;
let showCreateForm = $state(false);
let createUsername = $state('');
let createEmail = $state('');
@@ -67,6 +100,172 @@
} catch {}
});
async function loadApiKeys() {
apiKeysLoading = true;
apiKeysError = '';
try {
const res = await fetch('/api/keys');
if (res.ok) {
apiKeys = await res.json();
} else {
apiKeysError = 'Failed to load API keys.';
}
} catch {
apiKeysError = 'Network error.';
}
apiKeysLoading = false;
}
async function createApiKey(e: Event) {
e.preventDefault();
createKeyError = '';
createKeyLoading = true;
try {
const res = await fetch('/api/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: createKeyName })
});
if (!res.ok) {
createKeyError = await res.text() || 'Failed to create key.';
} else {
const data = await res.json();
newlyCreatedKey = { key: data.key, name: data.name };
apiKeys = [...apiKeys, {
id: data.id,
name: data.name,
key_prefix: data.prefix,
created_at: data.created_at,
last_used_at: null,
rate_limit: data.rate_limit,
}];
showCreateKeyForm = false;
createKeyName = '';
copiedKey = false;
}
} catch {
createKeyError = 'Network error.';
}
createKeyLoading = false;
}
async function deleteApiKey(id: string) {
deletingKeyId = id;
try {
const res = await fetch(`/api/keys/${id}`, { method: 'DELETE' });
if (res.ok) {
apiKeys = apiKeys.filter(k => k.id !== id);
}
} catch {}
deletingKeyId = null;
confirmDeleteKeyId = null;
}
async function copyKey(key: string) {
await navigator.clipboard.writeText(key);
copiedKey = true;
setTimeout(() => copiedKey = false, 2000);
}
async function regenerateApiKey(id: string) {
regeneratingKeyId = id;
try {
const res = await fetch(`/api/keys/${id}/regenerate`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
newlyCreatedKey = { key: data.key, name: data.name };
apiKeys = apiKeys.map(k => k.id === id ? {
...k, key_prefix: data.prefix, created_at: data.created_at, last_used_at: null
} : k);
copiedKey = false;
}
} catch {}
regeneratingKeyId = null;
confirmRegenerateId = null;
}
async function loadUsage(period: UsagePeriod) {
usageLoading = true;
try {
const res = await fetch(`/api/keys/usage?period=${period}`);
if (res.ok) usageData = await res.json();
} catch {}
usageLoading = false;
}
function pad(n: number) { return String(n).padStart(2, '0'); }
function buildChartData(period: UsagePeriod) {
const labels: string[] = [];
const counts: number[] = [];
if (period === '1hr') {
const now = new Date();
const baseMs = now.getTime() - (now.getUTCSeconds() * 1000 + now.getUTCMilliseconds());
for (let i = 59; i >= 0; i--) {
const d = new Date(baseMs - i * 60000);
const key = `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
labels.push(`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`);
const pt = usageData.find(p => p.date === key);
counts.push(pt ? pt.count : 0);
}
} else if (period === '1day') {
const now = new Date();
const baseMs = now.getTime() - (now.getUTCMinutes() * 60000 + now.getUTCSeconds() * 1000 + now.getUTCMilliseconds());
for (let i = 23; i >= 0; i--) {
const d = new Date(baseMs - i * 3600000);
const key = `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}`;
labels.push(`${pad(d.getUTCHours())}:00`);
const pt = usageData.find(p => p.date === key);
counts.push(pt ? pt.count : 0);
}
} else {
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const iso = d.toISOString().split('T')[0];
labels.push(iso.slice(5));
const pt = usageData.find(p => p.date === iso);
counts.push(pt ? pt.count : 0);
}
}
return { labels, counts };
}
$effect(() => {
if (!chartCanvas) return;
const { labels, counts } = buildChartData(usagePeriod);
if (chartInstance) chartInstance.destroy();
chartInstance = new Chart(chartCanvas, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Requests',
data: counts,
fill: true,
backgroundColor: 'rgba(59,130,246,0.12)',
borderColor: 'rgba(59,130,246,0.85)',
pointBackgroundColor: 'rgba(59,130,246,0.9)',
pointRadius: usagePeriod === '1hr' ? 2 : 3,
tension: 0.35,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { callbacks: {
title: (items) => items[0].label,
label: (item) => ` ${item.raw} request${(item.raw as number) !== 1 ? 's' : ''}`,
}}},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1, color: '#9ca3af', font: { size: 10 } }, grid: { color: 'rgba(156,163,175,0.1)' } },
x: { ticks: { color: '#9ca3af', font: { size: 10 }, maxTicksLimit: usagePeriod === '1hr' ? 12 : 8 }, grid: { display: false } }
}
}
});
return () => { chartInstance?.destroy(); chartInstance = null; };
});
async function loadAdminUsers() {
adminLoading = true;
adminError = '';
@@ -83,6 +282,18 @@
adminLoading = false;
}
$effect(() => {
if (activeSection === 'api-keys') {
loadApiKeys();
}
});
$effect(() => {
if (activeSection === 'api-keys') {
loadUsage(usagePeriod);
}
});
$effect(() => {
if (activeSection === 'admin' && $userStore?.is_admin) {
loadAdminUsers();
@@ -196,6 +407,7 @@
{ id: 'account', label: 'Account', icon: 'mdi:account-outline' },
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
{ id: 'api-keys', label: 'API Keys', icon: 'mdi:key-outline' },
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
]);
</script>
@@ -218,7 +430,6 @@
</nav>
<div class="flex flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 gap-8">
<!-- Sidebar -->
<aside class="w-56 flex-shrink-0">
<nav class="sticky top-24 space-y-1">
{#each navItems as item}
@@ -245,10 +456,8 @@
</nav>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0 space-y-6 pb-16">
<!-- Account Section -->
{#if activeSection === 'account'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="p-6 sm:p-8">
@@ -342,7 +551,6 @@
</div>
{/if}
<!-- Theme Section -->
{#if activeSection === 'theme'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="p-6 sm:p-8">
@@ -356,7 +564,6 @@
</div>
{/if}
<!-- Storage Section -->
{#if activeSection === 'storage'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="p-6 sm:p-8">
@@ -394,7 +601,215 @@
</div>
{/if}
<!-- Admin Section -->
{#if activeSection === 'api-keys'}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="p-6 sm:p-8">
<div class="flex items-center justify-between mb-2">
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:key-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
API Keys
</h2>
<button
onclick={() => { showCreateKeyForm = !showCreateKeyForm; createKeyError = ''; newlyCreatedKey = null; }}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-semibold rounded-lg transition-colors {showCreateKeyForm ? 'bg-gray-200 dark:bg-white/10 text-gray-700 dark:text-gray-300' : 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm'}"
>
<Icon icon={showCreateKeyForm ? 'mdi:close' : 'mdi:plus'} class="text-base" />
{showCreateKeyForm ? 'Cancel' : 'New Key'}
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Use API keys to render Typst documents programmatically via <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">POST /v1/render</code>.
Each key allows up to 60 requests/minute.
<a href="/api-docs" class="text-blue-600 dark:text-blue-400 hover:underline ml-1">View API docs →</a>
</p>
<div class="mb-6 p-4 rounded-xl border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-black/30">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Requests — {usagePeriod === '1hr' ? 'Last 60 Min' : usagePeriod === '1day' ? 'Last 24 Hours' : 'Last 7 Days'}
{#if usageData.length > 0}
<span class="ml-2 normal-case font-normal text-gray-400 dark:text-gray-500">
({usageData.reduce((s, p) => s + p.count, 0)} total)
</span>
{/if}
</p>
<div class="flex items-center gap-1">
{#each ([['1hr', '1 hr'], ['1day', '1 day'], ['1week', '1 week']] as const) as [val, label]}
<button
onclick={() => usagePeriod = val}
class="px-2 py-0.5 text-xs font-semibold rounded-md transition-colors {usagePeriod === val ? 'bg-blue-600 text-white' : 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-white/10'}"
>{label}</button>
{/each}
</div>
</div>
<div class="h-32">
{#if usageLoading}
<div class="h-full flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm">
<Icon icon="mdi:loading" class="animate-spin mr-2" /> Loading...
</div>
{:else if usageData.length === 0}
<div class="h-full flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm">
No usage yet — make your first API call to see data here.
</div>
{:else}
<canvas bind:this={chartCanvas}></canvas>
{/if}
</div>
</div>
{#if newlyCreatedKey}
<div class="mb-6 p-4 rounded-xl border border-green-200 dark:border-green-700/50 bg-green-50 dark:bg-green-900/10">
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<p class="text-sm font-bold text-green-800 dark:text-green-300 flex items-center gap-2">
<Icon icon="mdi:check-circle" class="text-lg" />
Key created: {newlyCreatedKey.name}
</p>
<p class="text-xs text-green-700 dark:text-green-400 mt-0.5">Copy this key now — it will not be shown again.</p>
</div>
<button onclick={() => newlyCreatedKey = null} class="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200 flex-shrink-0">
<Icon icon="mdi:close" class="text-lg" />
</button>
</div>
<div class="flex items-center gap-2 mt-3">
<code class="flex-1 font-mono text-xs bg-white dark:bg-black/40 border border-green-200 dark:border-green-700/50 text-gray-800 dark:text-gray-200 px-3 py-2 rounded-lg break-all">{newlyCreatedKey.key}</code>
<button
onclick={() => copyKey(newlyCreatedKey!.key)}
class="flex-shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm font-semibold rounded-lg transition-colors {copiedKey ? 'bg-green-600 text-white' : 'bg-gray-200 dark:bg-white/10 hover:bg-gray-300 dark:hover:bg-white/20 text-gray-700 dark:text-gray-300'}"
>
<Icon icon={copiedKey ? 'mdi:check' : 'mdi:content-copy'} class="text-base" />
{copiedKey ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
{/if}
{#if showCreateKeyForm}
<form onsubmit={createApiKey} class="mb-6 p-4 rounded-xl border border-blue-200 dark:border-blue-800/50 bg-blue-50/50 dark:bg-blue-900/10 space-y-3">
<h3 class="text-sm font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:key-plus" class="text-blue-500" />
Create API Key
</h3>
{#if createKeyError}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-3 py-2 rounded-lg text-sm">{createKeyError}</div>
{/if}
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
<input
type="text"
required
bind:value={createKeyName}
placeholder="e.g. My App, CI Pipeline"
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<button
type="submit"
disabled={createKeyLoading || !createKeyName.trim()}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
>
{#if createKeyLoading}
<Icon icon="mdi:loading" class="animate-spin text-base" />
Creating...
{:else}
<Icon icon="mdi:key-plus" class="text-base" />
Create
{/if}
</button>
</div>
</form>
{/if}
{#if apiKeysError}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm mb-4">{apiKeysError}</div>
{/if}
{#if apiKeysLoading}
<div class="flex items-center justify-center py-12 text-gray-400 dark:text-gray-500">
<Icon icon="mdi:loading" class="animate-spin text-2xl mr-2" />
Loading keys...
</div>
{:else if apiKeys.length === 0}
<div class="text-center py-12 text-gray-400 dark:text-gray-500">
<Icon icon="mdi:key-outline" class="text-4xl mb-2 opacity-40" />
<p class="text-sm">No API keys yet. Create one to get started.</p>
</div>
{:else}
<div class="space-y-2">
{#each apiKeys as key (key.id)}
<div class="flex items-center gap-4 px-4 py-3 rounded-xl border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-black/20">
<div class="h-9 w-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 flex-shrink-0">
<Icon icon="mdi:key" class="text-lg" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">{key.name}</p>
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{key.key_prefix}... · {key.rate_limit}/min</p>
</div>
<div class="text-right flex-shrink-0 hidden sm:block">
<p class="text-xs text-gray-400 dark:text-gray-500">Created {formatDate(key.created_at)}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{key.last_used_at ? `Last used ${formatDate(key.last_used_at)}` : 'Never used'}</p>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
{#if confirmRegenerateId === key.id}
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Regenerate?</span>
<button
onclick={() => regenerateApiKey(key.id)}
disabled={regeneratingKeyId === key.id}
class="text-xs px-2 py-1 rounded-md bg-amber-500 hover:bg-amber-600 text-white font-semibold transition-colors disabled:opacity-50"
>
{regeneratingKeyId === key.id ? '...' : 'Yes'}
</button>
<button
onclick={() => confirmRegenerateId = null}
class="text-xs px-2 py-1 rounded-md bg-gray-200 hover:bg-gray-300 dark:bg-white/10 dark:hover:bg-white/20 text-gray-700 dark:text-gray-300 font-semibold transition-colors"
>
No
</button>
</div>
{:else if confirmDeleteKeyId === key.id}
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Delete?</span>
<button
onclick={() => deleteApiKey(key.id)}
disabled={deletingKeyId === key.id}
class="text-xs px-2 py-1 rounded-md bg-red-600 hover:bg-red-700 text-white font-semibold transition-colors disabled:opacity-50"
>
{deletingKeyId === key.id ? '...' : 'Yes'}
</button>
<button
onclick={() => confirmDeleteKeyId = null}
class="text-xs px-2 py-1 rounded-md bg-gray-200 hover:bg-gray-300 dark:bg-white/10 dark:hover:bg-white/20 text-gray-700 dark:text-gray-300 font-semibold transition-colors"
>
No
</button>
</div>
{:else}
<button
onclick={() => { confirmRegenerateId = key.id; confirmDeleteKeyId = null; }}
title="Regenerate key"
class="p-1.5 rounded-lg text-gray-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 transition-colors"
>
<Icon icon="mdi:refresh" class="text-lg" />
</button>
<button
onclick={() => { confirmDeleteKeyId = key.id; confirmRegenerateId = null; }}
title="Revoke key"
class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
>
<Icon icon="mdi:delete-outline" class="text-lg" />
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{#if activeSection === 'admin' && $userStore?.is_admin}
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="p-6 sm:p-8">
+258
View File
@@ -0,0 +1,258 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import Editor from '$lib/components/Editor.svelte';
import Preview from '$lib/components/Preview.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import DocFooter from '$lib/components/DocFooter.svelte';
import FileTree from '$lib/components/space/FileTree.svelte';
import SpaceToolbar from '$lib/components/space/SpaceToolbar.svelte';
import PublishPackageModal from '$lib/components/PublishPackageModal.svelte';
import { compileSpace } from '$lib/ts/typst-api';
import type { Diagnostic } from '$lib/ts/typst-api';
import { editorErrors, documentStatsStore, previewOpenStore, editorViewStore } from '$lib/ts/store';
import { setSpace, openFile, getOpenFile, closeFile, renameOpenFile, getAllText, cleanupSpace } from '$lib/ts/yjs-space';
interface SpaceFile {
id: string;
path: string;
kind: string;
}
const spaceId = $page.params.id as string;
let spaceName = $state('Space');
let entrypoint = $state('main.typ');
let role = $state('owner');
let files = $state<SpaceFile[]>([]);
let activeFileId = $state('');
let svgs = $state<string[]>([]);
let errors = $state<Diagnostic[]>([]);
let showPublish = $state(false);
let ready = $state(false);
let timeoutId: number | undefined;
let contextMenu = $state({ show: false, x: 0, y: 0, text: '' });
let readOnly = $derived(role === 'viewer');
let activeEntry = $derived(activeFileId ? getOpenFile(activeFileId) : undefined);
let activePath = $derived(files.find((f) => f.id === activeFileId)?.path ?? '');
function scheduleCompile() {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = window.setTimeout(triggerCompile, 500);
}
function triggerCompile() {
if (!$previewOpenStore) return;
compileSpace(spaceId, getAllText())
.then((res) => {
if (res.stats) $documentStatsStore = res.stats;
if (res.svgs) {
svgs = res.svgs;
errors = [];
$editorErrors = [];
} else if (res.errors) {
errors = res.errors;
$editorErrors = res.errors;
}
})
.catch(() => {
errors = [{ message: 'Network or server error compiling space.', severity: 'error' }];
});
}
async function loadFiles() {
const res = await fetch(`/api/spaces/${spaceId}/files`);
if (!res.ok) return;
files = await res.json();
for (const f of files) {
if (f.kind === 'text') {
const entry = openFile(f.id, f.path);
entry.text.observe(scheduleCompile);
}
}
if (!activeFileId) {
const entry = files.find((f) => f.path === entrypoint) ?? files.find((f) => f.kind === 'text');
if (entry) activeFileId = entry.id;
}
}
function selectFile(file: SpaceFile) {
if (file.kind !== 'text') return;
activeFileId = file.id;
}
async function createFile(path: string) {
const res = await fetch(`/api/spaces/${spaceId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, kind: 'text', content: '' })
});
if (res.ok) {
const file = await res.json();
files = [...files, file].sort((a, b) => a.path.localeCompare(b.path));
const entry = openFile(file.id, file.path);
entry.text.observe(scheduleCompile);
activeFileId = file.id;
}
}
async function uploadFiles(fileList: FileList) {
const form = new FormData();
for (const f of fileList) form.append('file', f);
const res = await fetch(`/api/spaces/${spaceId}/files/upload`, { method: 'POST', body: form });
if (res.ok) {
await loadFiles();
triggerCompile();
}
}
async function renameFile(file: SpaceFile, path: string) {
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
if (res.ok) {
files = files.map((f) => (f.id === file.id ? { ...f, path } : f));
renameOpenFile(file.id, path);
scheduleCompile();
}
}
async function deleteFile(file: SpaceFile) {
if (!confirm(`Delete ${file.path}?`)) return;
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, { method: 'DELETE' });
if (res.ok) {
closeFile(file.id);
files = files.filter((f) => f.id !== file.id);
if (activeFileId === file.id) {
activeFileId = files.find((f) => f.kind === 'text')?.id ?? '';
}
scheduleCompile();
}
}
async function setEntry(file: SpaceFile) {
const res = await fetch(`/api/spaces/${spaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entrypoint: file.path })
});
if (res.ok) {
entrypoint = file.path;
scheduleCompile();
}
}
function handleContextMenu(e: MouseEvent) {
const view = $editorViewStore;
if (!view) return;
const target = e.target as HTMLElement;
if (!target.closest('.cm-editor') && !target.closest('.cm-content')) return;
const selection = view.state.selection.main;
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
if (selectedText.trim()) {
e.preventDefault();
contextMenu = { show: true, x: e.clientX, y: e.clientY, text: selectedText.trim() };
}
}
function closeContextMenu() {
contextMenu.show = false;
}
onMount(() => {
setSpace(spaceId);
fetch(`/api/spaces/${spaceId}`)
.then((r) => r.json())
.then((s) => {
if (s && s.name) spaceName = s.name;
if (s && s.entrypoint) entrypoint = s.entrypoint;
if (s && s.effective_role) role = s.effective_role;
})
.then(loadFiles)
.then(() => {
ready = true;
triggerCompile();
})
.catch((e) => console.error('Failed to load space', e));
return () => {
if (timeoutId) clearTimeout(timeoutId);
cleanupSpace();
};
});
</script>
<svelte:head>
<title>{spaceName} - TypstDrive</title>
</svelte:head>
<svelte:window onclick={closeContextMenu} />
<div class="flex flex-col h-screen relative">
<SpaceToolbar
{spaceName}
{spaceId}
{entrypoint}
{role}
activeText={activeEntry?.text ?? null}
{activePath}
{getAllText}
onPublish={() => (showPublish = true)}
onFilesChanged={loadFiles}
/>
<main class="flex-1 flex overflow-hidden relative" oncontextmenu={handleContextMenu}>
<aside class="w-56 flex-shrink-0 hidden md:block">
<FileTree
{files}
{activeFileId}
{entrypoint}
{readOnly}
onSelect={selectFile}
onCreate={createFile}
onUpload={uploadFiles}
onRename={renameFile}
onDelete={deleteFile}
onSetEntry={setEntry}
/>
</aside>
{#if !readOnly}
<div class="flex flex-col min-h-0 {$previewOpenStore ? 'w-full md:w-1/2 border-r border-gray-200 dark:border-white/10' : 'flex-1'}">
{#if ready && activeEntry}
{#key activeFileId}
<Editor ytext={activeEntry.text} awarenessProvider={activeEntry.provider} filePath={activePath} enableLsp={false} />
{/key}
{/if}
</div>
{/if}
{#if $previewOpenStore || readOnly}
<div class="{readOnly ? 'flex-1' : 'w-full md:w-1/2'} relative bg-white/50 dark:bg-black/20 flex flex-col">
<Preview {svgs} />
<ErrorBanner {errors} />
</div>
{/if}
</main>
<DocFooter />
</div>
{#if contextMenu.show}
<div class="fixed z-[9999] bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-lg shadow-xl border border-[var(--theme-border)] py-1 min-w-[180px] overflow-hidden" style="left: {contextMenu.x}px; top: {contextMenu.y}px;">
<button onclick={() => { navigator.clipboard.writeText(contextMenu.text); closeContextMenu(); }} class="w-full text-left px-4 py-2 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-500"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
Copy Text
</button>
</div>
{/if}
{#if showPublish}
<PublishPackageModal {spaceId} onClose={() => (showPublish = false)} />
{/if}
+207
View File
@@ -0,0 +1,207 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Icon from '@iconify/svelte';
import Navbar from '$lib/components/dashboard/Navbar.svelte';
import SpaceCard from '$lib/components/dashboard/SpaceCard.svelte';
interface Space {
id: string;
name: string;
entrypoint: string;
thumbnail_svg?: string;
updated_at: string;
effective_role?: string;
}
let spaces = $state<Space[]>([]);
let shared = $state<Space[]>([]);
let loading = $state(true);
let showCreate = $state(false);
let newName = $state('');
let creating = $state(false);
let activeMenu = $state<string | null>(null);
let showRename = $state(false);
let renameId = $state('');
let renameName = $state('');
let showInfo = $state(false);
let infoSpace = $state<Space | null>(null);
function setActiveMenu(id: string | null) { activeMenu = id; }
function openInfo(space: Space) { activeMenu = null; infoSpace = space; showInfo = true; }
function openRename(id: string, name: string) { activeMenu = null; renameId = id; renameName = name; showRename = true; }
async function submitRename(e: Event) {
e.preventDefault();
if (!renameName.trim()) return;
const res = await fetch(`/api/spaces/${renameId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameName.trim() })
});
if (res.ok) {
spaces = spaces.map((s) => (s.id === renameId ? { ...s, name: renameName.trim() } : s));
}
showRename = false;
}
function handleWindowClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.action-menu-container')) activeMenu = null;
}
async function load() {
loading = true;
const [own, sh] = await Promise.all([
fetch('/api/spaces').then((r) => (r.ok ? r.json() : [])),
fetch('/api/spaces/shared').then((r) => (r.ok ? r.json() : []))
]);
spaces = own;
shared = sh;
loading = false;
}
async function create() {
if (!newName.trim()) return;
creating = true;
const res = await fetch('/api/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim() })
});
creating = false;
if (res.ok) {
const space = await res.json();
goto(`/space/${space.id}`);
}
}
async function remove(id: string, name: string) {
if (!confirm(`Delete space "${name}"? This cannot be undone.`)) return;
const res = await fetch(`/api/spaces/${id}`, { method: 'DELETE' });
if (res.ok) spaces = spaces.filter((s) => s.id !== id);
}
onMount(load);
</script>
<svelte:head>
<title>Spaces - TypstDrive</title>
</svelte:head>
<svelte:window onclick={handleWindowClick} />
<div class="min-h-screen bg-gray-50 dark:bg-[var(--theme-bg)]">
<Navbar />
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors mb-2 flex items-center gap-1.5">
<Icon icon="mdi:arrow-left" class="text-lg" />
Back to Dashboard
</button>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:folder-multiple-outline" class="text-blue-500" />
Spaces
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Multi-file Typst workspaces with their own <code class="font-mono text-xs">typst.toml</code>.</p>
</div>
<button onclick={() => { showCreate = true; newName = ''; }} class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 flex items-center gap-2">
<Icon icon="mdi:plus" class="text-lg" /> New Space
</button>
</div>
{#if loading}
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
{:else}
{#if spaces.length === 0}
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
<Icon icon="mdi:folder-multiple-outline" class="text-5xl mx-auto mb-3 opacity-50" />
<p>No spaces yet. Create one to start a multi-file project.</p>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each spaces as space (space.id)}
<SpaceCard
{space}
{activeMenu}
{setActiveMenu}
{openInfo}
{openRename}
deleteSpace={remove}
/>
{/each}
</div>
{/if}
{#if shared.length > 0}
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-10 mb-4 flex items-center gap-2">
<Icon icon="mdi:account-group-outline" class="text-blue-500" /> Shared with me
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each shared as space (space.id)}
<button onclick={() => goto(`/space/${space.id}`)} class="text-left bg-white dark:bg-black/20 rounded-xl border border-gray-200 dark:border-white/10 overflow-hidden hover:shadow-md transition-shadow">
<div class="h-32 bg-gray-50 dark:bg-black/30 flex items-center justify-center overflow-hidden border-b border-gray-100 dark:border-white/5">
{#if space.thumbnail_svg}
{@html space.thumbnail_svg}
{:else}
<Icon icon="mdi:folder-multiple-outline" class="text-4xl text-gray-300 dark:text-gray-600" />
{/if}
</div>
<div class="p-3">
<p class="font-medium text-gray-900 dark:text-white truncate">{space.name}</p>
<p class="text-xs text-gray-400 mt-0.5">{space.effective_role}</p>
</div>
</button>
{/each}
</div>
{/if}
{/if}
</div>
</div>
{#if showCreate}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showCreate = false)} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md p-6" onclick={(e) => e.stopPropagation()} role="presentation">
<h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:folder-plus-outline" class="text-blue-500" /> New Space</h2>
<input bind:value={newName} placeholder="Space name" onkeydown={(e) => e.key === 'Enter' && create()} class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-white/10 bg-transparent text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500/40" />
<div class="flex justify-end gap-2">
<button onclick={() => (showCreate = false)} class="px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10">Cancel</button>
<button onclick={create} disabled={creating} class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">Create</button>
</div>
</div>
</div>
{/if}
{#if showRename}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showRename = false)} role="presentation">
<form onsubmit={submitRename} class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md p-6" onclick={(e) => e.stopPropagation()}>
<h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Space</h2>
<input bind:value={renameName} class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-white/10 bg-transparent text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500/40" />
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (showRename = false)} class="px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10">Cancel</button>
<button type="submit" class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700">Save</button>
</div>
</form>
</div>
{/if}
{#if showInfo && infoSpace}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showInfo = false)} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
<div class="p-6 border-b border-gray-100 dark:border-white/10 flex items-center gap-3">
<Icon icon="mdi:folder-multiple-outline" class="text-xl text-blue-500" />
<h3 class="text-lg font-semibold flex-grow truncate">{infoSpace.name}</h3>
</div>
<div class="p-6 space-y-4 text-sm">
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Entrypoint</p><p class="font-mono">{infoSpace.entrypoint}</p></div>
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p><p>{new Date(infoSpace.updated_at.endsWith('Z') ? infoSpace.updated_at : infoSpace.updated_at + 'Z').toLocaleString()}</p></div>
</div>
<div class="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 flex justify-end">
<button onclick={() => (showInfo = false)} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg">Close</button>
</div>
</div>
</div>
{/if}
+1 -1
Submodule typst updated: de6f400976...9dfd3a0850