Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36f41f00f0 | ||
|
|
57dbdd5d02 | ||
|
|
9d3966772b | ||
|
|
7ab71fc843 | ||
|
|
b50e0c0224 | ||
|
|
7586e5a8e4 | ||
|
|
9619a892d5 | ||
|
|
bf4ea1e661 | ||
|
|
2110d408bd | ||
|
|
de2c2aaccc | ||
|
|
538ddf70d8 | ||
|
|
2cfa4afe92 | ||
|
|
5d3681bd3f | ||
|
|
40555d7d6a | ||
|
|
a399636db9 | ||
|
|
a2b7b5871a | ||
|
|
7de21ffcd5 | ||
|
|
83f2503081 | ||
|
|
0ac2b21591 | ||
|
|
f77d23fab4 | ||
|
|
1b84df88c6 | ||
|
|
e6a5dbd90c | ||
|
|
3fe3544223 | ||
|
|
039c88d4d0 | ||
|
|
be6dce4d4a | ||
|
|
630b668760 | ||
|
|
690d504535 | ||
|
|
428a8d021e | ||
|
|
1afa80f332 | ||
|
|
9644361bad | ||
|
|
68ffa14622 | ||
|
|
bbd7be86a5 | ||
|
|
c1fdb5a1b7 | ||
|
|
18738c399d | ||
|
|
c42ae39bb8 | ||
|
|
e9155be432 | ||
|
|
5563c0279c | ||
|
|
13dff1fb3b |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Build Frontend
|
# 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
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN bun install
|
RUN bun install
|
||||||
@@ -7,10 +9,12 @@ COPY . .
|
|||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
# Build Backend
|
# Build Backend
|
||||||
|
# Built for the target platform (under QEMU emulation for non-native arches).
|
||||||
FROM rust:alpine AS backend-builder
|
FROM rust:alpine AS backend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig git
|
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/Cargo.* server/
|
||||||
COPY server/src server/src
|
COPY server/src server/src
|
||||||
WORKDIR /app/server
|
WORKDIR /app/server
|
||||||
@@ -18,10 +22,21 @@ RUN cargo build --release
|
|||||||
|
|
||||||
# Final Runtime Image
|
# Final Runtime Image
|
||||||
FROM alpine:3.19
|
FROM alpine:3.19
|
||||||
|
# Provided automatically by buildx (e.g. "amd64", "arm64").
|
||||||
|
ARG TARGETARCH
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache libgcc openssl pandoc curl sqlite
|
RUN apk add --no-cache libgcc openssl pandoc curl sqlite
|
||||||
RUN mkdir -p /data
|
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=frontend-builder /app/build /app/build
|
||||||
COPY --from=backend-builder /app/server/target/release/server /app/server
|
COPY --from=backend-builder /app/server/target/release/server /app/server
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# TypstDrive
|
# TypstDrive
|
||||||
|
|
||||||
[](https://github.com/your-username/typstdrive)
|
[](https://github.com/sirblobby/typstdrive)
|
||||||
[](https://typst.app/)
|
[](https://typst.app/)
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](https://kit.svelte.dev/)
|
[](https://kit.svelte.dev/)
|
||||||
[](https://tailwindcss.com/)
|
[](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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Fonts & Images
|
||||||
|
|
||||||
@@ -62,6 +66,45 @@ Uploaded images can be referenced natively using the `#image` function in Typst.
|
|||||||
#image("logo.png", width: 50%)
|
#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
|
## Self-Hosting
|
||||||
|
|
||||||
TypstDrive is completely self-hostable. A Docker image packages both the Rust backend and the SvelteKit frontend into a single container.
|
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
|
### Getting Started
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Pull the image:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/typstdrive.git
|
docker pull ghcr.io/sirblobby/typstdrive:latest
|
||||||
cd typstdrive
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
docker compose up -d
|
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.
|
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
|
### 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.
|
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
|
- **Toggle Admin** — promote or demote any other user
|
||||||
- **Delete User** — permanently remove any account other than your own
|
- **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
|
## Contributing & Local Development
|
||||||
|
|
||||||
Clone the official Typst compiler into the `typst/` folder before building the backend:
|
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>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="preview/dashboard.png" alt="Dashboard view" width="49%">
|
<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>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "typstdrive",
|
"name": "typstdrive",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev --host",
|
||||||
@@ -14,32 +14,36 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.56.1",
|
"@sveltejs/kit": "^2.70.3",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"svelte": "^5.55.1",
|
"svelte": "^5.57.0",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.7.6",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.2",
|
"vite": "^7.3.6",
|
||||||
"vite-plugin-top-level-await": "^1.6.0",
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
"vite-plugin-wasm": "^3.6.0"
|
"vite-plugin-wasm": "^3.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.11.0",
|
||||||
"@codemirror/lang-rust": "^6.0.2",
|
"@codemirror/lang-rust": "^6.0.2",
|
||||||
"@codemirror/lint": "^6.9.5",
|
"@codemirror/lint": "^6.9.7",
|
||||||
"@codemirror/lsp-client": "^6.2.2",
|
"@codemirror/lsp-client": "^6.2.5",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/legacy-modes": "^6.5.4",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/language": "^6.12.4",
|
||||||
"@iconify/svelte": "^5.2.1",
|
"@codemirror/state": "^6.7.4",
|
||||||
|
"@codemirror/view": "^6.43.11",
|
||||||
|
"@iconify/svelte": "^5.2.2",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-typst": "^0.4.0",
|
"codemirror-lang-typst": "^0.4.0",
|
||||||
"y-codemirror.next": "^0.3.5",
|
"highlight.js": "^11.12.0",
|
||||||
"y-websocket": "^3.0.0",
|
"y-codemirror.next": "^0.3.6",
|
||||||
"yjs": "^13.6.30"
|
"y-websocket": "^3.1.0",
|
||||||
|
"yjs": "^13.6.32"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 464 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 118 KiB |
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -20,16 +20,21 @@ argon2 = "0.5"
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
ecow = "0.2"
|
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-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
|
||||||
typst-pdf = { path = "../typst/crates/typst-pdf" }
|
typst-pdf = { path = "../typst/crates/typst-pdf" }
|
||||||
typst-render = { path = "../typst/crates/typst-render" }
|
typst-render = { path = "../typst/crates/typst-render" }
|
||||||
typst-svg = { path = "../typst/crates/typst-svg" }
|
typst-svg = { path = "../typst/crates/typst-svg" }
|
||||||
|
typst-html = { path = "../typst/crates/typst-html" }
|
||||||
typst-layout = { path = "../typst/crates/typst-layout" }
|
typst-layout = { path = "../typst/crates/typst-layout" }
|
||||||
|
|
||||||
yrs = "0.18.8"
|
yrs = "0.18.8"
|
||||||
yrs-axum = "0.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"
|
tokio-stream = "0.1.18"
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
toml = "0.8"
|
||||||
|
ureq = "2.12"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -11,8 +11,6 @@ use crate::{
|
|||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const USER_FIELDS: &str = "id, username, email, password_hash, is_admin";
|
|
||||||
|
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2,
|
Argon2,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use serde::Deserialize;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{Collaborator, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest},
|
models::{Collaborator, CollaboratorView, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ pub async fn invite_collaborator(
|
|||||||
return Err((StatusCode::FORBIDDEN, "Only the owner can invite collaborators".to_string()));
|
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)
|
.bind(&payload.email)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
@@ -103,6 +103,82 @@ pub async fn accept_invite(
|
|||||||
Ok(Json(collab))
|
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(
|
pub async fn get_comments(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(doc_id): Path<String>,
|
Path(doc_id): Path<String>,
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ use serde::Serialize;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use typst::diag::{SourceDiagnostic, Warned};
|
use typst::diag::{SourceDiagnostic, Warned};
|
||||||
use typst::layout::{Frame, FrameItem};
|
use typst::layout::{Frame, FrameItem};
|
||||||
|
use typst::utils::Scalar;
|
||||||
|
use typst_html::{HtmlDocument, HtmlOptions};
|
||||||
use typst_layout::PagedDocument;
|
use typst_layout::PagedDocument;
|
||||||
use typst_pdf::{pdf, PdfOptions};
|
use typst_pdf::{pdf, PdfOptions};
|
||||||
use typst_render::render;
|
use typst_render::{render, RenderOptions};
|
||||||
|
use typst_svg::SvgOptions;
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct DocumentStats {
|
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;
|
pub struct TypstCompiler;
|
||||||
|
|
||||||
impl TypstCompiler {
|
impl TypstCompiler {
|
||||||
@@ -58,22 +83,26 @@ impl TypstCompiler {
|
|||||||
|
|
||||||
pub fn compile_svg(
|
pub fn compile_svg(
|
||||||
&self,
|
&self,
|
||||||
text: String,
|
input: ProjectInput,
|
||||||
files: HashMap<String, Vec<u8>>,
|
|
||||||
) -> Result<
|
) -> Result<
|
||||||
(Vec<String>, String, DocumentStats),
|
(Vec<String>, String, DocumentStats),
|
||||||
Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>,
|
Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>,
|
||||||
> {
|
> {
|
||||||
let world = MemoryWorld::new(text, files);
|
let world = input.into_world(false);
|
||||||
match typst::compile::<PagedDocument>(&world) {
|
match typst::compile::<PagedDocument>(&world) {
|
||||||
Warned {
|
Warned {
|
||||||
output: Ok(doc),
|
output: Ok(doc),
|
||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
let stats = extract_stats(&doc);
|
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() {
|
let thumbnail = if let Some(page) = doc.pages().first() {
|
||||||
typst_svg::svg(page)
|
typst_svg::svg(page, &options)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -83,15 +112,11 @@ impl TypstCompiler {
|
|||||||
output: Err(errors),
|
output: Err(errors),
|
||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
use typst::World;
|
use typst::WorldExt;
|
||||||
let diag = errors
|
let diag = errors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let range = d
|
let range = world.range(d.span);
|
||||||
.span
|
|
||||||
.id()
|
|
||||||
.and_then(|id| world.source(id).ok())
|
|
||||||
.and_then(|s| s.range(d.span));
|
|
||||||
(d, range)
|
(d, range)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -102,10 +127,9 @@ impl TypstCompiler {
|
|||||||
|
|
||||||
pub fn export_pdf(
|
pub fn export_pdf(
|
||||||
&self,
|
&self,
|
||||||
text: String,
|
input: ProjectInput,
|
||||||
files: HashMap<String, Vec<u8>>,
|
|
||||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
) -> 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) {
|
match typst::compile::<PagedDocument>(&world) {
|
||||||
Warned {
|
Warned {
|
||||||
output: Ok(doc),
|
output: Ok(doc),
|
||||||
@@ -121,15 +145,11 @@ impl TypstCompiler {
|
|||||||
output: Err(errors),
|
output: Err(errors),
|
||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
use typst::World;
|
use typst::WorldExt;
|
||||||
Err(errors
|
Err(errors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let range = d
|
let range = world.range(d.span);
|
||||||
.span
|
|
||||||
.id()
|
|
||||||
.and_then(|id| world.source(id).ok())
|
|
||||||
.and_then(|s| s.range(d.span));
|
|
||||||
(d, range)
|
(d, range)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -139,17 +159,20 @@ impl TypstCompiler {
|
|||||||
|
|
||||||
pub fn export_png(
|
pub fn export_png(
|
||||||
&self,
|
&self,
|
||||||
text: String,
|
input: ProjectInput,
|
||||||
files: HashMap<String, Vec<u8>>,
|
|
||||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
) -> 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) {
|
match typst::compile::<PagedDocument>(&world) {
|
||||||
Warned {
|
Warned {
|
||||||
output: Ok(doc),
|
output: Ok(doc),
|
||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
if let Some(page) = doc.pages().first() {
|
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() {
|
if let Ok(encoded) = pixmap.encode_png() {
|
||||||
return Ok(encoded);
|
return Ok(encoded);
|
||||||
}
|
}
|
||||||
@@ -160,15 +183,51 @@ impl TypstCompiler {
|
|||||||
output: Err(errors),
|
output: Err(errors),
|
||||||
warnings: _,
|
warnings: _,
|
||||||
} => {
|
} => {
|
||||||
use typst::World;
|
use typst::WorldExt;
|
||||||
Err(errors
|
Err(errors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let range = d
|
let range = world.range(d.span);
|
||||||
.span
|
(d, range)
|
||||||
.id()
|
})
|
||||||
.and_then(|id| world.source(id).ok())
|
.collect())
|
||||||
.and_then(|s| s.range(d.span));
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
(d, range)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
|
|
||||||
pub async fn init_schema(pool: &AnyPool) {
|
pub async fn init_schema(pool: &AnyPool) {
|
||||||
|
// Rename the legacy "space" tables/columns to the "project" vocabulary on
|
||||||
|
// existing databases. Best-effort: on a fresh database (or one already
|
||||||
|
// migrated) the old names don't exist, so these fail silently and the
|
||||||
|
// CREATE TABLE IF NOT EXISTS statements below take over.
|
||||||
|
let rename_migrations = [
|
||||||
|
"ALTER TABLE IF EXISTS spaces RENAME TO projects",
|
||||||
|
"ALTER TABLE IF EXISTS space_files RENAME TO project_files",
|
||||||
|
"ALTER TABLE IF EXISTS space_collaborators RENAME TO project_collaborators",
|
||||||
|
"ALTER TABLE IF EXISTS project_files RENAME COLUMN space_id TO project_id",
|
||||||
|
"ALTER TABLE IF EXISTS project_collaborators RENAME COLUMN space_id TO project_id",
|
||||||
|
];
|
||||||
|
for stmt in &rename_migrations {
|
||||||
|
let _ = sqlx::query(stmt).execute(pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
let statements = [
|
let statements = [
|
||||||
"CREATE TABLE IF NOT EXISTS users (
|
"CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -76,6 +91,97 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
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 projects (
|
||||||
|
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 project_files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(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(project_id, path)
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS project_collaborators (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(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(project_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 {
|
for stmt in &statements {
|
||||||
@@ -90,6 +196,7 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT",
|
"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 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 users ADD COLUMN IF NOT EXISTS created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
|
||||||
|
"ALTER TABLE project_files ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
|
||||||
];
|
];
|
||||||
for stmt in &migrations {
|
for stmt in &migrations {
|
||||||
sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default());
|
sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default());
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to enable SQLite foreign keys");
|
.expect("Failed to enable SQLite foreign keys");
|
||||||
|
|
||||||
|
// Rename the legacy "space" tables/columns to the "project" vocabulary on
|
||||||
|
// existing databases. Best-effort: on a fresh database (or one already
|
||||||
|
// migrated) the old names don't exist, so these fail silently and the
|
||||||
|
// CREATE TABLE IF NOT EXISTS statements below take over.
|
||||||
|
let rename_migrations = [
|
||||||
|
"ALTER TABLE spaces RENAME TO projects",
|
||||||
|
"ALTER TABLE space_files RENAME TO project_files",
|
||||||
|
"ALTER TABLE space_collaborators RENAME TO project_collaborators",
|
||||||
|
"ALTER TABLE project_files RENAME COLUMN space_id TO project_id",
|
||||||
|
"ALTER TABLE project_collaborators RENAME COLUMN space_id TO project_id",
|
||||||
|
];
|
||||||
|
for stmt in &rename_migrations {
|
||||||
|
let _ = sqlx::query(stmt).execute(pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
let statements = [
|
let statements = [
|
||||||
"CREATE TABLE IF NOT EXISTS users (
|
"CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -81,6 +96,97 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
|
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 projects (
|
||||||
|
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 project_files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(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(project_id, path)
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS project_collaborators (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(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(project_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 {
|
for stmt in &statements {
|
||||||
@@ -94,6 +200,7 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
let migrations = [
|
let migrations = [
|
||||||
"ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0",
|
"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 users ADD COLUMN created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
|
||||||
|
"ALTER TABLE project_files ADD COLUMN updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
|
||||||
];
|
];
|
||||||
for stmt in &migrations {
|
for stmt in &migrations {
|
||||||
let _ = sqlx::query(stmt).execute(pool).await;
|
let _ = sqlx::query(stmt).execute(pool).await;
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
Path, State,
|
||||||
|
},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::SignedCookieJar;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{broadcast, watch};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
fn hash_token(token: &str) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(token.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.filter(|value| value.starts_with("Bearer "))
|
||||||
|
.map(|value| value[7..].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_user_id(jar: &SignedCookieJar) -> Option<String> {
|
||||||
|
jar.get("session_user_id").map(|c| c.value().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authenticate_device(
|
||||||
|
state: &AppState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<(String, String), (StatusCode, String)> {
|
||||||
|
let token = bearer_token(headers).ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Missing Authorization header".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT id, user_id FROM device_tokens WHERE token_hash = ?",
|
||||||
|
)
|
||||||
|
.bind(hash_token(&token))
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let (device_id, user_id) = row.ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Invalid device token".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok((user_id, device_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct DeviceEvent {
|
||||||
|
pub kind: String,
|
||||||
|
pub project_id: Option<String>,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceEvent {
|
||||||
|
pub fn project(project_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: "project".to_string(),
|
||||||
|
project_id: Some(project_id.to_string()),
|
||||||
|
document_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn document(document_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: "document".to_string(),
|
||||||
|
project_id: None,
|
||||||
|
document_id: Some(document_id.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn structure() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: "structure".to_string(),
|
||||||
|
project_id: None,
|
||||||
|
document_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn notify_devices(state: &AppState, user_id: &str, event: DeviceEvent) {
|
||||||
|
let events = state.device_events.lock().await;
|
||||||
|
if let Some(sender) = events.get(user_id) {
|
||||||
|
let _ = sender.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DevicePresence {
|
||||||
|
pub connected_since: String,
|
||||||
|
pub connection_id: String,
|
||||||
|
pub stop: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ws_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
) -> Result<axum::response::Response, (StatusCode, String)> {
|
||||||
|
let (user_id, device_id) = authenticate_device(&state, &headers).await?;
|
||||||
|
|
||||||
|
Ok(ws.on_upgrade(move |socket| handle_socket(state, socket, user_id, device_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(state: AppState, socket: WebSocket, user_id: String, device_id: String) {
|
||||||
|
let mut receiver = {
|
||||||
|
let mut events = state.device_events.lock().await;
|
||||||
|
let sender = events
|
||||||
|
.entry(user_id)
|
||||||
|
.or_insert_with(|| broadcast::channel(16).0)
|
||||||
|
.clone();
|
||||||
|
sender.subscribe()
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection_id = Uuid::new_v4().to_string();
|
||||||
|
let (stop_tx, mut stop_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut presence = state.device_presence.lock().await;
|
||||||
|
presence.insert(
|
||||||
|
device_id.clone(),
|
||||||
|
DevicePresence {
|
||||||
|
connected_since: chrono::Utc::now().to_rfc3339(),
|
||||||
|
connection_id: connection_id.clone(),
|
||||||
|
stop: stop_tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
let mut heartbeat = tokio::time::interval(Duration::from_secs(20));
|
||||||
|
heartbeat.tick().await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
event = receiver.recv() => {
|
||||||
|
match event {
|
||||||
|
Ok(event) => {
|
||||||
|
let Ok(payload) = serde_json::to_string(&event) else { continue };
|
||||||
|
if sink.send(Message::Text(payload.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = heartbeat.tick() => {
|
||||||
|
if sink.send(Message::Ping(Vec::new().into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
incoming = stream.next() => {
|
||||||
|
match incoming {
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
Some(Err(_)) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = stop_rx.changed() => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut presence = state.device_presence.lock().await;
|
||||||
|
if presence
|
||||||
|
.get(&device_id)
|
||||||
|
.map(|entry| entry.connection_id == connection_id)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
presence.remove(&device_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DeviceView {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub connected: bool,
|
||||||
|
pub connected_since: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_devices(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Vec<DeviceView>>, (StatusCode, String)> {
|
||||||
|
let user_id =
|
||||||
|
get_user_id(&jar).ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, (String, String, String, Option<String>)>(
|
||||||
|
"SELECT id, name, created_at, last_used_at FROM device_tokens 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()))?;
|
||||||
|
|
||||||
|
let presence = state.device_presence.lock().await;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|(id, name, created_at, last_used_at)| {
|
||||||
|
let entry = presence.get(&id);
|
||||||
|
DeviceView {
|
||||||
|
connected: entry.is_some(),
|
||||||
|
connected_since: entry.map(|e| e.connected_since.clone()),
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
last_used_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Path(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 device_tokens WHERE id = ? AND user_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, "Device not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let presence = state.device_presence.lock().await;
|
||||||
|
if let Some(entry) = presence.get(&id) {
|
||||||
|
let _ = entry.stop.send(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -17,6 +17,28 @@ pub struct ListDocsQuery {
|
|||||||
pub folder_id: Option<String>,
|
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(
|
pub async fn list_documents(
|
||||||
axum::extract::Query(query): axum::extract::Query<ListDocsQuery>,
|
axum::extract::Query(query): axum::extract::Query<ListDocsQuery>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State, Multipart},
|
extract::{Path, Query, State, Multipart},
|
||||||
http::{header, StatusCode},
|
http::{header, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use yrs_axum::ws::AxumSink;
|
use yrs_axum::ws::AxumSink;
|
||||||
@@ -14,6 +15,7 @@ use yrs::{Doc, ReadTxn, Transact, Update};
|
|||||||
use yrs::updates::decoder::Decode;
|
use yrs::updates::decoder::Decode;
|
||||||
use futures_util::stream::{StreamExt, Stream};
|
use futures_util::stream::{StreamExt, Stream};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::devices::{notify_devices, DeviceEvent};
|
||||||
use crate::models::Document;
|
use crate::models::Document;
|
||||||
|
|
||||||
pub struct ViewerFilterStream {
|
pub struct ViewerFilterStream {
|
||||||
@@ -50,11 +52,29 @@ impl Stream for ViewerFilterStream {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CompileRequest {
|
pub struct CompileRequest {
|
||||||
pub text: String,
|
#[serde(default)]
|
||||||
|
pub text: Option<String>,
|
||||||
pub document_id: Option<String>,
|
pub document_id: Option<String>,
|
||||||
|
pub project_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)]
|
#[derive(Serialize)]
|
||||||
pub struct CompileResponse {
|
pub struct CompileResponse {
|
||||||
@@ -71,14 +91,55 @@ pub struct Diagnostic {
|
|||||||
pub to: Option<usize>,
|
pub to: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct YjsSaveTarget {
|
||||||
|
table: &'static str,
|
||||||
|
row_id: String,
|
||||||
|
owner_id: String,
|
||||||
|
event: DeviceEvent,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn yjs_handler(
|
pub async fn yjs_handler(
|
||||||
ws: axum::extract::ws::WebSocketUpgrade,
|
ws: axum::extract::ws::WebSocketUpgrade,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: axum_extra::extract::cookie::SignedCookieJar,
|
jar: axum_extra::extract::cookie::SignedCookieJar,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
let user_id_opt = match jar.get("session_user_id").map(|c| c.value().to_string()) {
|
||||||
|
Some(uid) => Some(uid),
|
||||||
|
None => match params.get("token") {
|
||||||
|
Some(token) => crate::desktop::user_id_for_token(&state, token).await,
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut is_viewer = true;
|
||||||
|
let mut initial_content: Option<Vec<u8>> = None;
|
||||||
|
let mut save_target: Option<YjsSaveTarget> = None;
|
||||||
|
|
||||||
|
if let Some(rest) = id.strip_prefix("project:") {
|
||||||
|
if let Some((project_id, file_id)) = rest.split_once(':') {
|
||||||
|
if let Some((project, role)) = crate::projects::project_role(&state, project_id, &user_id_opt).await {
|
||||||
|
is_viewer = role == "viewer";
|
||||||
|
if let Ok(Some((content,))) = sqlx::query_as::<_, (Option<Vec<u8>>,)>(
|
||||||
|
"SELECT content FROM project_files WHERE id = ? AND project_id = ?"
|
||||||
|
)
|
||||||
|
.bind(file_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
initial_content = content;
|
||||||
|
}
|
||||||
|
save_target = Some(YjsSaveTarget {
|
||||||
|
table: "project_files",
|
||||||
|
row_id: file_id.to_string(),
|
||||||
|
owner_id: project.owner_id,
|
||||||
|
event: DeviceEvent::project(project_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let doc_info = sqlx::query_as::<_, Document>(
|
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 = ?"
|
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
|
||||||
)
|
)
|
||||||
@@ -86,7 +147,6 @@ pub async fn yjs_handler(
|
|||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let mut is_viewer = true;
|
|
||||||
if let Ok(Some(ref d)) = doc_info {
|
if let Ok(Some(ref d)) = doc_info {
|
||||||
if let Some(uid) = &user_id_opt {
|
if let Some(uid) = &user_id_opt {
|
||||||
if &d.owner_id == uid {
|
if &d.owner_id == uid {
|
||||||
@@ -107,6 +167,14 @@ pub async fn yjs_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
initial_content = d.content.clone();
|
||||||
|
save_target = Some(YjsSaveTarget {
|
||||||
|
table: "documents",
|
||||||
|
row_id: id.clone(),
|
||||||
|
owner_id: d.owner_id.clone(),
|
||||||
|
event: DeviceEvent::document(&id),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bcast_map = state.bcast_map.lock().await;
|
let mut bcast_map = state.bcast_map.lock().await;
|
||||||
@@ -115,34 +183,51 @@ pub async fn yjs_handler(
|
|||||||
} else {
|
} else {
|
||||||
let ydoc = Doc::new();
|
let ydoc = Doc::new();
|
||||||
|
|
||||||
if let Ok(Some(db_doc)) = doc_info {
|
if let Some(content) = initial_content {
|
||||||
if let Some(content) = db_doc.content {
|
|
||||||
if let Ok(update) = Update::decode_v1(&content) {
|
if let Ok(update) = Update::decode_v1(&content) {
|
||||||
ydoc.transact_mut().apply_update(update);
|
ydoc.transact_mut().apply_update(update);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let awareness = Arc::new(RwLock::new(Awareness::new(ydoc)));
|
let awareness = Arc::new(RwLock::new(Awareness::new(ydoc)));
|
||||||
let new_bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 10).await);
|
let new_bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 10).await);
|
||||||
bcast_map.insert(id.clone(), new_bcast.clone());
|
bcast_map.insert(id.clone(), new_bcast.clone());
|
||||||
|
|
||||||
|
if let Some(target) = save_target {
|
||||||
let save_db = state.db.clone();
|
let save_db = state.db.clone();
|
||||||
let save_id = id.clone();
|
|
||||||
let save_awareness = awareness.clone();
|
let save_awareness = awareness.clone();
|
||||||
|
let save_state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||||
|
let mut last_content: Option<Vec<u8>> = None;
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
let doc = save_awareness.read().await;
|
let doc = save_awareness.read().await;
|
||||||
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
|
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 = ?")
|
drop(doc);
|
||||||
.bind(content)
|
|
||||||
.bind(&save_id)
|
if last_content.as_ref() == Some(&content) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = if target.table == "project_files" {
|
||||||
|
"UPDATE project_files SET content = ? WHERE id = ?"
|
||||||
|
} else {
|
||||||
|
"UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||||
|
};
|
||||||
|
let result = sqlx::query(query)
|
||||||
|
.bind(&content)
|
||||||
|
.bind(&target.row_id)
|
||||||
.execute(&save_db)
|
.execute(&save_db)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
if result.is_ok() {
|
||||||
|
last_content = Some(content);
|
||||||
|
notify_devices(&save_state, &target.owner_id, target.event.clone()).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
new_bcast
|
new_bcast
|
||||||
};
|
};
|
||||||
@@ -175,6 +260,54 @@ pub async fn compile_handler(
|
|||||||
let mut can_save_thumbnail = false;
|
let mut can_save_thumbnail = false;
|
||||||
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
|
|
||||||
|
if let Some(project_id) = &payload.project_id {
|
||||||
|
let (project, role) = match crate::projects::project_role(&state, project_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::projects::assemble_project(&state, &project, 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 projects SET thumbnail_svg = ? WHERE id = ?")
|
||||||
|
.bind(&thumbnail)
|
||||||
|
.bind(&project.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 Some(doc_id) = &payload.document_id {
|
||||||
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>(
|
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 = ?"
|
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?"
|
||||||
@@ -208,7 +341,7 @@ pub async fn compile_handler(
|
|||||||
|
|
||||||
if has_access {
|
if has_access {
|
||||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
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)
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -216,12 +349,25 @@ pub async fn compile_handler(
|
|||||||
files_map.insert(name, data);
|
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;
|
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)) => {
|
Ok((svgs, thumbnail, stats)) => {
|
||||||
if let Some(doc_id) = &payload.document_id {
|
if let Some(doc_id) = &payload.document_id {
|
||||||
if can_save_thumbnail {
|
if can_save_thumbnail {
|
||||||
@@ -298,7 +444,7 @@ pub async fn export_handler(
|
|||||||
|
|
||||||
if has_access {
|
if has_access {
|
||||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
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)
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -306,14 +452,38 @@ pub async fn export_handler(
|
|||||||
files_map.insert(name, data);
|
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(project_id) = &payload.project_id {
|
||||||
|
match crate::projects::project_role(&state, project_id, &user_id_opt).await {
|
||||||
|
Some((project, _)) => {
|
||||||
|
let overrides = payload.files.clone().unwrap_or_default();
|
||||||
|
crate::projects::assemble_project(&state, &project, 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;
|
let compiler = state.compiler.lock().await;
|
||||||
|
|
||||||
match format.as_str() {
|
match format.as_str() {
|
||||||
"pdf" => match compiler.export_pdf(payload.text, files_map.clone()) {
|
"pdf" => match compiler.export_pdf(input) {
|
||||||
Ok(bytes) => (
|
Ok(bytes) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[(header::CONTENT_TYPE, "application/pdf")],
|
[(header::CONTENT_TYPE, "application/pdf")],
|
||||||
@@ -322,7 +492,7 @@ pub async fn export_handler(
|
|||||||
.into_response(),
|
.into_response(),
|
||||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").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) => (
|
Ok(bytes) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[(header::CONTENT_TYPE, "image/png")],
|
[(header::CONTENT_TYPE, "image/png")],
|
||||||
@@ -331,7 +501,7 @@ pub async fn export_handler(
|
|||||||
.into_response(),
|
.into_response(),
|
||||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").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, _, _)) => {
|
Ok((svgs, _, _)) => {
|
||||||
let mut combined = String::new();
|
let mut combined = String::new();
|
||||||
for svg in svgs {
|
for svg in svgs {
|
||||||
@@ -383,7 +553,7 @@ pub async fn pandoc_export_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut stdin = child.stdin.take().unwrap();
|
let mut stdin = child.stdin.take().unwrap();
|
||||||
let text = payload.text.clone();
|
let text = payload.text.clone().unwrap_or_default();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
let _ = stdin.write_all(text.as_bytes()).await;
|
let _ = stdin.write_all(text.as_bytes()).await;
|
||||||
@@ -556,8 +726,10 @@ pub async fn lsp_handler(
|
|||||||
.arg("--font-path")
|
.arg("--font-path")
|
||||||
.arg(temp_dir.path())
|
.arg(temp_dir.path())
|
||||||
.current_dir(temp_dir.path())
|
.current_dir(temp_dir.path())
|
||||||
|
.env("RUST_LOG", "warn")
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
|
.kill_on_drop(true)
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to start tinymist lsp");
|
.expect("Failed to start tinymist lsp");
|
||||||
|
|
||||||
@@ -575,7 +747,7 @@ pub async fn lsp_handler(
|
|||||||
use futures_util::SinkExt;
|
use futures_util::SinkExt;
|
||||||
let _ = ws_tx.send(axum::extract::ws::Message::Text(init_msg.to_string().into())).await;
|
let _ = ws_tx.send(axum::extract::ws::Message::Text(init_msg.to_string().into())).await;
|
||||||
|
|
||||||
let ws_to_lsp = tokio::spawn(async move {
|
let mut ws_to_lsp = tokio::spawn(async move {
|
||||||
while let Some(Ok(axum::extract::ws::Message::Text(msg))) = ws_rx.next().await {
|
while let Some(Ok(axum::extract::ws::Message::Text(msg))) = ws_rx.next().await {
|
||||||
let content_length = format!("Content-Length: {}\r\n\r\n", msg.len());
|
let content_length = format!("Content-Length: {}\r\n\r\n", msg.len());
|
||||||
if stdin.write_all(content_length.as_bytes()).await.is_err() {
|
if stdin.write_all(content_length.as_bytes()).await.is_err() {
|
||||||
@@ -587,7 +759,7 @@ pub async fn lsp_handler(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let lsp_to_ws = tokio::spawn(async move {
|
let mut lsp_to_ws = tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let mut content_length = 0;
|
let mut content_length = 0;
|
||||||
let mut header = String::new();
|
let mut header = String::new();
|
||||||
@@ -627,9 +799,13 @@ pub async fn lsp_handler(
|
|||||||
});
|
});
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = ws_to_lsp => {}
|
_ = &mut ws_to_lsp => {}
|
||||||
_ = lsp_to_ws => {}
|
_ = &mut lsp_to_ws => {}
|
||||||
_ = child.wait() => {}
|
_ = child.wait() => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ws_to_lsp.abort();
|
||||||
|
lsp_to_ws.abort();
|
||||||
|
let _ = child.kill().await;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,29 @@ use axum_extra::extract::cookie::Key;
|
|||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::{broadcast, Mutex};
|
||||||
use yrs_axum::broadcast::BroadcastGroup;
|
use yrs_axum::broadcast::BroadcastGroup;
|
||||||
|
|
||||||
|
use devices::{DeviceEvent, DevicePresence};
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
|
mod api_keys;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod compiler;
|
mod compiler;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod desktop;
|
||||||
|
mod devices;
|
||||||
mod docs;
|
mod docs;
|
||||||
mod folders;
|
mod folders;
|
||||||
mod files;
|
mod files;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod packages;
|
||||||
|
mod projects;
|
||||||
|
mod public_api;
|
||||||
mod setup;
|
mod setup;
|
||||||
mod world;
|
mod world;
|
||||||
mod collab;
|
mod collab;
|
||||||
@@ -28,6 +36,8 @@ mod collab;
|
|||||||
use compiler::TypstCompiler;
|
use compiler::TypstCompiler;
|
||||||
use handlers::{compile_handler, export_handler, yjs_handler};
|
use handlers::{compile_handler, export_handler, yjs_handler};
|
||||||
|
|
||||||
|
pub type RateLimiterMap = Arc<Mutex<HashMap<String, (u32, std::time::Instant)>>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub compiler: Arc<Mutex<TypstCompiler>>,
|
pub compiler: Arc<Mutex<TypstCompiler>>,
|
||||||
@@ -35,6 +45,9 @@ pub struct AppState {
|
|||||||
pub db: AnyPool,
|
pub db: AnyPool,
|
||||||
pub key: Key,
|
pub key: Key,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
|
pub rate_limiter: RateLimiterMap,
|
||||||
|
pub device_presence: Arc<Mutex<HashMap<String, DevicePresence>>>,
|
||||||
|
pub device_events: Arc<Mutex<HashMap<String, broadcast::Sender<DeviceEvent>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl axum::extract::FromRef<AppState> for Key {
|
impl axum::extract::FromRef<AppState> for Key {
|
||||||
@@ -84,6 +97,9 @@ async fn main() {
|
|||||||
db,
|
db,
|
||||||
key,
|
key,
|
||||||
registration_enabled,
|
registration_enabled,
|
||||||
|
rate_limiter: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
device_presence: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
device_events: Arc::new(Mutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let api_routes = Router::new()
|
let api_routes = Router::new()
|
||||||
@@ -107,14 +123,55 @@ async fn main() {
|
|||||||
.route("/files", get(files::list_files).post(files::upload_file_global))
|
.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}", delete(files::delete_file).patch(files::update_file))
|
||||||
.route("/files/{id}/data", get(files::get_file_data))
|
.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", get(docs::list_documents).post(docs::create_document))
|
||||||
.route("/docs/accept-invite", get(collab::accept_invite))
|
.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}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document))
|
||||||
.route("/docs/{id}/files", post(docs::upload_file))
|
.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}/invite", post(collab::invite_collaborator))
|
||||||
.route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment))
|
.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("/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("/projects/shared", get(projects::list_shared_projects))
|
||||||
|
.route("/projects", get(projects::list_projects).post(projects::create_project))
|
||||||
|
.route("/projects/{id}", get(projects::get_project).delete(projects::delete_project).patch(projects::update_project))
|
||||||
|
.route("/projects/{id}/files", get(projects::list_project_files).post(projects::create_project_file))
|
||||||
|
.route("/projects/{id}/files/upload", post(projects::upload_project_file))
|
||||||
|
.route("/projects/{id}/files/{fid}", get(projects::get_project_file).patch(projects::update_project_file).delete(projects::delete_project_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))
|
||||||
|
.route("/devices", get(devices::list_devices))
|
||||||
|
.route("/devices/{id}", delete(devices::revoke_device));
|
||||||
|
|
||||||
|
let desktop_routes = Router::new()
|
||||||
|
.route("/version", get(desktop::version_info))
|
||||||
|
.route("/auth/login", post(desktop::login))
|
||||||
|
.route("/auth/logout", post(desktop::logout))
|
||||||
|
.route("/auth/me", get(desktop::me))
|
||||||
|
.route("/projects", get(desktop::list_projects).post(desktop::create_project))
|
||||||
|
.route("/projects/{id}", get(desktop::pull_project).delete(desktop::delete_project).patch(desktop::move_project))
|
||||||
|
.route("/projects/{id}/manifest", get(desktop::get_manifest))
|
||||||
|
.route("/folders", get(desktop::list_folders).post(desktop::create_folder))
|
||||||
|
.route("/folders/{id}", patch(desktop::rename_folder).delete(desktop::delete_folder))
|
||||||
|
.route("/folders/{id}/move", patch(desktop::move_folder))
|
||||||
|
.route("/documents", get(desktop::list_documents).post(desktop::create_document))
|
||||||
|
.route("/documents/{id}", get(desktop::pull_document).put(desktop::push_document).delete(desktop::delete_document).patch(desktop::move_document))
|
||||||
|
.route("/shared", get(desktop::list_shared))
|
||||||
|
.route("/files", get(desktop::list_account_files).post(desktop::upload_account_file))
|
||||||
|
.route("/files/{id}", get(desktop::pull_account_file).patch(desktop::rename_account_file).delete(desktop::delete_account_file))
|
||||||
|
.route("/files/{id}/move", patch(desktop::move_account_file))
|
||||||
|
.route("/projects/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file))
|
||||||
|
.route("/ws", get(devices::ws_handler));
|
||||||
|
|
||||||
|
let v1_routes = Router::new()
|
||||||
|
.route("/render", post(public_api::render_handler));
|
||||||
|
|
||||||
let yjs_routes = Router::new()
|
let yjs_routes = Router::new()
|
||||||
.route("/{id}", get(yjs_handler));
|
.route("/{id}", get(yjs_handler));
|
||||||
@@ -122,7 +179,8 @@ async fn main() {
|
|||||||
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../build".to_string());
|
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../build".to_string());
|
||||||
|
|
||||||
let app = Router::new()
|
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()))
|
.nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http()))
|
||||||
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir))))
|
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir))))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|||||||
@@ -72,6 +72,93 @@ pub struct Document {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Project {
|
||||||
|
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 ProjectFile {
|
||||||
|
pub id: String,
|
||||||
|
pub project_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 CreateProjectRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub folder_id: Option<String>,
|
||||||
|
pub template: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateProjectRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub folder_id: Option<String>,
|
||||||
|
pub entrypoint: Option<String>,
|
||||||
|
pub public_role: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreateProjectFileRequest {
|
||||||
|
pub path: String,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateProjectFileRequest {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PublishPackageRequest {
|
||||||
|
pub project_id: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct RegisterRequest {
|
pub struct RegisterRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -133,6 +220,16 @@ pub struct Collaborator {
|
|||||||
pub created_at: String,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct Invitation {
|
pub struct Invitation {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -195,6 +292,27 @@ pub struct AdminCreateUserRequest {
|
|||||||
pub is_admin: Option<bool>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct SetupRequest {
|
pub struct SetupRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|||||||
@@ -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, Project, PublishPackageRequest},
|
||||||
|
projects::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 project = sqlx::query_as::<_, Project>(
|
||||||
|
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ? AND owner_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&payload.project_id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
|
||||||
|
|
||||||
|
let files = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
|
||||||
|
"SELECT path, kind, content FROM project_files WHERE project_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&project.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, "Project 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
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,
|
||||||
|
devices::{notify_devices, DeviceEvent},
|
||||||
|
models::{
|
||||||
|
CreateProjectFileRequest, CreateProjectRequest, Project, ProjectFile, UpdateProjectFileRequest,
|
||||||
|
UpdateProjectRequest,
|
||||||
|
},
|
||||||
|
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-project".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn project_role(
|
||||||
|
state: &AppState,
|
||||||
|
project_id: &str,
|
||||||
|
user_id_opt: &Option<String>,
|
||||||
|
) -> Option<(Project, String)> {
|
||||||
|
let project = sqlx::query_as::<_, Project>(
|
||||||
|
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.ok()??;
|
||||||
|
|
||||||
|
if let Some(uid) = user_id_opt {
|
||||||
|
if &project.owner_id == uid {
|
||||||
|
return Some((project, "owner".to_string()));
|
||||||
|
}
|
||||||
|
if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT role FROM project_collaborators WHERE project_id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Some((project, role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pr) = project.public_role.clone() {
|
||||||
|
if pr == "viewer" || pr == "editor" {
|
||||||
|
return Some((project, 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,
|
||||||
|
project: &Project,
|
||||||
|
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 projects; project 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(&project.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 project_files WHERE project_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&project.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: project.entrypoint.clone(),
|
||||||
|
files,
|
||||||
|
packages: load_local_packages(state).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct ListProjectsQuery {
|
||||||
|
pub folder_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_projects(
|
||||||
|
Query(query): Query<ListProjectsQuery>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Vec<Project>>, (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 projects = if let Some(folder_id) = query.folder_id {
|
||||||
|
sqlx::query_as::<_, Project>(
|
||||||
|
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects 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::<_, Project>(
|
||||||
|
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects 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(projects))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_shared_projects(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Vec<Project>>, (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 projects = sqlx::query_as::<_, Project>(
|
||||||
|
"SELECT p.id, p.owner_id, p.folder_id, p.name, p.entrypoint, p.thumbnail_svg, \
|
||||||
|
p.public_role, p.created_at, p.updated_at, c.role as effective_role \
|
||||||
|
FROM projects p \
|
||||||
|
INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \
|
||||||
|
ORDER BY p.updated_at DESC"
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(projects))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_project(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Json(payload): Json<CreateProjectRequest>,
|
||||||
|
) -> Result<Json<Project>, (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 project_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let project = sqlx::query_as::<_, Project>(
|
||||||
|
"INSERT INTO projects (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(&project_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 Project\n\nStart writing here.\n".to_string()),
|
||||||
|
];
|
||||||
|
for (path, content) in seeds {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO project_files (id, project_id, path, kind, content, mime_type) VALUES (?, ?, ?, 'text', ?, 'text/plain')"
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(&project_id)
|
||||||
|
.bind(path)
|
||||||
|
.bind(encode_text_blob(&content))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(project))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_project(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Project>, (StatusCode, String)> {
|
||||||
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
|
|
||||||
|
let (mut project, role) = project_role(&state, &id, &user_id_opt)
|
||||||
|
.await
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||||
|
|
||||||
|
project.effective_role = Some(role);
|
||||||
|
Ok(Json(project))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_project(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Json(payload): Json<UpdateProjectRequest>,
|
||||||
|
) -> Result<Json<Project>, (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 project = sqlx::query_as::<_, Project>(
|
||||||
|
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects 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, "Project not found".to_string()))?;
|
||||||
|
|
||||||
|
if let Some(name) = payload.name {
|
||||||
|
project.name = name;
|
||||||
|
}
|
||||||
|
if let Some(entrypoint) = payload.entrypoint {
|
||||||
|
project.entrypoint = entrypoint;
|
||||||
|
}
|
||||||
|
if let Some(folder_id) = payload.folder_id {
|
||||||
|
project.folder_id = if folder_id.is_empty() { None } else { Some(folder_id) };
|
||||||
|
}
|
||||||
|
if let Some(public_role) = payload.public_role {
|
||||||
|
project.public_role = if public_role == "none" || public_role.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(public_role)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let project = sqlx::query_as::<_, Project>(
|
||||||
|
"UPDATE projects 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(&project.name)
|
||||||
|
.bind(&project.entrypoint)
|
||||||
|
.bind(&project.folder_id)
|
||||||
|
.bind(&project.public_role)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
|
Ok(Json(project))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_project(
|
||||||
|
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 project_files WHERE project_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM projects 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, "Project not found or unauthorized".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_project_files(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Vec<ProjectFile>>, (StatusCode, String)> {
|
||||||
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
|
project_role(&state, &id, &user_id_opt)
|
||||||
|
.await
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
|
||||||
|
|
||||||
|
let files = sqlx::query_as::<_, ProjectFile>(
|
||||||
|
"SELECT id, project_id, path, kind, mime_type, created_at FROM project_files WHERE project_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_project_file(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Json(payload): Json<CreateProjectFileRequest>,
|
||||||
|
) -> Result<Json<ProjectFile>, (StatusCode, String)> {
|
||||||
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
|
let (project, role) = project_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::<_, ProjectFile>(
|
||||||
|
"INSERT INTO project_files (id, project_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, 'text/plain') RETURNING id, project_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()))?;
|
||||||
|
|
||||||
|
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
|
||||||
|
|
||||||
|
Ok(Json(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_project_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 (project, role) = project_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 project_files (id, project_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, ?) \
|
||||||
|
ON CONFLICT (project_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);
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "files": uploaded })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_project_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());
|
||||||
|
project_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 project_files WHERE id = ? AND project_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_project_file(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((id, file_id)): Path<(String, String)>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Json(payload): Json<UpdateProjectFileRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||||
|
let (project, role) = project_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 project_files SET path = ? WHERE id = ? AND project_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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_project_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 (project, role) = project_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 project_files WHERE id = ? AND project_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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
use typst::diag::{FileError, FileResult};
|
use typst::diag::{FileError, FileResult};
|
||||||
use typst::foundations::{Bytes, Datetime, Duration};
|
use typst::foundations::{Bytes, Datetime, Duration};
|
||||||
@@ -13,20 +14,64 @@ use typst_kit::packages::SystemPackages;
|
|||||||
pub struct MemoryWorld {
|
pub struct MemoryWorld {
|
||||||
library: typst::utils::LazyHash<Library>,
|
library: typst::utils::LazyHash<Library>,
|
||||||
main: FileId,
|
main: FileId,
|
||||||
source: Source,
|
|
||||||
files: HashMap<String, Vec<u8>>,
|
files: HashMap<String, Vec<u8>>,
|
||||||
|
local_packages: HashMap<String, HashMap<String, Vec<u8>>>,
|
||||||
book: typst::utils::LazyHash<FontBook>,
|
book: typst::utils::LazyHash<FontBook>,
|
||||||
fonts: Vec<Font>,
|
fonts: Vec<Font>,
|
||||||
packages: SystemPackages,
|
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 {
|
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(
|
let main = FileId::new(RootedPath::new(
|
||||||
VirtualRoot::Project,
|
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 downloader = SystemDownloader::new("TypstDrive (typst-kit)");
|
||||||
let packages = SystemPackages::new(downloader);
|
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 {
|
Self {
|
||||||
library: typst::utils::LazyHash::new(Library::builder().build()),
|
library: typst::utils::LazyHash::new(library),
|
||||||
main,
|
main,
|
||||||
source,
|
|
||||||
files,
|
files,
|
||||||
|
local_packages,
|
||||||
book: typst::utils::LazyHash::new(book),
|
book: typst::utils::LazyHash::new(book),
|
||||||
fonts,
|
fonts,
|
||||||
packages,
|
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 {
|
impl World for MemoryWorld {
|
||||||
@@ -79,42 +163,16 @@ impl World for MemoryWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||||
if id == self.main {
|
let data = self.load_bytes(id)?;
|
||||||
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)
|
let text = std::str::from_utf8(&data)
|
||||||
.map_err(|_| FileError::InvalidUtf8)?
|
.map_err(|_| FileError::InvalidUtf8)?
|
||||||
.to_owned();
|
.to_owned();
|
||||||
Ok(Source::new(id, text))
|
Ok(Source::new(id, text))
|
||||||
} else {
|
|
||||||
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||||
if id == self.main {
|
let data = self.load_bytes(id)?;
|
||||||
Ok(Bytes::from_string(self.source.text().to_string()))
|
Ok(Bytes::new(data))
|
||||||
} 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()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn font(&self, index: usize) -> Option<Font> {
|
fn font(&self, index: usize) -> Option<Font> {
|
||||||
|
|||||||
@@ -6,12 +6,101 @@
|
|||||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-muted: #eff1f5;
|
||||||
|
--color-surface-sunken: #dce0e8;
|
||||||
|
--color-line: #ccd0da;
|
||||||
|
--color-ink: #11111b;
|
||||||
|
--color-ink-muted: #5c5f77;
|
||||||
|
--color-accent: #1e66f5;
|
||||||
|
--color-accent-soft: #dce8fd;
|
||||||
|
--color-danger: #d5382f;
|
||||||
|
--color-success: #1f8a4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-surface: #181825;
|
||||||
|
--color-surface-muted: #1e1e2e;
|
||||||
|
--color-surface-sunken: #45475a;
|
||||||
|
--color-line: #313244;
|
||||||
|
--color-ink: #cdd6f4;
|
||||||
|
--color-ink-muted: #6c7086;
|
||||||
|
--color-accent: #89b4fa;
|
||||||
|
--color-accent-soft: #1e2f4d;
|
||||||
|
--color-danger: #f4736a;
|
||||||
|
--color-success: #4cc47f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme='Cerberus'] {
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-muted: #ffffff;
|
||||||
|
--color-surface-sunken: #f4f4f5;
|
||||||
|
--color-line: #d4d4d4;
|
||||||
|
--color-ink: #000000;
|
||||||
|
--color-ink-muted: #52525b;
|
||||||
|
--color-accent: #4338ca;
|
||||||
|
--color-accent-soft: #e0e7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme='Cerberus'][data-theme='dark'] {
|
||||||
|
--color-surface: #121212;
|
||||||
|
--color-surface-muted: #171717;
|
||||||
|
--color-surface-sunken: #1f1f1f;
|
||||||
|
--color-line: #262626;
|
||||||
|
--color-ink: #f5f5f5;
|
||||||
|
--color-ink-muted: #737373;
|
||||||
|
--color-accent: #818cf8;
|
||||||
|
--color-accent-soft: #262244;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme='Arch Linux'] {
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-muted: #ffffff;
|
||||||
|
--color-surface-sunken: #f6f8fa;
|
||||||
|
--color-line: #d0d7de;
|
||||||
|
--color-ink: #0d1117;
|
||||||
|
--color-ink-muted: #57606a;
|
||||||
|
--color-accent: #0550ae;
|
||||||
|
--color-accent-soft: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme='Arch Linux'][data-theme='dark'] {
|
||||||
|
--color-surface: #010409;
|
||||||
|
--color-surface-muted: #0d1117;
|
||||||
|
--color-surface-sunken: #161b22;
|
||||||
|
--color-line: #21262d;
|
||||||
|
--color-ink: #c9d1d9;
|
||||||
|
--color-ink-muted: #6e7681;
|
||||||
|
--color-accent: #1793d1;
|
||||||
|
--color-accent-soft: #0d2b3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--theme-bg: var(--color-surface-muted);
|
||||||
|
--theme-text: var(--color-ink);
|
||||||
|
--theme-border: var(--color-line);
|
||||||
|
--theme-cursor: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-surface-muted);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-line) transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
|||||||
@@ -96,26 +96,24 @@
|
|||||||
</script>
|
</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)]">
|
<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 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">
|
<div class="flex items-center gap-2">
|
||||||
<Icon icon="mdi:comment-text-multiple-outline" class="text-lg" />
|
<Icon icon="mdi:comment-text-multiple-outline" class="text-lg" />
|
||||||
<h2 class="text-sm font-semibold text-[var(--theme-text)]">Comments</h2>
|
<h2 class="text-sm font-semibold text-[var(--theme-text)]">Comments</h2>
|
||||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full">{comments.length}</span>
|
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full">{comments.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={onClose} class="p-1.5 hover:text-gray-600 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-white/10 rounded-md transition-colors" title="Close Comments">
|
<button onclick={onClose} class="p-1.5 hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors" title="Close Comments">
|
||||||
<Icon icon="mdi:close" class="text-lg" />
|
<Icon icon="mdi:close" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Feed -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex justify-center items-center h-full">
|
<div class="flex justify-center items-center h-full">
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-2xl" />
|
<Icon icon="mdi:loading" class="animate-spin text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="text-red-500 text-sm text-center p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-900/30">
|
<div class="text-[var(--color-danger)] text-sm text-center p-4 bg-[var(--color-danger)]/10 rounded-md border border-[var(--color-danger)]/20">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
{:else if comments.length === 0}
|
{:else if comments.length === 0}
|
||||||
@@ -128,7 +126,7 @@
|
|||||||
<div class="group flex flex-col gap-2 p-3 border rounded-xl shadow-sm hover:shadow-md transition-all {comment.resolved ? 'opacity-60' : ''} bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
<div class="group flex flex-col gap-2 p-3 border rounded-xl shadow-sm hover:shadow-md transition-all {comment.resolved ? 'opacity-60' : ''} bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center text-xs font-bold">
|
<div class="w-6 h-6 rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)] flex items-center justify-center text-xs font-bold">
|
||||||
{(comment.author_name || 'A').substring(0, 1).toUpperCase()}
|
{(comment.author_name || 'A').substring(0, 1).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -137,14 +135,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex opacity-0 group-hover:opacity-100 transition-opacity gap-1">
|
<div class="flex opacity-0 group-hover:opacity-100 transition-opacity gap-1">
|
||||||
{#if $userStore?.id === comment.user_id}
|
{#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">
|
<button onclick={() => deleteComment(comment.id)} class="p-1 hover:text-[var(--color-danger)] rounded hover:bg-[var(--color-danger)]/10 transition-colors" title="Delete">
|
||||||
<Icon icon="mdi:trash-can-outline" class="text-xs" />
|
<Icon icon="mdi:trash-can-outline" class="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button onclick={() => toggleResolve(comment)} class="p-1 hover:text-emerald-500 rounded hover:bg-emerald-50 dark:hover:bg-emerald-500/10 transition-colors" title={comment.resolved ? "Reopen" : "Resolve"}>
|
<button onclick={() => toggleResolve(comment)} class="p-1 hover:text-[var(--color-success)] rounded hover:bg-[var(--color-success)]/10 transition-colors" title={comment.resolved ? "Reopen" : "Resolve"}>
|
||||||
<Icon icon={comment.resolved ? "mdi:check-circle" : "mdi:check-circle-outline"} class="text-xs" />
|
<Icon icon={comment.resolved ? "mdi:check-circle" : "mdi:check-circle-outline"} class="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,13 +152,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input Area -->
|
|
||||||
<div class="p-4 border-t bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
<div class="p-4 border-t bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={newCommentContent}
|
bind:value={newCommentContent}
|
||||||
placeholder="Add a comment..."
|
placeholder="Add a comment..."
|
||||||
class="w-full border text-[var(--theme-text)] text-sm rounded-xl px-3 py-2.5 pr-10 focus:outline-none focus:ring-2 focus:ring-blue-500/50 resize-none min-h-[80px] bg-[var(--theme-bg)] border-[var(--theme-border)]"
|
class="w-full border text-[var(--theme-text)] text-sm rounded-md px-3 py-2.5 pr-10 focus:outline-none focus:border-[var(--color-accent)] resize-none min-h-[80px] bg-[var(--theme-bg)] border-[var(--theme-border)]"
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -172,7 +168,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={postComment}
|
onclick={postComment}
|
||||||
disabled={!newCommentContent.trim()}
|
disabled={!newCommentContent.trim()}
|
||||||
class="absolute bottom-2.5 right-2.5 p-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-zinc-700 disabled:text-gray-500 rounded-lg transition-colors"
|
class="absolute bottom-2.5 right-2.5 p-1.5 bg-[var(--color-accent)] hover:opacity-90 disabled:bg-[var(--color-surface-sunken)] disabled:text-[var(--color-ink-muted)] rounded-md transition-colors"
|
||||||
title="Post (Enter)"
|
title="Post (Enter)"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:send" class="text-sm" />
|
<Icon icon="mdi:send" class="text-sm" />
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onconfirm: () => void;
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = "Delete",
|
||||||
|
onconfirm,
|
||||||
|
onclose,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {title} icon="ph:warning-circle" {onclose}>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)]">{message}</p>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={onclose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-[var(--color-danger)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||||
|
onclick={onconfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
<div class="h-8 border-t border-[var(--theme-border)] bg-[var(--theme-bg)] flex items-center justify-between px-4 text-xs text-[var(--theme-text)] select-none z-[60] relative">
|
<div class="h-8 border-t border-[var(--theme-border)] bg-[var(--theme-bg)] flex items-center justify-between px-4 text-xs text-[var(--theme-text)] select-none z-[60] relative">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex items-center gap-1.5 font-medium {
|
<div class="flex items-center gap-1.5 font-medium {
|
||||||
$connectionStatus === 'connected' ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'
|
$connectionStatus === 'connected' ? 'text-[var(--color-success)]' : 'text-amber-600 dark:text-amber-400'
|
||||||
}">
|
}">
|
||||||
<div class="w-1.5 h-1.5 rounded-full {$connectionStatus === 'connected' ? 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.4)]' : 'bg-amber-500 animate-pulse'}"></div>
|
<div class="w-1.5 h-1.5 rounded-full {$connectionStatus === 'connected' ? 'bg-[var(--color-success)] shadow-[0_0_4px_rgba(16,185,129,0.4)]' : 'bg-amber-500 animate-pulse'}"></div>
|
||||||
{$connectionStatus === 'connected' ? 'Document synced' : 'Connecting...'}
|
{$connectionStatus === 'connected' ? 'Document synced' : 'Connecting...'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $documentStatsStore}
|
{#if $documentStatsStore}
|
||||||
<button class="hover:bg-gray-100 dark:hover:bg-white/10 px-2 py-0.5 rounded transition-colors flex items-center gap-1 cursor-pointer" onclick={toggleModal} aria-label="Word count statistics">
|
<button class="hover:bg-[var(--color-surface-sunken)] px-2 py-0.5 rounded transition-colors flex items-center gap-1 cursor-pointer" onclick={toggleModal} aria-label="Word count statistics">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-70"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-70"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
||||||
{$documentStatsStore.words} words
|
{$documentStatsStore.words} words
|
||||||
</button>
|
</button>
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
{#if showStatsModal}
|
{#if showStatsModal}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fixed inset-0 bg-black/20 dark:bg-black/40 z-[100] flex items-center justify-center backdrop-blur-sm" onclick={toggleModal}>
|
<div class="fixed inset-0 bg-black/40 z-[100] flex items-center justify-center" onclick={toggleModal}>
|
||||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] rounded-xl shadow-xl w-80 overflow-hidden" onclick={e => e.stopPropagation()}>
|
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] rounded-xl shadow-xl w-80 overflow-hidden" onclick={e => e.stopPropagation()}>
|
||||||
<div class="px-4 py-3 border-b border-[var(--theme-border)] flex items-center justify-between">
|
<div class="px-4 py-3 border-b border-[var(--theme-border)] flex items-center justify-between">
|
||||||
<h3 class="font-semibold text-sm">Word count</h3>
|
<h3 class="font-semibold text-sm">Word count</h3>
|
||||||
<button onclick={toggleModal} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
<button onclick={toggleModal} aria-label="Close" class="text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]">
|
||||||
<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"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
<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"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,14 +5,31 @@
|
|||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
import { autocompletion, snippetCompletion, type CompletionContext } from '@codemirror/autocomplete';
|
import { autocompletion, snippetCompletion, type CompletionContext } from '@codemirror/autocomplete';
|
||||||
import { typst, TypstParser, typstHighlight } from 'codemirror-lang-typst';
|
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 { yCollab } from 'y-codemirror.next';
|
||||||
import { text, provider } from '../ts/yjs-setup';
|
import { text, provider } from '../ts/yjs-setup';
|
||||||
import { getThemeExtension } from '../ts/themes';
|
import { getThemeExtension } from '../ts/themes';
|
||||||
import { themeStore, darkModeStore, editorViewStore, editorErrors, triggerLspReconnect } from '../ts/store';
|
import { themeStore, darkModeStore, editorViewStore, editorErrors, triggerLspReconnect } from '../ts/store';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
|
import { LSPClient } from "@codemirror/lsp-client";
|
||||||
|
import { typstLspExtensions } from '../ts/editor-lsp';
|
||||||
import { setDiagnostics, lintGutter } from '@codemirror/lint';
|
import { setDiagnostics, lintGutter } from '@codemirror/lint';
|
||||||
|
import { bracketExtensions, typstBracketSettings } from '../ts/editor-brackets';
|
||||||
|
|
||||||
|
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 editorContainer: HTMLElement;
|
||||||
let view: EditorView;
|
let view: EditorView;
|
||||||
@@ -136,7 +153,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!text || !provider) return;
|
const activeText = ytext ?? text;
|
||||||
|
const activeProvider = awarenessProvider ?? provider;
|
||||||
|
if (!activeText || !activeProvider) return;
|
||||||
|
|
||||||
themeStore.subscribe(t => { currentTheme = t; })();
|
themeStore.subscribe(t => { currentTheme = t; })();
|
||||||
darkModeStore.subscribe(d => { isDark = d; })();
|
darkModeStore.subscribe(d => { isDark = d; })();
|
||||||
@@ -150,16 +169,21 @@
|
|||||||
'typst'
|
'typst'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isToml = (filePath ?? '').toLowerCase().endsWith('.toml');
|
||||||
|
const languageExtension = isToml ? StreamLanguage.define(toml) : [myLang, typstBracketSettings(myLang)];
|
||||||
|
const completionExtensions = isToml ? [] : [autocompletion({ override: [typstCompletions] })];
|
||||||
|
|
||||||
state = EditorState.create({
|
state = EditorState.create({
|
||||||
doc: text.toString(),
|
doc: activeText.toString(),
|
||||||
extensions: [
|
extensions: [
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
lintGutter(),
|
lintGutter(),
|
||||||
history(),
|
history(),
|
||||||
|
...bracketExtensions,
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab] as any),
|
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab] as any),
|
||||||
myLang,
|
languageExtension,
|
||||||
yCollab(text, provider.awareness),
|
yCollab(activeText, activeProvider.awareness),
|
||||||
autocompletion({ override: [typstCompletions] }),
|
...completionExtensions,
|
||||||
themeCompartment.of(getThemeExtension(currentTheme as any, isDark)),
|
themeCompartment.of(getThemeExtension(currentTheme as any, isDark)),
|
||||||
lspCompartment.of([]),
|
lspCompartment.of([]),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
@@ -179,6 +203,7 @@
|
|||||||
|
|
||||||
editorViewStore.set(view);
|
editorViewStore.set(view);
|
||||||
|
|
||||||
|
let lastCompilerDiagnostics = '';
|
||||||
unsubscribeErrors = editorErrors.subscribe((errors) => {
|
unsubscribeErrors = editorErrors.subscribe((errors) => {
|
||||||
if (view) {
|
if (view) {
|
||||||
const docLen = view.state.doc.length;
|
const docLen = view.state.doc.length;
|
||||||
@@ -195,6 +220,9 @@
|
|||||||
message: e.message
|
message: e.message
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const snapshot = JSON.stringify(safeDiagnostics);
|
||||||
|
if (snapshot === lastCompilerDiagnostics) return;
|
||||||
|
lastCompilerDiagnostics = snapshot;
|
||||||
view.dispatch(setDiagnostics(view.state, safeDiagnostics));
|
view.dispatch(setDiagnostics(view.state, safeDiagnostics));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -219,10 +247,11 @@
|
|||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const host = window.location.host;
|
const host = window.location.host;
|
||||||
const docId = $page.params.id;
|
const docId = lspDocId ?? $page.params.id;
|
||||||
|
|
||||||
let lsHandlers: ((value: string) => void)[] = [];
|
let lsHandlers: ((value: string) => void)[] = [];
|
||||||
let lspInitialized = false;
|
let lspInitialized = false;
|
||||||
|
let lastServerDiagnostics = '';
|
||||||
|
|
||||||
const transport = {
|
const transport = {
|
||||||
send(message: string) { if (lsSocket?.readyState === WebSocket.OPEN) lsSocket.send(message); },
|
send(message: string) { if (lsSocket?.readyState === WebSocket.OPEN) lsSocket.send(message); },
|
||||||
@@ -238,6 +267,7 @@
|
|||||||
|
|
||||||
lspInitialized = false;
|
lspInitialized = false;
|
||||||
lsHandlers = [];
|
lsHandlers = [];
|
||||||
|
lastServerDiagnostics = '';
|
||||||
|
|
||||||
lsSocket = new WebSocket(`${protocol}//${host}/api/lsp/${docId}`);
|
lsSocket = new WebSocket(`${protocol}//${host}/api/lsp/${docId}`);
|
||||||
|
|
||||||
@@ -248,13 +278,10 @@
|
|||||||
const msg = JSON.parse(data);
|
const msg = JSON.parse(data);
|
||||||
if (msg.type === 'init') {
|
if (msg.type === 'init') {
|
||||||
lspInitialized = true;
|
lspInitialized = true;
|
||||||
|
|
||||||
// Recreate the client because the backend started a completely new LSP process
|
|
||||||
// which requires a fresh 'initialize' handshake.
|
|
||||||
client = new LSPClient({
|
client = new LSPClient({
|
||||||
rootUri: msg.rootUri,
|
rootUri: msg.rootUri,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
extensions: languageServerExtensions()
|
extensions: typstLspExtensions()
|
||||||
}).connect(transport);
|
}).connect(transport);
|
||||||
|
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
@@ -262,9 +289,7 @@
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
// Fallthrough
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let processedData = data;
|
let processedData = data;
|
||||||
@@ -274,6 +299,10 @@
|
|||||||
if (msg.method === 'textDocument/publishDiagnostics' && msg.params && msg.params.diagnostics) {
|
if (msg.method === 'textDocument/publishDiagnostics' && msg.params && msg.params.diagnostics) {
|
||||||
msg.params.diagnostics = msg.params.diagnostics.filter((d: any) => !d.message.toLowerCase().includes('unknown font family'));
|
msg.params.diagnostics = msg.params.diagnostics.filter((d: any) => !d.message.toLowerCase().includes('unknown font family'));
|
||||||
processedData = JSON.stringify(msg);
|
processedData = JSON.stringify(msg);
|
||||||
|
|
||||||
|
const snapshot = `${msg.params.uri}:${msg.params.version ?? ''}:${JSON.stringify(msg.params.diagnostics)}`;
|
||||||
|
if (snapshot === lastServerDiagnostics) return;
|
||||||
|
lastServerDiagnostics = snapshot;
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
@@ -281,11 +310,10 @@
|
|||||||
for (let h of lsHandlers) h(processedData);
|
for (let h of lsHandlers) h(processedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
lsSocket.onopen = () => {
|
lsSocket.onopen = () => {};
|
||||||
// Waiting for init message from server
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (enableLsp && docId) {
|
||||||
connectLsp();
|
connectLsp();
|
||||||
|
|
||||||
unsubscribeLspReconnect = triggerLspReconnect.subscribe((val) => {
|
unsubscribeLspReconnect = triggerLspReconnect.subscribe((val) => {
|
||||||
@@ -293,6 +321,7 @@
|
|||||||
connectLsp();
|
connectLsp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
let { sticky = true }: { sticky?: boolean } = $props();
|
let { sticky = true }: { sticky?: boolean } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer class="mt-auto py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-white/10 bg-[var(--theme-bg)] {sticky ? 'sticky bottom-0 z-10' : ''} w-full flex-shrink-0 transition-colors duration-200">
|
<footer class="mt-auto py-6 text-center text-sm text-[var(--color-ink-muted)] border-t border-[var(--color-line)] bg-[var(--color-surface)] {sticky ? 'sticky bottom-0 z-10' : ''} w-full flex-shrink-0 transition-colors duration-200">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col justify-center items-center gap-4">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col justify-center items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/SirBlobby/TypstDrive"
|
href="https://github.com/SirBlobby/TypstDrive"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-1.5 rounded-lg border border-transparent dark:border-white/10"
|
class="inline-flex items-center gap-2 hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-3 py-1.5 rounded-md"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:github" class="text-xl" />
|
<Icon icon="mdi:github" class="text-xl" />
|
||||||
<span class="font-semibold">GitHub Repository</span>
|
<span class="font-semibold">GitHub Repository</span>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
width?: string;
|
||||||
|
onclose: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
footer?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
icon = "ph:squares-four",
|
||||||
|
width = "max-w-md",
|
||||||
|
onclose,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handleKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onclose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKey} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-6"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onclose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full {width} overflow-hidden rounded-xl border border-[var(--color-line)] bg-[var(--color-surface)] shadow-2xl"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="flex items-center gap-2 border-b border-[var(--color-line)] px-5 py-3.5"
|
||||||
|
>
|
||||||
|
<Icon {icon} class="text-lg text-[var(--color-accent)]" />
|
||||||
|
<h2 class="flex-1 text-sm font-semibold">{title}</h2>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||||
|
onclick={onclose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:x" class="text-base" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="scroll-thin max-h-[70vh] overflow-y-auto px-5 py-4">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if footer}
|
||||||
|
<footer
|
||||||
|
class="flex items-center justify-end gap-2 border-t border-[var(--color-line)] bg-[var(--color-surface-muted)] px-5 py-3"
|
||||||
|
>
|
||||||
|
{@render footer()}
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
let props = $props<{
|
let props = $props<{
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -60,43 +60,34 @@
|
|||||||
props.onApply(newPageSettings, newDocSettings);
|
props.onApply(newPageSettings, newDocSettings);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldClass = "w-full rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={() => props.onClose()} onkeydown={(e) => { if (e.key === "Enter") { props.onClose(); } }}>
|
<Modal title="Document & page settings" icon="ph:file-text" width="max-w-2xl" onclose={props.onClose}>
|
||||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-[var(--theme-border)] w-full max-w-2xl overflow-hidden flex flex-col max-h-[85vh]" role="dialog" tabindex="-1" aria-modal="true" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.key === 'Escape' && props.onClose()}>
|
<div class="flex flex-col gap-8">
|
||||||
<div class="flex justify-between items-center p-5 border-b border-[var(--theme-border)]">
|
|
||||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
|
||||||
<Icon icon="mdi:file-document-edit-outline" class="text-blue-500 text-xl" />
|
|
||||||
Document & Page Settings
|
|
||||||
</h2>
|
|
||||||
<button onclick={() => props.onClose()} class="opacity-60 hover:opacity-100 rounded-full p-1 transition-opacity">
|
|
||||||
<Icon icon="mdi:close" class="text-xl" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-6 space-y-8 overflow-y-auto flex-1">
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="text-sm font-bold uppercase tracking-wider mb-4 border-b border-[var(--theme-border)] pb-2">Document Metadata</h3>
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-[var(--color-ink-muted)] mb-4 border-b border-[var(--color-line)] pb-2">Document metadata</h3>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="docTitle" class="text-sm font-medium">PDF Title</label>
|
<label for="docTitle" class="text-xs font-medium text-[var(--color-ink-muted)]">PDF title</label>
|
||||||
<input id="docTitle" type="text" bind:value={docTitle} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="My Report" />
|
<input id="docTitle" type="text" bind:value={docTitle} class={fieldClass} placeholder="My Report" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="author" class="text-sm font-medium">Author</label>
|
<label for="author" class="text-xs font-medium text-[var(--color-ink-muted)]">Author</label>
|
||||||
<input id="author" type="text" bind:value={author} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="Jane Doe" />
|
<input id="author" type="text" bind:value={author} class={fieldClass} placeholder="Jane Doe" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="text-sm font-bold uppercase tracking-wider mb-4 border-b border-[var(--theme-border)] pb-2">Page Layout</h3>
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-[var(--color-ink-muted)] mb-4 border-b border-[var(--color-line)] pb-2">Page layout</h3>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="paper" class="text-sm font-medium">Paper Size</label>
|
<label for="paper" class="text-xs font-medium text-[var(--color-ink-muted)]">Paper size</label>
|
||||||
<select id="paper" bind:value={paper} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2">
|
<select id="paper" bind:value={paper} class={fieldClass}>
|
||||||
<option value="a4">A4</option>
|
<option value="a4">A4</option>
|
||||||
<option value="us-letter">US Letter</option>
|
<option value="us-letter">US Letter</option>
|
||||||
<option value="a5">A5</option>
|
<option value="a5">A5</option>
|
||||||
@@ -104,41 +95,41 @@
|
|||||||
<option value="presentation-4-3">4:3 Presentation</option>
|
<option value="presentation-4-3">4:3 Presentation</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="margin" class="text-sm font-medium">Margin</label>
|
<label for="margin" class="text-xs font-medium text-[var(--color-ink-muted)]">Margin</label>
|
||||||
<input id="margin" type="text" bind:value={margin} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto or 1in" />
|
<input id="margin" type="text" bind:value={margin} class={fieldClass} placeholder="auto or 1in" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="width" class="text-sm font-medium">Width</label>
|
<label for="width" class="text-xs font-medium text-[var(--color-ink-muted)]">Width</label>
|
||||||
<input id="width" type="text" bind:value={width} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
<input id="width" type="text" bind:value={width} class={fieldClass} placeholder="auto" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="height" class="text-sm font-medium">Height</label>
|
<label for="height" class="text-xs font-medium text-[var(--color-ink-muted)]">Height</label>
|
||||||
<input id="height" type="text" bind:value={height} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
<input id="height" type="text" bind:value={height} class={fieldClass} placeholder="auto" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="columns" class="text-sm font-medium">Columns</label>
|
<label for="columns" class="text-xs font-medium text-[var(--color-ink-muted)]">Columns</label>
|
||||||
<input id="columns" type="number" min="1" max="10" bind:value={columns} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" />
|
<input id="columns" type="number" min="1" max="10" bind:value={columns} class={fieldClass} />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="fill" class="text-sm font-medium">Background Fill</label>
|
<label for="fill" class="text-xs font-medium text-[var(--color-ink-muted)]">Background fill</label>
|
||||||
<input id="fill" type="text" bind:value={fill} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto or rgb(200, 200, 200)" />
|
<input id="fill" type="text" bind:value={fill} class={fieldClass} placeholder="auto or rgb(200, 200, 200)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mt-4">
|
<div class="flex items-center gap-2 mt-4">
|
||||||
<input id="flipped" type="checkbox" bind:checked={flipped} class="rounded text-blue-600 focus:ring-blue-500 bg-[var(--theme-bg)] border-[var(--theme-border)]" />
|
<input id="flipped" type="checkbox" bind:checked={flipped} class="rounded border-[var(--color-line)] text-[var(--color-accent)] focus:ring-[var(--color-accent)]" />
|
||||||
<label for="flipped" class="text-sm font-medium">Landscape Orientation (Flipped)</label>
|
<label for="flipped" class="text-xs font-medium text-[var(--color-ink-muted)]">Landscape orientation (flipped)</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="text-sm font-bold uppercase tracking-wider mb-4 border-b border-[var(--theme-border)] pb-2">Headers & Footers</h3>
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-[var(--color-ink-muted)] mb-4 border-b border-[var(--color-line)] pb-2">Headers & footers</h3>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="numbering" class="text-sm font-medium">Page Numbering</label>
|
<label for="numbering" class="text-xs font-medium text-[var(--color-ink-muted)]">Page numbering</label>
|
||||||
<select id="numbering" bind:value={numbering} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2">
|
<select id="numbering" bind:value={numbering} class={fieldClass}>
|
||||||
<option value="none">None</option>
|
<option value="none">None</option>
|
||||||
<option value="1">1, 2, 3</option>
|
<option value="1">1, 2, 3</option>
|
||||||
<option value="1/1">1/3, 2/3, 3/3</option>
|
<option value="1/1">1/3, 2/3, 3/3</option>
|
||||||
@@ -147,25 +138,24 @@
|
|||||||
<option value="I">I, II, III</option>
|
<option value="I">I, II, III</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="header" class="text-sm font-medium">Header Content</label>
|
<label for="header" class="text-xs font-medium text-[var(--color-ink-muted)]">Header content</label>
|
||||||
<input id="header" type="text" bind:value={header} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
<input id="header" type="text" bind:value={header} class={fieldClass} placeholder="auto or [Text]" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2 sm:col-span-2">
|
<div class="flex flex-col gap-1 sm:col-span-2">
|
||||||
<label for="footer" class="text-sm font-medium">Footer Content</label>
|
<label for="footer" class="text-xs font-medium text-[var(--color-ink-muted)]">Footer content</label>
|
||||||
<input id="footer" type="text" bind:value={footer} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
<input id="footer" type="text" bind:value={footer} class={fieldClass} placeholder="auto or [Text]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-5 border-t border-[var(--theme-border)] flex justify-end gap-3" style="background-color: var(--theme-border);">
|
{#snippet footer()}
|
||||||
<button onclick={() => props.onClose()} class="px-4 py-2 text-sm font-medium bg-[var(--theme-bg)] opacity-80 hover:opacity-100 rounded-lg transition-opacity border border-[var(--theme-border)]">
|
<button onclick={() => props.onClose()} class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onclick={apply} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
<button onclick={apply} class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90">
|
||||||
Apply Settings
|
Apply settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
let isDrawing = false;
|
let isDrawing = false;
|
||||||
let currentPath = $state<{x: number, y: number}[]>([]);
|
let currentPath = $state<{x: number, y: number}[]>([]);
|
||||||
|
|
||||||
// New feature states
|
|
||||||
let tool = $state<'pen' | 'highlighter' | 'eraser' | 'laser'>('laser');
|
let tool = $state<'pen' | 'highlighter' | 'eraser' | 'laser'>('laser');
|
||||||
let selectedColor = $state('#ef4444');
|
let selectedColor = $state('#ef4444');
|
||||||
let showGrid = $state(false);
|
let showGrid = $state(false);
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
|
|
||||||
const colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#ffffff', '#000000'];
|
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 drawings = $state<Record<number, string>>({});
|
||||||
let undoStack = $state<Record<number, string[]>>({});
|
let undoStack = $state<Record<number, string[]>>({});
|
||||||
let redoStack = $state<Record<number, string[]>>({});
|
let redoStack = $state<Record<number, string[]>>({});
|
||||||
@@ -41,7 +39,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Find the preview svgs from the main DOM
|
|
||||||
const previewContainers = document.querySelectorAll('.preview-container svg');
|
const previewContainers = document.querySelectorAll('.preview-container svg');
|
||||||
const svgStrings: string[] = [];
|
const svgStrings: string[] = [];
|
||||||
previewContainers.forEach(container => {
|
previewContainers.forEach(container => {
|
||||||
@@ -49,7 +46,6 @@
|
|||||||
});
|
});
|
||||||
svgs = svgStrings;
|
svgs = svgStrings;
|
||||||
|
|
||||||
// Request fullscreen
|
|
||||||
const el = document.getElementById('presentation-container');
|
const el = document.getElementById('presentation-container');
|
||||||
if (el && el.requestFullscreen) {
|
if (el && el.requestFullscreen) {
|
||||||
el.requestFullscreen().catch(err => console.error(err));
|
el.requestFullscreen().catch(err => console.error(err));
|
||||||
@@ -104,7 +100,6 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (canvas && currentSlide !== undefined && !showGrid) {
|
if (canvas && currentSlide !== undefined && !showGrid) {
|
||||||
// Resize canvas to match the svg
|
|
||||||
const svgEl = document.getElementById('presentation-svg')?.querySelector('svg');
|
const svgEl = document.getElementById('presentation-svg')?.querySelector('svg');
|
||||||
if (svgEl) {
|
if (svgEl) {
|
||||||
const rect = svgEl.getBoundingClientRect();
|
const rect = svgEl.getBoundingClientRect();
|
||||||
@@ -126,7 +121,6 @@
|
|||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
// Load previous drawing if any
|
|
||||||
if (drawings[currentSlide]) {
|
if (drawings[currentSlide]) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
@@ -302,7 +296,6 @@
|
|||||||
|
|
||||||
<div id="presentation-container" class="fixed inset-0 z-[100] flex flex-col items-center justify-center">
|
<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}
|
{#if tool === 'laser' && laserPos.visible && !showGrid}
|
||||||
<div
|
<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"
|
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>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Thumbnail Grid UI -->
|
|
||||||
{#if showGrid}
|
{#if showGrid}
|
||||||
<div class="absolute inset-0 z-[50] p-8 overflow-y-auto">
|
<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">
|
<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>
|
</div>
|
||||||
{/if}
|
{/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="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)]">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slide Area -->
|
|
||||||
<div class="relative w-full h-full flex items-center justify-center p-8">
|
<div class="relative w-full h-full flex items-center justify-center p-8">
|
||||||
{#if svgs.length > 0 && !showGrid}
|
{#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)]">
|
<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]}
|
{@html svgs[currentSlide]}
|
||||||
|
|
||||||
<!-- Drawing Canvas Overlay -->
|
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={canvas}
|
bind:this={canvas}
|
||||||
class="absolute inset-0 z-10 touch-none pointer-events-none"
|
class="absolute inset-0 z-10 touch-none pointer-events-none"
|
||||||
></canvas>
|
></canvas>
|
||||||
|
|
||||||
<!-- Active Stroke Canvas Overlay -->
|
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={activeCanvas}
|
bind:this={activeCanvas}
|
||||||
class="absolute inset-0 z-20 touch-none {tool === 'laser' ? 'cursor-none' : 'cursor-crosshair'}"
|
class="absolute inset-0 z-20 touch-none {tool === 'laser' ? 'cursor-none' : 'cursor-crosshair'}"
|
||||||
@@ -413,7 +401,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation -->
|
|
||||||
{#if svgs.length > 0}
|
{#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)]">
|
<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}
|
{#if svgs.length > 1}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-gray-400 flex flex-col items-center justify-center h-full">
|
<div class="text-[var(--color-ink-muted)] flex flex-col items-center justify-center h-full">
|
||||||
<p>Document is empty or compiling...</p>
|
<p>Document is empty or compiling...</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
suffix?: string;
|
||||||
|
onsubmit: (value: string) => void;
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
icon = "ph:pencil-simple",
|
||||||
|
value = "",
|
||||||
|
placeholder = "",
|
||||||
|
confirmLabel = "Create",
|
||||||
|
danger = false,
|
||||||
|
suffix = "",
|
||||||
|
onsubmit,
|
||||||
|
onclose,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let text = $state(
|
||||||
|
untrack(() =>
|
||||||
|
suffix && value.endsWith(suffix) ? value.slice(0, -suffix.length) : value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!text.trim()) return;
|
||||||
|
onsubmit(text.trim());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {title} {icon} {onclose}>
|
||||||
|
<form id="prompt-form" onsubmit={submit}>
|
||||||
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="font-medium text-[var(--color-ink-muted)]">{label}</span>
|
||||||
|
<div
|
||||||
|
class="flex items-center rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] focus-within:border-[var(--color-accent)]"
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
class="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm focus:outline-none"
|
||||||
|
bind:value={text}
|
||||||
|
{placeholder}
|
||||||
|
/>
|
||||||
|
{#if suffix}
|
||||||
|
<span class="pr-3 text-sm text-[var(--color-ink-muted)]">{suffix}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={onclose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="prompt-form"
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90
|
||||||
|
{danger ? 'bg-[var(--color-danger)]' : 'bg-[var(--color-accent)]'}"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
projectId,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
projectId: 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({ project_id: projectId, 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>
|
||||||
|
|
||||||
|
<Modal title="Publish as package" icon="ph:package" onclose={onClose}>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)] mb-4">
|
||||||
|
Snapshots this project's files into an immutable package version, importable instance-wide as
|
||||||
|
<code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-1.5 py-0.5 rounded">@typstdrive/<name>:<version></code>.
|
||||||
|
The name, version and entrypoint come from your <code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-1.5 py-0.5 rounded">typst.toml</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="block text-xs font-medium text-[var(--color-ink-muted)] 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 rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm mb-2 focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="text-sm text-[var(--color-danger)] mt-2 break-words">{error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if success}
|
||||||
|
<div class="text-sm text-[var(--color-success)] mt-2">{success}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button onclick={onClose} class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]">Close</button>
|
||||||
|
<button onclick={publish} disabled={publishing} class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50 flex items-center gap-2">
|
||||||
|
{#if publishing}<Icon icon="mdi:loading" class="animate-spin" />{/if}
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
@@ -2,18 +2,45 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
let { onClose, docId = undefined } = $props<{ onClose: () => void, docId?: string }>();
|
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 link = $state('');
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let role = $state('editor');
|
let role = $state('editor');
|
||||||
|
|
||||||
|
let collaborators = $state<CollaboratorView[]>([]);
|
||||||
|
let collabLoading = $state(false);
|
||||||
|
let removingId = $state<string | null>(null);
|
||||||
|
|
||||||
let inviteEmail = $state('');
|
let inviteEmail = $state('');
|
||||||
let inviteRole = $state('editor');
|
let inviteRole = $state('editor');
|
||||||
let inviteStatus = $state<'idle' | 'loading' | 'success' | 'error'>('idle');
|
let inviteStatus = $state<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
let inviteMessage = $state('');
|
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) {
|
async function inviteUser(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!docId || !inviteEmail.trim()) return;
|
if (!docId || !inviteEmail.trim()) return;
|
||||||
@@ -24,9 +51,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/docs/${docId}/invite`, {
|
const res = await fetch(`/api/docs/${docId}/invite`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole })
|
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,25 +59,23 @@
|
|||||||
inviteStatus = 'success';
|
inviteStatus = 'success';
|
||||||
inviteMessage = 'User invited successfully!';
|
inviteMessage = 'User invited successfully!';
|
||||||
inviteEmail = '';
|
inviteEmail = '';
|
||||||
|
loadCollaborators();
|
||||||
} else {
|
} else {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
inviteStatus = 'error';
|
inviteStatus = 'error';
|
||||||
inviteMessage = text || 'Failed to invite user';
|
inviteMessage = text || 'Failed to invite user';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
|
||||||
inviteStatus = 'error';
|
inviteStatus = 'error';
|
||||||
inviteMessage = 'Network error occurred';
|
inviteMessage = 'Network error occurred';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
||||||
const baseUrl = window.location.origin;
|
const baseUrl = window.location.origin;
|
||||||
const docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href;
|
const docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href;
|
||||||
|
|
||||||
|
|
||||||
link = `${docUrl}?role=${role}`;
|
link = `${docUrl}?role=${role}`;
|
||||||
|
loadCollaborators();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -81,20 +104,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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}>
|
<Modal title="Share document" icon="ph:share-network" width="max-w-[500px]" onclose={onClose}>
|
||||||
<div tabindex="-1" class="rounded-xl shadow-2xl border w-full max-w-[500px] overflow-hidden bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]" style="background-color: var(--theme-bg); color: var(--theme-text); border-color: var(--theme-border);" role="dialog" aria-modal="true" aria-labelledby="share-dialog-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
<div class="flex flex-col gap-5">
|
||||||
<div class="flex justify-between items-center p-4 border-b border-[var(--theme-border)]" style="border-color: var(--theme-border);">
|
<div class="flex flex-col gap-2">
|
||||||
<h2 id="share-dialog-title" class="text-lg font-semibold text-[var(--theme-text)]" style="color: var(--theme-text);">Share Document</h2>
|
<div class="text-xs font-medium text-[var(--color-ink-muted)]">Invite collaborator</div>
|
||||||
<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">
|
<form onsubmit={inviteUser} class="flex items-center gap-2 rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] p-1.5 focus-within:border-[var(--color-accent)] transition">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
<div class="pl-2 text-[var(--color-ink-muted)]">
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-6 space-y-6">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="text-sm font-semibold text-[var(--theme-text)]" style="color: var(--theme-text);">Invite Collaborator</div>
|
|
||||||
<form onsubmit={inviteUser} class="flex items-center gap-2 bg-gray-50 dark:bg-zinc-900/50 p-1.5 rounded-lg border border-gray-300 dark:border-zinc-700 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-all">
|
|
||||||
<div class="pl-2 text-gray-400">
|
|
||||||
<Icon icon="mdi:account-plus-outline" class="text-xl" />
|
<Icon icon="mdi:account-plus-outline" class="text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -102,66 +117,103 @@
|
|||||||
placeholder="Add people via email..."
|
placeholder="Add people via email..."
|
||||||
bind:value={inviteEmail}
|
bind:value={inviteEmail}
|
||||||
required
|
required
|
||||||
class="flex-1 bg-transparent border-none text-gray-800 dark:text-gray-200 text-sm px-2 py-2 focus:ring-0 focus:outline-none w-full"
|
class="flex-1 bg-transparent border-none text-sm px-2 py-2 focus:ring-0 focus:outline-none w-full"
|
||||||
/>
|
/>
|
||||||
<div class="h-6 w-px bg-gray-300 dark:bg-zinc-700"></div>
|
<div class="h-6 w-px bg-[var(--color-line)]"></div>
|
||||||
<select bind:value={inviteRole} class="bg-transparent border-none text-sm text-gray-700 dark:text-gray-300 px-2 py-2 focus:ring-0 focus:outline-none cursor-pointer font-medium">
|
<select bind:value={inviteRole} class="bg-transparent border-none text-sm text-[var(--color-ink-muted)] px-2 py-2 focus:ring-0 focus:outline-none cursor-pointer font-medium">
|
||||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="editor">Editor</option>
|
<option value="editor">Editor</option>
|
||||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={inviteStatus === 'loading'}
|
disabled={inviteStatus === 'loading'}
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm disabled:opacity-70 min-w-[80px]"
|
class="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-70 min-w-[80px]"
|
||||||
>
|
>
|
||||||
{inviteStatus === 'loading' ? 'Inviting...' : 'Invite'}
|
{inviteStatus === 'loading' ? 'Inviting...' : 'Invite'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{#if inviteMessage}
|
{#if inviteMessage}
|
||||||
<div class="flex items-center gap-1.5 text-xs font-medium {inviteStatus === 'success' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}">
|
<div class="flex items-center gap-1.5 text-xs font-medium {inviteStatus === 'success' ? 'text-[var(--color-success)]' : 'text-[var(--color-danger)]'}">
|
||||||
<Icon icon={inviteStatus === 'success' ? 'mdi:check-circle' : 'mdi:alert-circle'} class="text-sm" />
|
<Icon icon={inviteStatus === 'success' ? 'mdi:check-circle' : 'mdi:alert-circle'} class="text-sm" />
|
||||||
{inviteMessage}
|
{inviteMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-px bg-gray-200 dark:bg-zinc-800/50"></div>
|
{#if collabLoading}
|
||||||
|
<div class="flex items-center gap-2 text-sm text-[var(--color-ink-muted)] py-1">
|
||||||
|
<Icon icon="mdi:loading" class="animate-spin text-base" />
|
||||||
|
Loading collaborators...
|
||||||
|
</div>
|
||||||
|
{:else if collaborators.length > 0}
|
||||||
|
<div class="h-px bg-[var(--color-line)]"></div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-xs font-medium text-[var(--color-ink-muted)]">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-[var(--color-accent-soft)] flex items-center justify-center text-[var(--color-accent)] 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-[var(--color-ink)] truncate">{collab.username}</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)] 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-[var(--color-accent-soft)] text-[var(--color-accent)]' : 'bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]'}">
|
||||||
|
{collab.role}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => removeCollaborator(collab)}
|
||||||
|
disabled={removingId === collab.id}
|
||||||
|
title="Remove collaborator"
|
||||||
|
class="flex-shrink-0 rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-danger)]/10 hover:text-[var(--color-danger)] 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="space-y-3">
|
<div class="h-px bg-[var(--color-line)]"></div>
|
||||||
<div class="text-sm font-semibold text-[var(--theme-text)]" style="color: var(--theme-text);">General Access</div>
|
|
||||||
<div class="flex items-center gap-4 p-3 bg-gray-50/50 dark:bg-zinc-950/30 rounded-xl border border-gray-200 dark:border-zinc-800/50 hover:bg-gray-50 dark:hover:bg-zinc-900/50 transition-colors">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-2.5 rounded-full text-gray-600 dark:text-gray-300">
|
<div class="text-xs font-medium text-[var(--color-ink-muted)]">General access</div>
|
||||||
|
<div class="flex items-center gap-4 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3 transition hover:bg-[var(--color-surface-sunken)]">
|
||||||
|
<div class="rounded-full bg-[var(--color-surface-sunken)] p-2.5 text-[var(--color-ink-muted)]">
|
||||||
<Icon icon="mdi:earth" class="text-xl" />
|
<Icon icon="mdi:earth" class="text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Anyone with the link</h4>
|
<h4 class="text-sm font-medium text-[var(--color-ink)]">Anyone with the link</h4>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Can view and collaborate based on role</p>
|
<p class="text-xs text-[var(--color-ink-muted)] mt-0.5">Can view and collaborate based on role</p>
|
||||||
</div>
|
</div>
|
||||||
<select bind:value={role} class="bg-gray-100 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 text-sm font-medium text-gray-700 dark:text-gray-300 rounded-md px-3 py-1.5 focus:outline-none cursor-pointer focus:ring-2 focus:ring-blue-500/20 hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors">
|
<select bind:value={role} class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-1.5 text-sm font-medium text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] focus:outline-none cursor-pointer">
|
||||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="editor">Editor</option>
|
<option value="editor">Editor</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 bg-gray-50 dark:bg-zinc-950/50 border-t border-[var(--theme-border)] flex items-center justify-between" style="border-color: var(--theme-border);">
|
{#snippet footer()}
|
||||||
<button
|
<button
|
||||||
onclick={copyLink}
|
onclick={copyLink}
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-500/10 transition-colors"
|
class="mr-auto flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] transition hover:bg-[var(--color-accent-soft)]"
|
||||||
>
|
>
|
||||||
{#if copied}
|
{#if copied}
|
||||||
<Icon icon="mdi:check" class="text-lg" />
|
<Icon icon="mdi:check" class="text-base" />
|
||||||
<span>Link copied!</span>
|
<span>Link copied!</span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="mdi:link-variant" class="text-lg" />
|
<Icon icon="mdi:link-variant" class="text-base" />
|
||||||
<span>Copy link</span>
|
<span>Copy link</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onclick={onClose} class="px-6 py-2 text-sm font-semibold text-white bg-gray-800 hover:bg-gray-900 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white rounded-lg shadow-sm transition-colors">
|
<button onclick={onClose} class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90">
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -5,32 +5,47 @@
|
|||||||
|
|
||||||
let { class: className = '' } = $props();
|
let { class: className = '' } = $props();
|
||||||
|
|
||||||
const themeOptions = Object.keys(themes).flatMap(themeName => [
|
const themeNames = Object.keys(themes);
|
||||||
{ name: `${themeName} Light`, theme: themeName, isDark: false },
|
|
||||||
{ name: `${themeName} Dark`, theme: themeName, isDark: true }
|
|
||||||
]);
|
|
||||||
|
|
||||||
function handleChange(e: Event) {
|
|
||||||
const val = (e.target as HTMLSelectElement).value;
|
|
||||||
const opt = themeOptions.find(o => o.name === val);
|
|
||||||
if (opt) {
|
|
||||||
$themeStore = opt.theme;
|
|
||||||
$darkModeStore = opt.isDark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedValue = $derived(`${$themeStore} ${$darkModeStore ? 'Dark' : 'Light'}`);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 {className}">
|
<div class="flex items-center gap-2 {className}">
|
||||||
<Icon icon={themes[$themeStore]?.icon || 'mdi:palette'} class="text-xl text-[var(--theme-text)] opacity-70" />
|
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
|
||||||
<select
|
{#each themeNames as name}
|
||||||
value={selectedValue}
|
<button
|
||||||
onchange={handleChange}
|
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 transition
|
||||||
class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-sm font-medium rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 block py-1.5 pl-3 pr-8 appearance-none cursor-pointer transition-colors outline-none"
|
{$themeStore === name
|
||||||
|
? 'bg-[var(--color-surface)] text-[var(--color-accent)] shadow-sm'
|
||||||
|
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
||||||
|
onclick={() => ($themeStore = name)}
|
||||||
>
|
>
|
||||||
{#each themeOptions as opt}
|
<Icon icon={themes[name].icon} class="text-sm" />
|
||||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value={opt.name}>{opt.name}</option>
|
{name}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
|
||||||
|
<button
|
||||||
|
class="flex items-center rounded-md px-2.5 py-1.5 transition
|
||||||
|
{!$darkModeStore
|
||||||
|
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
|
||||||
|
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
||||||
|
onclick={() => ($darkModeStore = false)}
|
||||||
|
aria-label="Light mode"
|
||||||
|
title="Light mode"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:sun" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center rounded-md px-2.5 py-1.5 transition
|
||||||
|
{$darkModeStore
|
||||||
|
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
|
||||||
|
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
||||||
|
onclick={() => ($darkModeStore = true)}
|
||||||
|
aria-label="Dark mode"
|
||||||
|
title="Dark mode"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:moon" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { exportTypst } from '../ts/typst-api';
|
import { exportTypst } from '../ts/typst-api';
|
||||||
import { text } from '../ts/yjs-setup';
|
import { text } from '../ts/yjs-setup';
|
||||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect, documentStatsStore, previewOpenStore } from '../ts/store';
|
import { connectionStatus, connectedUsers, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect, documentStatsStore, previewOpenStore } from '../ts/store';
|
||||||
import { themes } from '../ts/themes';
|
import { themes } from '../ts/themes';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import ShareModal from './ShareModal.svelte';
|
import ShareModal from './ShareModal.svelte';
|
||||||
import PageSettingsModal from './PageSettingsModal.svelte';
|
import PageSettingsModal from './PageSettingsModal.svelte';
|
||||||
import ThemePicker from './ThemePicker.svelte';
|
|
||||||
import PresentationMode from "./PresentationMode.svelte";
|
import PresentationMode from "./PresentationMode.svelte";
|
||||||
import CommentsSidebar from "./CommentsSidebar.svelte";
|
import CommentsSidebar from "./CommentsSidebar.svelte";
|
||||||
import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import PromptModal from './PromptModal.svelte';
|
||||||
|
import ConfirmModal from './ConfirmModal.svelte';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { undo, redo } from '@codemirror/commands';
|
import { undo, redo } from '@codemirror/commands';
|
||||||
|
|
||||||
@@ -348,13 +350,12 @@
|
|||||||
showRenameModal = true;
|
showRenameModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitRename(e: Event) {
|
function submitRename(newTitle: string) {
|
||||||
e.preventDefault();
|
if (newTitle && newTitle !== title) {
|
||||||
if (renameTitle && renameTitle !== title) {
|
|
||||||
fetch(`/api/docs/${docId}`, {
|
fetch(`/api/docs/${docId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: renameTitle })
|
body: JSON.stringify({ title: newTitle })
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -401,7 +402,7 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onclick={() => goto('/dashboard')}
|
onclick={() => goto('/dashboard')}
|
||||||
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"
|
class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] rounded-md hover:bg-[var(--color-surface-sunken)] transition-colors"
|
||||||
aria-label="Back to dashboard"
|
aria-label="Back to dashboard"
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
>
|
>
|
||||||
@@ -410,15 +411,14 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="text-[16px] font-semibold text-gray-900 dark:text-white tracking-tight truncate max-w-[200px] md:max-w-xs" title={title}>
|
<h1 class="text-[16px] font-semibold text-[var(--color-ink)] tracking-tight truncate max-w-[200px] md:max-w-xs" title={title}>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Sync status removed from here and moved to Footer -->
|
|
||||||
</div>
|
</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="flex items-center gap-0.5 text-[13px] font-medium text-[var(--color-ink-muted)] -ml-1 action-menu-container">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }}
|
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }}
|
||||||
@@ -454,7 +454,7 @@
|
|||||||
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">HTML (.html)</button>
|
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">HTML (.html)</button>
|
||||||
{#if !isViewer}
|
{#if !isViewer}
|
||||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||||
<button onclick={() => { activeMenu = null; deleteDoc(); }} class="w-full text-left px-4 py-1.5 text-sm text-red-500 hover:bg-red-500/10">Delete</button>
|
<button onclick={() => { activeMenu = null; deleteDoc(); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10">Delete</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -493,11 +493,6 @@
|
|||||||
<button onclick={() => { activeMenu = null; $versionHistoryOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center justify-between">
|
<button onclick={() => { activeMenu = null; $versionHistoryOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center justify-between">
|
||||||
Version History
|
Version History
|
||||||
</button>
|
</button>
|
||||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
|
||||||
<button onclick={() => { activeMenu = null; $darkModeStore = !$darkModeStore; document.documentElement.classList.toggle('dark', $darkModeStore); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center justify-between">
|
|
||||||
Dark Mode
|
|
||||||
<Icon icon={$darkModeStore ? "mdi:check" : ""} class="text-sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -511,7 +506,7 @@
|
|||||||
<div class="flex items-center -space-x-2 mr-2">
|
<div class="flex items-center -space-x-2 mr-2">
|
||||||
{#each $connectedUsers as user}
|
{#each $connectedUsers as user}
|
||||||
<div
|
<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"
|
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-[var(--color-surface)] shadow-sm"
|
||||||
style="background-color: {user.color}; z-index: {user.isLocal ? 10 : 1};"
|
style="background-color: {user.color}; z-index: {user.isLocal ? 10 : 1};"
|
||||||
title={user.name + (user.isLocal ? ' (You)' : '')}
|
title={user.name + (user.isLocal ? ' (You)' : '')}
|
||||||
>
|
>
|
||||||
@@ -523,21 +518,21 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5 px-2">
|
<div class="flex items-center gap-1.5 px-2">
|
||||||
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="p-1.5 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Typst Docs">
|
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] rounded-md hover:bg-[var(--color-surface-sunken)] transition-colors" title="Typst Docs">
|
||||||
<Icon icon="mdi:book-open-page-variant-outline" class="text-[18px]" />
|
<Icon icon="mdi:book-open-page-variant-outline" class="text-[18px]" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://typst.app/universe/" target="_blank" rel="noopener noreferrer" class="p-1.5 text-gray-500 hover:text-purple-500 dark:text-gray-400 dark:hover:text-purple-400 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Typst Universe">
|
<a href="https://typst.app/universe/" target="_blank" rel="noopener noreferrer" class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] rounded-md hover:bg-[var(--color-surface-sunken)] transition-colors" title="Typst Universe">
|
||||||
<Icon icon="mdi:earth" class="text-[18px]" />
|
<Icon icon="mdi:earth" class="text-[18px]" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-5 bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
|
|
||||||
{#if !isViewer}
|
{#if !isViewer}
|
||||||
<button
|
<button
|
||||||
onclick={() => (isPresentationOpen = true)}
|
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"
|
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:presentation-play" class="text-[16px]" />
|
<Icon icon="mdi:presentation-play" class="text-[16px]" />
|
||||||
Present
|
Present
|
||||||
@@ -545,7 +540,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => ($commentsSidebarOpen = !$commentsSidebarOpen)}
|
onclick={() => ($commentsSidebarOpen = !$commentsSidebarOpen)}
|
||||||
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"
|
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:comment-outline" class="text-[16px]" />
|
<Icon icon="mdi:comment-outline" class="text-[16px]" />
|
||||||
Comments
|
Comments
|
||||||
@@ -553,27 +548,27 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => (isShareModalOpen = true)}
|
onclick={() => (isShareModalOpen = 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"
|
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
<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"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" x2="12" y1="2" y2="15"/></svg>
|
<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"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" x2="12" y1="2" y2="15"/></svg>
|
||||||
Share
|
Share
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-5 bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => ($previewOpenStore = !$previewOpenStore)}
|
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 dark:hover:bg-blue-900/40'}"
|
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium transition-colors rounded-md {$previewOpenStore ? 'text-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)]' : 'text-[var(--color-accent)] bg-[var(--color-accent-soft)] hover:opacity-90'}"
|
||||||
title={$previewOpenStore ? 'Hide preview' : 'Show preview'}
|
title={$previewOpenStore ? 'Hide preview' : 'Show preview'}
|
||||||
>
|
>
|
||||||
<Icon icon={$previewOpenStore ? 'mdi:eye-off-outline' : 'mdi:eye-outline'} class="text-[16px]" />
|
<Icon icon={$previewOpenStore ? 'mdi:eye-off-outline' : 'mdi:eye-outline'} class="text-[16px]" />
|
||||||
Preview
|
Preview
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-5 bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
<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 Document">
|
<button onclick={handlePrint} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors" title="Print Document">
|
||||||
<Icon icon="mdi:printer" class="text-[16px]" />
|
<Icon icon="mdi:printer" class="text-[16px]" />
|
||||||
Print
|
Print
|
||||||
</button>
|
</button>
|
||||||
@@ -581,7 +576,7 @@
|
|||||||
<div class="relative action-menu-container">
|
<div class="relative action-menu-container">
|
||||||
<button
|
<button
|
||||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'export' ? null : 'export'; }}
|
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 dark:hover:bg-blue-900/40 rounded-md transition-colors {activeMenu === 'export' ? 'ring-2 ring-blue-500/30' : ''}"
|
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-[var(--color-accent)] bg-[var(--color-accent-soft)] hover:opacity-90 rounded-md transition-colors {activeMenu === 'export' ? 'ring-2 ring-[var(--color-accent)]/30' : ''}"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:export-variant" class="text-[16px]" />
|
<Icon icon="mdi:export-variant" class="text-[16px]" />
|
||||||
Export
|
Export
|
||||||
@@ -608,50 +603,50 @@
|
|||||||
</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 px-4 py-1.5 bg-[var(--color-surface-muted)] border-t border-[var(--color-line)] gap-4 overflow-x-auto no-scrollbar">
|
||||||
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<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">
|
<button onclick={() => applyFormat('*', '*', 'bold')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Bold">
|
||||||
<Icon icon="mdi:format-bold" class="text-lg" />
|
<Icon icon="mdi:format-bold" class="text-lg" />
|
||||||
</button>
|
</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">
|
<button onclick={() => applyFormat('_', '_', 'italic')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Italic">
|
||||||
<Icon icon="mdi:format-italic" class="text-lg" />
|
<Icon icon="mdi:format-italic" class="text-lg" />
|
||||||
</button>
|
</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">
|
<button onclick={() => applyFormat('`', '`', 'code')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Code">
|
||||||
<Icon icon="mdi:code-tags" class="text-lg" />
|
<Icon icon="mdi:code-tags" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-4 mx-1 bg-[var(--color-line)]"></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)">
|
<button onclick={() => applyFormat('$ ', ' $', 'x = y')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Math (Inline)">
|
||||||
<Icon icon="mdi:sigma" class="text-lg" />
|
<Icon icon="mdi:sigma" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => applyFormat('$ \n ', '\n$ ', '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 (Block)">
|
<button onclick={() => applyFormat('$ \n ', '\n$ ', 'x = y')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Math (Block)">
|
||||||
<Icon icon="mdi:math-integral" class="text-lg" />
|
<Icon icon="mdi:math-integral" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-4 mx-1 bg-[var(--color-line)]"></div>
|
||||||
<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">
|
<button onclick={() => applyFormat('- ', '', 'List item')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Bullet List">
|
||||||
<Icon icon="mdi:format-list-bulleted" class="text-lg" />
|
<Icon icon="mdi:format-list-bulleted" class="text-lg" />
|
||||||
</button>
|
</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">
|
<button onclick={() => applyFormat('+ ', '', 'Numbered item')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Numbered List">
|
||||||
<Icon icon="mdi:format-list-numbered" class="text-lg" />
|
<Icon icon="mdi:format-list-numbered" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-4 mx-1 bg-[var(--color-line)]"></div>
|
||||||
<input type="file" bind:this={fileInput} onchange={handleImageUpload} class="hidden" accept="image/*,.ttf,.otf" />
|
<input type="file" bind:this={fileInput} onchange={handleImageUpload} class="hidden" accept="image/*,.ttf,.otf" />
|
||||||
{#if !isViewer}
|
{#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">
|
<button onclick={() => fileInput?.click()} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Upload Image / Font">
|
||||||
<Icon icon="mdi:image-plus" class="text-lg" />
|
<Icon icon="mdi:image-plus" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-4 bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label for="font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
|
<label for="font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
|
||||||
<select
|
<select
|
||||||
id="font-select"
|
id="font-select"
|
||||||
onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)}
|
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 focus:ring-blue-500 focus:border-blue-500 block py-1 pl-2 pr-6 appearance-none cursor-pointer transition-colors"
|
class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-xs rounded shadow-sm focus:border-[var(--color-accent)] focus:outline-none block py-1 pl-2 pr-6 appearance-none cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value="New Computer Modern">Default (New CM)</option>
|
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value="New Computer Modern">Default (New CM)</option>
|
||||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value="Libertinus Serif">Libertinus Serif</option>
|
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value="Libertinus Serif">Libertinus Serif</option>
|
||||||
@@ -667,7 +662,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-4 bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if !isViewer}
|
{#if !isViewer}
|
||||||
@@ -681,34 +676,28 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
<div class="w-px h-4 bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
<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">
|
<div class="flex items-center gap-1 bg-[var(--color-surface)] border border-[var(--color-line)] rounded shadow-sm overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onclick={() => $documentZoomStore = Math.max(10, $documentZoomStore - 10)}
|
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"
|
class="px-2 py-1 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] transition-colors"
|
||||||
title="Zoom Out"
|
title="Zoom Out"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:minus" class="text-sm" />
|
<Icon icon="mdi:minus" class="text-sm" />
|
||||||
</button>
|
</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}>
|
<span role="button" tabindex="0" onkeydown={(e) => { if (e.key === "Enter") $documentZoomStore = 100; }} class="text-[11px] font-semibold text-[var(--color-ink-muted)] min-w-[3rem] text-center select-none" ondblclick={() => $documentZoomStore = 100}>
|
||||||
{$documentZoomStore}%
|
{$documentZoomStore}%
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onclick={() => $documentZoomStore = Math.min(500, $documentZoomStore + 10)}
|
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"
|
class="px-2 py-1 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] transition-colors"
|
||||||
title="Zoom In"
|
title="Zoom In"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:plus" class="text-sm" />
|
<Icon icon="mdi:plus" class="text-sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ThemePicker />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow"></div>
|
<div class="flex-grow"></div>
|
||||||
|
|
||||||
|
|
||||||
@@ -716,7 +705,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if isShareModalOpen}
|
{#if isShareModalOpen}
|
||||||
<ShareModal onClose={() => (isShareModalOpen = false)} />
|
<ShareModal {docId} onClose={() => (isShareModalOpen = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isPageSettingsOpen}
|
{#if isPageSettingsOpen}
|
||||||
@@ -736,105 +725,52 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showInfoModal}
|
{#if showInfoModal}
|
||||||
|
<Modal title={docInfo?.title || title} icon="ph:file-text" onclose={() => showInfoModal = false}>
|
||||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={() => showInfoModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showInfoModal = false; } }}>
|
<div class="flex flex-col gap-4 text-xs">
|
||||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
|
||||||
<div class="p-6 border-b border-gray-100 dark:border-white/10">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="p-2 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg">
|
|
||||||
<Icon icon="mdi:file-document" class="text-xl" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex-grow truncate">{docInfo?.title || title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p>
|
<p class="mb-1 font-medium text-[var(--color-ink-muted)]">Type</p>
|
||||||
<p class="text-sm text-gray-900 dark:text-gray-100 capitalize">Document</p>
|
<p class="text-sm text-[var(--color-ink)] capitalize">Document</p>
|
||||||
</div>
|
</div>
|
||||||
{#if docInfo?.created_at}
|
{#if docInfo?.created_at}
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Created At</p>
|
<p class="mb-1 font-medium text-[var(--color-ink-muted)]">Created</p>
|
||||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(docInfo.created_at).toLocaleString()}</p>
|
<p class="text-sm text-[var(--color-ink)]">{new Date(docInfo.created_at).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if docInfo?.updated_at}
|
{#if docInfo?.updated_at}
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p>
|
<p class="mb-1 font-medium text-[var(--color-ink-muted)]">Last modified</p>
|
||||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(docInfo.updated_at).toLocaleString()}</p>
|
<p class="text-sm text-[var(--color-ink)]">{new Date(docInfo.updated_at).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</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 type="button" onclick={() => showInfoModal = false} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
{#snippet footer()}
|
||||||
|
<button type="button" onclick={() => showInfoModal = false} class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showRenameModal}
|
{#if showRenameModal}
|
||||||
|
<PromptModal
|
||||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={() => showRenameModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showRenameModal = false; } }}>
|
title="Rename"
|
||||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
label="New name"
|
||||||
<form onsubmit={submitRename} class="p-6">
|
icon="ph:pencil-simple"
|
||||||
<div class="flex items-center gap-3 mb-6">
|
value={renameTitle}
|
||||||
<div class="p-2 bg-yellow-50 dark:bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-lg">
|
confirmLabel="Save"
|
||||||
<Icon icon="mdi:pencil-outline" class="text-xl" />
|
onsubmit={submitRename}
|
||||||
</div>
|
onclose={() => showRenameModal = false}
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Rename</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
bind:value={renameTitle}
|
|
||||||
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"
|
|
||||||
placeholder="Enter new name"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-6 flex justify-end gap-3">
|
|
||||||
<button type="button" onclick={() => showRenameModal = false} 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">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showDeleteModal}
|
{#if showDeleteModal}
|
||||||
|
<ConfirmModal
|
||||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={() => showDeleteModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showDeleteModal = false; } }}>
|
title="Delete document"
|
||||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
message="Are you sure you want to delete this document? This action cannot be undone."
|
||||||
<div class="p-6">
|
confirmLabel="Delete"
|
||||||
<div class="flex items-center gap-3 mb-6">
|
onconfirm={confirmDelete}
|
||||||
<div class="p-2 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded-lg">
|
onclose={() => showDeleteModal = false}
|
||||||
<Icon icon="mdi:trash-can-outline" class="text-xl" />
|
/>
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Delete Document</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-6">
|
|
||||||
Are you sure you want to delete this document? This action 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 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="button" onclick={confirmDelete} class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -55,26 +55,24 @@
|
|||||||
</script>
|
</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)]">
|
<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 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">
|
<div class="flex items-center gap-2">
|
||||||
<Icon icon="mdi:history" class="text-lg" />
|
<Icon icon="mdi:history" class="text-lg" />
|
||||||
<h2 class="text-sm font-semibold text-[var(--theme-text)]">Version History</h2>
|
<h2 class="text-sm font-semibold text-[var(--theme-text)]">Version History</h2>
|
||||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full">{versions.length}</span>
|
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full">{versions.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={onClose} class="p-1.5 hover:text-gray-600 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-white/10 rounded-md transition-colors" title="Close Version History">
|
<button onclick={onClose} class="p-1.5 hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors" title="Close Version History">
|
||||||
<Icon icon="mdi:close" class="text-lg" />
|
<Icon icon="mdi:close" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Feed -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex justify-center items-center h-full">
|
<div class="flex justify-center items-center h-full">
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-2xl" />
|
<Icon icon="mdi:loading" class="animate-spin text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="text-red-500 text-sm text-center p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-900/30">
|
<div class="text-[var(--color-danger)] text-sm text-center p-4 bg-[var(--color-danger)]/10 rounded-md border border-[var(--color-danger)]/20">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
{:else if versions.length === 0}
|
{:else if versions.length === 0}
|
||||||
@@ -98,11 +96,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 mt-2">
|
<div class="flex gap-2 mt-2">
|
||||||
<button onclick={() => previewVersion = version} class="flex-1 px-3 py-1.5 hover:bg-gray-200 dark:hover:bg-white/20 text-xs font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5">
|
<button onclick={() => previewVersion = version} class="flex-1 px-3 py-1.5 hover:bg-[var(--color-surface-sunken)] text-xs font-medium rounded-md transition-colors flex items-center justify-center gap-1.5">
|
||||||
<Icon icon="mdi:eye" class="text-sm" />
|
<Icon icon="mdi:eye" class="text-sm" />
|
||||||
Preview
|
Preview
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => restoreVersion(version)} class="flex-1 px-3 py-1.5 bg-purple-50 text-purple-700 hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400 dark:hover:bg-purple-900/40 text-xs font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5">
|
<button onclick={() => restoreVersion(version)} class="flex-1 px-3 py-1.5 bg-purple-50 text-purple-700 hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400 dark:hover:bg-purple-900/40 text-xs font-medium rounded-md transition-colors flex items-center justify-center gap-1.5">
|
||||||
<Icon icon="mdi:restore" class="text-sm" />
|
<Icon icon="mdi:restore" class="text-sm" />
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
@@ -122,17 +120,17 @@
|
|||||||
<h3 class="text-lg font-semibold text-[var(--theme-text)]">Previewing Version</h3>
|
<h3 class="text-lg font-semibold text-[var(--theme-text)]">Previewing Version</h3>
|
||||||
<span class="text-sm">{formatDate(previewVersion.created_at)}</span>
|
<span class="text-sm">{formatDate(previewVersion.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => previewVersion = null} class="p-1.5 hover:text-gray-600 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-white/10 rounded-md transition-colors" title="Close Preview">
|
<button onclick={() => previewVersion = null} class="p-1.5 hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors" title="Close Preview">
|
||||||
<Icon icon="mdi:close" class="text-xl" />
|
<Icon icon="mdi:close" class="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto p-6 bg-gray-50/50 bg-[var(--theme-bg)] text-[var(--theme-text)]">
|
<div class="flex-1 overflow-auto p-6 bg-[var(--theme-bg)] text-[var(--theme-text)]">
|
||||||
<pre class="text-sm font-mono whitespace-pre-wrap word-break-break-word">{previewVersion.content}</pre>
|
<pre class="text-sm font-mono whitespace-pre-wrap word-break-break-word">{previewVersion.content}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 border-t flex justify-end gap-3 bg-white/50 rounded-b-2xl border-[var(--theme-border)]">
|
<div class="p-4 border-t flex justify-end gap-3 bg-[var(--color-surface)] rounded-b-2xl border-[var(--theme-border)]">
|
||||||
<button onclick={() => previewVersion = null} class="px-4 py-2 text-sm font-medium hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
<button onclick={() => previewVersion = null} class="px-4 py-2 text-sm font-medium hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded-md transition-colors">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => restoreVersion(previewVersion!)} class="bg-purple-600 hover:bg-purple-700 px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm flex items-center gap-2">
|
<button onclick={() => restoreVersion(previewVersion!)} class="bg-purple-600 hover:bg-purple-700 px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
let { createDoc, onClose } = $props<{
|
|
||||||
createDoc: (title: string) => void,
|
|
||||||
onClose: () => void
|
|
||||||
}>();
|
|
||||||
|
|
||||||
let newDocTitle = $state('Untitled Document');
|
|
||||||
|
|
||||||
function onSubmit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
createDoc(newDocTitle);
|
|
||||||
}
|
|
||||||
</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">
|
|
||||||
<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
|
|
||||||
</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="doc-title-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Document Title</label>
|
|
||||||
<input
|
|
||||||
id="doc-title-input"
|
|
||||||
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"
|
|
||||||
placeholder="Untitled Document"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
let { createFolder, onClose } = $props<{
|
|
||||||
createFolder: (name: string) => void,
|
|
||||||
onClose: () => void
|
|
||||||
}>();
|
|
||||||
|
|
||||||
let newFolderName = $state('New Folder');
|
|
||||||
|
|
||||||
function onSubmit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
createFolder(newFolderName);
|
|
||||||
}
|
|
||||||
</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">
|
|
||||||
<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
|
|
||||||
</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="folder-title-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Folder Name</label>
|
|
||||||
<input
|
|
||||||
id="folder-title-input"
|
|
||||||
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"
|
|
||||||
placeholder="New Folder"
|
|
||||||
/>
|
|
||||||
</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-yellow-500 hover:bg-yellow-600 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>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
let { deleteTarget, confirmDelete, onClose } = $props<{
|
|
||||||
deleteTarget: {id: string, type: 'document'|'folder'|'file', name: string},
|
|
||||||
confirmDelete: () => void,
|
|
||||||
onClose: () => void
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={onClose} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { onClose(e); } }}>
|
|
||||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex items-center gap-3 mb-6">
|
|
||||||
<div class="p-2 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded-lg">
|
|
||||||
<Icon icon="mdi:trash-can-outline" class="text-xl" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Delete {deleteTarget.type}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-6">
|
|
||||||
Are you sure you want to delete <span class="font-semibold text-gray-900 dark:text-white">{deleteTarget.name}</span>?
|
|
||||||
{#if deleteTarget.type === 'folder'}This will also delete all of its contents.{/if}
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="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="button" onclick={confirmDelete} class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
} else {
|
} else {
|
||||||
const button = e.currentTarget as HTMLElement;
|
const button = e.currentTarget as HTMLElement;
|
||||||
const rect = button.getBoundingClientRect();
|
const rect = button.getBoundingClientRect();
|
||||||
// If there's less than 200px below the button, open upwards
|
|
||||||
if (window.innerHeight - rect.bottom < 200) {
|
if (window.innerHeight - rect.bottom < 200) {
|
||||||
dropUp = true;
|
dropUp = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -33,7 +32,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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"
|
class="bg-[var(--color-surface)] rounded-lg shadow-sm border border-[var(--color-line)] flex flex-col hover:border-[var(--color-accent)] transition relative group cursor-pointer overflow-visible"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick={() => goto(`/doc/${doc.id}`)}
|
onclick={() => goto(`/doc/${doc.id}`)}
|
||||||
@@ -42,13 +41,13 @@
|
|||||||
ondragstart={(e) => e.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'document', id: doc.id }))}
|
ondragstart={(e) => e.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'document', id: doc.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">
|
<div class="h-40 w-full bg-[var(--color-surface-muted)] rounded-t-lg overflow-hidden flex items-center justify-center border-b border-[var(--color-line)] relative pointer-events-none">
|
||||||
{#if doc.thumbnail_svg}
|
{#if doc.thumbnail_svg}
|
||||||
<div class="w-full h-full flex items-start justify-center bg-white transition-transform duration-300 group-hover:scale-110">
|
<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(doc.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
|
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(doc.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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">
|
<div class="p-4 bg-[var(--color-accent-soft)] text-[var(--color-accent)] rounded-full transition-transform duration-300 group-hover:scale-110">
|
||||||
<Icon icon="mdi:file-document" class="text-4xl" />
|
<Icon icon="mdi:file-document" class="text-4xl" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -57,34 +56,34 @@
|
|||||||
|
|
||||||
<div class="p-4 flex flex-col flex-grow">
|
<div class="p-4 flex flex-col flex-grow">
|
||||||
<div class="flex items-start justify-between">
|
<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={doc.title}>{doc.title}</h3>
|
<h3 class="text-lg font-semibold text-[var(--color-ink)] truncate pr-2 pointer-events-none" title={doc.title}>{doc.title}</h3>
|
||||||
|
|
||||||
|
|
||||||
<div class="relative action-menu-container">
|
<div class="relative action-menu-container">
|
||||||
<button
|
<button
|
||||||
aria-label="Document actions"
|
aria-label="Document actions"
|
||||||
onclick={toggleMenu}
|
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"
|
class="text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors p-1 rounded-full hover:bg-[var(--color-surface-sunken)] pointer-events-auto"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:dots-vertical" class="text-xl" />
|
<Icon icon="mdi:dots-vertical" class="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if activeMenu === doc.id}
|
{#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]">
|
<div class="absolute right-0 {dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} w-48 bg-[var(--color-surface)] rounded-md shadow-xl border border-[var(--color-line)] 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">
|
<button onclick={(e) => { e.stopPropagation(); openInfo(doc, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
<Icon icon="mdi:information-outline" class="text-lg text-blue-500" />
|
<Icon icon="mdi:information-outline" class="text-lg text-blue-500" />
|
||||||
View Info
|
View Info
|
||||||
</button>
|
</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-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
<Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
|
<Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</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-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
<Icon icon="mdi:share-variant-outline" class="text-lg text-green-500" />
|
<Icon icon="mdi:share-variant-outline" class="text-lg text-green-500" />
|
||||||
Share
|
Share
|
||||||
</button>
|
</button>
|
||||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
<div class="h-px bg-[var(--color-line)] 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">
|
<button onclick={(e) => { e.stopPropagation(); deleteDoc(doc.id, doc.title); }} class="w-full text-left px-4 py-2 text-sm text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 flex items-center gap-2">
|
||||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -92,7 +91,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-2 pointer-events-none">
|
<p class="text-xs text-[var(--color-ink-muted)] flex items-center gap-1 mt-2 pointer-events-none">
|
||||||
<Icon icon="mdi:clock-outline" class="text-sm" />
|
<Icon icon="mdi:clock-outline" class="text-sm" />
|
||||||
Edited {new Date(doc.updated_at.endsWith('Z') ? doc.updated_at : doc.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
Edited {new Date(doc.updated_at.endsWith('Z') ? doc.updated_at : doc.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 flex flex-col transition-all duration-200 relative group transform hover:-translate-y-1 {isFont ? 'cursor-default' : 'hover:shadow-lg hover:border-green-400 dark:hover:border-green-500/50 cursor-pointer'}"
|
class="bg-[var(--color-surface)] rounded-lg shadow-sm border border-[var(--color-line)] p-6 flex flex-col transition relative group {isFont ? 'cursor-default' : 'hover:border-[var(--color-accent)] cursor-pointer'}"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick={handleOpen}
|
onclick={handleOpen}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
ondragstart={(e) => e.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'file', id: file.id }))}
|
ondragstart={(e) => e.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'file', id: file.id }))}
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-4 pointer-events-none">
|
<div class="flex items-start justify-between mb-4 pointer-events-none">
|
||||||
<div class="p-3 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg overflow-hidden flex items-center justify-center w-12 h-12">
|
<div class="p-3 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded-md overflow-hidden flex items-center justify-center w-12 h-12">
|
||||||
{#if file.mime_type.startsWith('image/')}
|
{#if file.mime_type.startsWith('image/')}
|
||||||
<img src={`/api/files/${file.id}/data`} alt={file.name} class="w-full h-full object-cover rounded" draggable="false" />
|
<img src={`/api/files/${file.id}/data`} alt={file.name} class="w-full h-full object-cover rounded" draggable="false" />
|
||||||
{:else if isFont}
|
{:else if isFont}
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
<Icon icon="mdi:file-outline" class="text-2xl" />
|
<Icon icon="mdi:file-outline" class="text-2xl" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button aria-label="Delete file" onclick={(e) => { e.stopPropagation(); deleteFile(file.id, file.name); }} class="pointer-events-auto text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity bg-gray-50 hover:bg-red-50 dark:bg-white/5 dark:hover:bg-red-900/20 rounded-full p-2 shadow-sm border border-gray-100 dark:border-white/10">
|
<button aria-label="Delete file" onclick={(e) => { e.stopPropagation(); deleteFile(file.id, file.name); }} class="pointer-events-auto text-[var(--color-ink-muted)] hover:text-[var(--color-danger)] opacity-0 group-hover:opacity-100 transition-opacity bg-[var(--color-surface-muted)] hover:bg-[var(--color-danger)]/10 rounded-full p-2 shadow-sm border border-[var(--color-line)]">
|
||||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate mb-1 pointer-events-none" title={file.name}>{file.name}</h3>
|
<h3 class="text-lg font-semibold text-[var(--color-ink)] truncate mb-1 pointer-events-none" title={file.name}>{file.name}</h3>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-auto pt-4 border-t border-gray-100 dark:border-white/10 pointer-events-none">
|
<p class="text-xs text-[var(--color-ink-muted)] flex items-center gap-1 mt-auto pt-4 border-t border-[var(--color-line)] pointer-events-none">
|
||||||
<Icon icon="mdi:clock-outline" class="text-sm" />
|
<Icon icon="mdi:clock-outline" class="text-sm" />
|
||||||
Uploaded {new Date(file.created_at ? (file.created_at.endsWith('Z') ? file.created_at : file.created_at + 'Z') : Date.now()).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
Uploaded {new Date(file.created_at ? (file.created_at.endsWith('Z') ? file.created_at : file.created_at + 'Z') : Date.now()).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-row items-center p-3 bg-white/50 dark:bg-black/20 backdrop-blur-sm border border-gray-200 dark:border-white/10 rounded-xl shadow-sm hover:shadow-md cursor-pointer group transition-all duration-200 relative {dragOverFolderId === folder.id ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'hover:-translate-y-0.5 hover:border-gray-300 dark:hover:border-white/20'}"
|
class="flex flex-row items-center p-3 bg-[var(--color-surface)] border border-[var(--color-line)] rounded-lg shadow-sm cursor-pointer group transition relative {dragOverFolderId === folder.id ? 'ring-2 ring-[var(--color-accent)] bg-[var(--color-accent-soft)]' : 'hover:border-[var(--color-accent)]'}"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick={() => navigateToFolder(folder)}
|
onclick={() => navigateToFolder(folder)}
|
||||||
@@ -21,12 +21,12 @@
|
|||||||
ondragleave={() => setDragOverFolderId(null)}
|
ondragleave={() => setDragOverFolderId(null)}
|
||||||
ondrop={(e) => handleDrop(e, folder.id)}
|
ondrop={(e) => handleDrop(e, folder.id)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-center w-10 h-10 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg group-hover:scale-105 transition-transform pointer-events-none shrink-0 mr-3">
|
<div class="flex items-center justify-center w-10 h-10 bg-yellow-50 dark:bg-yellow-500/10 rounded-md group-hover:scale-105 transition-transform pointer-events-none shrink-0 mr-3">
|
||||||
<Icon icon="mdi:folder" class="text-2xl text-yellow-500" />
|
<Icon icon="mdi:folder" class="text-2xl text-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
<span class="font-medium text-gray-900 dark:text-white text-sm truncate w-full pointer-events-none pr-6">{folder.name}</span>
|
<span class="font-medium text-[var(--color-ink)] text-sm truncate w-full pointer-events-none pr-6">{folder.name}</span>
|
||||||
|
|
||||||
<button aria-label="Delete folder" onclick={(e) => { e.stopPropagation(); deleteFolder(folder.id, folder.name); }} class="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 shrink-0">
|
<button aria-label="Delete folder" onclick={(e) => { e.stopPropagation(); deleteFolder(folder.id, folder.name); }} class="absolute top-1/2 -translate-y-1/2 right-2 text-[var(--color-ink-muted)] hover:text-[var(--color-danger)] opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded hover:bg-[var(--color-danger)]/10 shrink-0">
|
||||||
<Icon icon="mdi:trash-can-outline" class="text-base" />
|
<Icon icon="mdi:trash-can-outline" class="text-base" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,41 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Modal from '../Modal.svelte';
|
||||||
let { selectedInfo, onClose } = $props<{
|
let { selectedInfo, onClose } = $props<{
|
||||||
selectedInfo: {type: string, title?: string, name?: string, created_at: string, updated_at?: string},
|
selectedInfo: {type: string, title?: string, name?: string, created_at: string, updated_at?: string},
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const icon = $derived(selectedInfo.type === 'document' ? 'ph:file-text' : selectedInfo.type === 'folder' ? 'ph:folder' : 'ph:file');
|
||||||
|
const title = $derived(selectedInfo.title || selectedInfo.name || 'Details');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-opacity" onclick={onClose} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { onClose(e); } }}>
|
<Modal {title} {icon} onclose={onClose}>
|
||||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
<div class="flex flex-col gap-4 text-xs">
|
||||||
<div class="p-6 border-b border-gray-100 dark:border-white/10">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="p-2 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg">
|
|
||||||
<Icon icon={selectedInfo.type === 'document' ? 'mdi:file-document' : selectedInfo.type === 'folder' ? 'mdi:folder' : 'mdi:file'} class="text-xl" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex-grow truncate">{selectedInfo.title || selectedInfo.name}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p>
|
<p class="mb-1 font-medium text-[var(--color-ink-muted)]">Type</p>
|
||||||
<p class="text-sm text-gray-900 dark:text-gray-100 capitalize">{selectedInfo.type}</p>
|
<p class="text-sm capitalize text-[var(--color-ink)]">{selectedInfo.type}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Created At</p>
|
<p class="mb-1 font-medium text-[var(--color-ink-muted)]">Created</p>
|
||||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(selectedInfo.created_at.endsWith('Z') ? selectedInfo.created_at : selectedInfo.created_at + 'Z').toLocaleString()}</p>
|
<p class="text-sm text-[var(--color-ink)]">{new Date(selectedInfo.created_at.endsWith('Z') ? selectedInfo.created_at : selectedInfo.created_at + 'Z').toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if selectedInfo.updated_at}
|
{#if selectedInfo.updated_at}
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p>
|
<p class="mb-1 font-medium text-[var(--color-ink-muted)]">Last modified</p>
|
||||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(selectedInfo.updated_at.endsWith('Z') ? selectedInfo.updated_at : selectedInfo.updated_at + 'Z').toLocaleString()}</p>
|
<p class="text-sm text-[var(--color-ink)]">{new Date(selectedInfo.updated_at.endsWith('Z') ? selectedInfo.updated_at : selectedInfo.updated_at + 'Z').toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</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 type="button" onclick={onClose} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
{#snippet footer()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { userStore } from '$lib/ts/auth';
|
import { userStore } from '$lib/ts/auth';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
@@ -11,36 +10,43 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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">
|
<nav class="bg-[var(--color-surface)] shadow-sm border-b border-[var(--color-line)] px-4 py-2 flex justify-between items-center sticky top-0 z-10 transition-colors duration-200">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
<h1 class="text-lg font-bold text-[var(--color-ink)] flex items-center gap-2">
|
||||||
<Icon icon="mdi:script-text" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--color-accent)]">
|
||||||
|
<img src="/favicon.png" alt="TypstDrive" class="h-6 w-6" />
|
||||||
|
</span>
|
||||||
TypstDrive
|
TypstDrive
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300 font-medium">
|
<div class="flex items-center gap-1.5 text-[var(--color-ink-muted)] font-medium text-sm">
|
||||||
<Icon icon="mdi:account-circle" class="text-xl" />
|
<Icon icon="mdi:account-circle" class="text-lg" />
|
||||||
{$userStore?.username}
|
{$userStore?.username}
|
||||||
</div>
|
</div>
|
||||||
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
<div class="h-5 w-px bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
|
<a href="/projects" class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-2 py-1.5 rounded-md flex items-center gap-2" title="Projects">
|
||||||
<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:folder-multiple-outline" class="text-lg" />
|
||||||
<Icon icon="mdi:book-open-page-variant-outline" class="text-xl" />
|
|
||||||
</a>
|
</a>
|
||||||
<a href="https://typst.app/universe/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-gray-600 hover:text-purple-500 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="Typst Universe">
|
<a href="/packages" class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-2 py-1.5 rounded-md flex items-center gap-2" title="Packages">
|
||||||
<Icon icon="mdi:earth" class="text-xl" />
|
<Icon icon="mdi:package-variant-closed" class="text-lg" />
|
||||||
</a>
|
</a>
|
||||||
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
|
||||||
|
|
||||||
|
<a href="/api-docs" class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-2 py-1.5 rounded-md flex items-center gap-2" title="API Docs">
|
||||||
|
<Icon icon="mdi:api" class="text-lg" />
|
||||||
|
</a>
|
||||||
|
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-2 py-1.5 rounded-md flex items-center gap-2" title="Typst Docs">
|
||||||
|
<Icon icon="mdi:book-open-page-variant-outline" class="text-lg" />
|
||||||
|
</a>
|
||||||
|
<a href="https://typst.app/universe/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-2 py-1.5 rounded-md flex items-center gap-2" title="Typst Universe">
|
||||||
|
<Icon icon="mdi:earth" class="text-lg" />
|
||||||
|
</a>
|
||||||
|
<div class="h-5 w-px bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
<ThemePicker />
|
<button onclick={() => goto('/settings')} class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-2 py-1.5 rounded-md flex items-center gap-2" title="Settings">
|
||||||
|
<Icon icon="mdi:cog" class="text-lg" />
|
||||||
<button onclick={() => goto('/settings')} 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-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Settings">
|
|
||||||
<Icon icon="mdi:cog" class="text-2xl" />
|
|
||||||
</button>
|
</button>
|
||||||
<button onclick={logout} 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 border border-transparent dark:border-white/10">
|
<button onclick={logout} class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-3 py-1.5 rounded-md flex items-center gap-2" title="Logout">
|
||||||
<Icon icon="mdi:logout" class="text-lg" />
|
<Icon icon="mdi:logout" class="text-base" />
|
||||||
Logout
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { project, activeMenu, setActiveMenu, openInfo, openRename, deleteProject } = $props<{
|
||||||
|
project: any;
|
||||||
|
activeMenu: string | null;
|
||||||
|
setActiveMenu: (id: string | null) => void;
|
||||||
|
openInfo: (project: any) => void;
|
||||||
|
openRename: (id: string, name: string) => void;
|
||||||
|
deleteProject: (id: string, name: string) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let dropUp = $state(false);
|
||||||
|
|
||||||
|
function toggleMenu(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (activeMenu === project.id) {
|
||||||
|
setActiveMenu(null);
|
||||||
|
} else {
|
||||||
|
const button = e.currentTarget as HTMLElement;
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
dropUp = window.innerHeight - rect.bottom < 200;
|
||||||
|
setActiveMenu(project.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-[var(--color-surface)] rounded-lg shadow-sm border border-[var(--color-line)] flex flex-col hover:border-[var(--color-accent)] transition relative group cursor-pointer overflow-visible"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => goto(`/project/${project.id}`)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && goto(`/project/${project.id}`)}
|
||||||
|
>
|
||||||
|
<div class="h-40 w-full bg-[var(--color-surface-muted)] rounded-t-lg overflow-hidden flex items-center justify-center border-b border-[var(--color-line)] relative pointer-events-none">
|
||||||
|
{#if project.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(project.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="p-4 bg-[var(--color-accent-soft)] text-[var(--color-accent)] 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-[var(--color-ink)] truncate pr-2 pointer-events-none" title={project.name}>{project.name}</h3>
|
||||||
|
|
||||||
|
<div class="relative action-menu-container">
|
||||||
|
<button aria-label="Project actions" onclick={toggleMenu} class="text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors p-1 rounded-full hover:bg-[var(--color-surface-sunken)] pointer-events-auto">
|
||||||
|
<Icon icon="mdi:dots-vertical" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if activeMenu === project.id}
|
||||||
|
<div class="absolute right-0 {dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} w-48 bg-[var(--color-surface)] rounded-md shadow-xl border border-[var(--color-line)] py-1 z-[100]">
|
||||||
|
<button onclick={(e) => { e.stopPropagation(); openInfo(project); }} class="w-full text-left px-4 py-2 text-sm text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] 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(project.id, project.name); }} class="w-full text-left px-4 py-2 text-sm text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button onclick={(e) => { e.stopPropagation(); goto(`/project/${project.id}`); }} class="w-full text-left px-4 py-2 text-sm text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:open-in-new" class="text-lg text-green-500" />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<div class="h-px bg-[var(--color-line)] my-1"></div>
|
||||||
|
<button onclick={(e) => { e.stopPropagation(); deleteProject(project.id, project.name); }} class="w-full text-left px-4 py-2 text-sm text-[var(--color-danger)] hover:bg-[var(--color-danger)]/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-[var(--color-ink-muted)] flex items-center gap-1 mt-2 pointer-events-none">
|
||||||
|
<Icon icon="mdi:clock-outline" class="text-sm" />
|
||||||
|
Edited {new Date(project.updated_at.endsWith('Z') ? project.updated_at : project.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
let { initialTitle, handleRename, onClose } = $props<{
|
|
||||||
initialTitle: string,
|
|
||||||
handleRename: (newTitle: string) => void,
|
|
||||||
onClose: () => void
|
|
||||||
}>();
|
|
||||||
|
|
||||||
let renameTitle = $state("");
|
|
||||||
$effect(() => { renameTitle = initialTitle; });
|
|
||||||
|
|
||||||
function onSubmit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleRename(renameTitle);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-opacity" onclick={onClose} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { onClose(e); } }}>
|
|
||||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
|
||||||
<form onsubmit={onSubmit} class="p-6">
|
|
||||||
<div class="flex items-center gap-3 mb-6">
|
|
||||||
<div class="p-2 bg-yellow-50 dark:bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-lg">
|
|
||||||
<Icon icon="mdi:pencil-outline" class="text-xl" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Rename</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
bind:value={renameTitle}
|
|
||||||
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"
|
|
||||||
placeholder="Enter new name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-6 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">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
|
interface ProjectFile {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
files = [],
|
||||||
|
activeFileId = '',
|
||||||
|
entrypoint = 'main.typ',
|
||||||
|
readOnly = false,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onUpload,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onSetEntry
|
||||||
|
}: {
|
||||||
|
files?: ProjectFile[];
|
||||||
|
activeFileId?: string;
|
||||||
|
entrypoint?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
onSelect: (file: ProjectFile) => void;
|
||||||
|
onCreate: (path: string) => void;
|
||||||
|
onUpload: (fileList: FileList) => void;
|
||||||
|
onRename: (file: ProjectFile, path: string) => void;
|
||||||
|
onDelete: (file: ProjectFile) => void;
|
||||||
|
onSetEntry: (file: ProjectFile) => 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: ProjectFile) {
|
||||||
|
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(--color-surface)] border-r border-[var(--color-line)]">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--color-line)]">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-muted)]">Files</span>
|
||||||
|
{#if !readOnly}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button onclick={handleCreate} title="New file" class="p-1 rounded hover:bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:file-plus-outline" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
<button onclick={() => fileInput.click()} title="Upload file" class="p-1 rounded hover:bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]">
|
||||||
|
<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-[var(--color-accent-soft)] text-[var(--color-accent)]' : 'text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}">
|
||||||
|
<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-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:star-outline" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button onclick={() => handleRename(file)} title="Rename" class="p-0.5 rounded hover:bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:pencil-outline" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
<button onclick={() => onDelete(file)} title="Delete" class="p-0.5 rounded hover:bg-[var(--color-danger)]/10 text-[var(--color-ink-muted)] hover:text-[var(--color-danger)]">
|
||||||
|
<Icon icon="mdi:trash-can-outline" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { undo, redo } from '@codemirror/commands';
|
||||||
|
import {
|
||||||
|
connectedUsers,
|
||||||
|
editorViewStore,
|
||||||
|
documentZoomStore,
|
||||||
|
previewOpenStore
|
||||||
|
} from '../../ts/store';
|
||||||
|
import { exportProject } from '../../ts/typst-api';
|
||||||
|
import PageSettingsModal from '../PageSettingsModal.svelte';
|
||||||
|
import PresentationMode from '../PresentationMode.svelte';
|
||||||
|
import Modal from '../Modal.svelte';
|
||||||
|
import PromptModal from '../PromptModal.svelte';
|
||||||
|
import ConfirmModal from '../ConfirmModal.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
projectName = 'Project',
|
||||||
|
projectId,
|
||||||
|
entrypoint = 'main.typ',
|
||||||
|
role = 'owner',
|
||||||
|
activeText = null,
|
||||||
|
activePath = '',
|
||||||
|
getAllText,
|
||||||
|
onPublish,
|
||||||
|
onFilesChanged
|
||||||
|
}: {
|
||||||
|
projectName?: string;
|
||||||
|
projectId: 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 = projectName; });
|
||||||
|
|
||||||
|
function safeName() {
|
||||||
|
return projectName.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;
|
||||||
|
}
|
||||||
|
exportProject(projectId, 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({ project_id: projectId, 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/projects/${projectId}/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(name: string) {
|
||||||
|
if (name && name !== projectName) {
|
||||||
|
fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
}).then((res) => { if (res.ok) window.location.reload(); });
|
||||||
|
}
|
||||||
|
showRenameModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
fetch(`/api/projects/${projectId}`, { method: 'DELETE' }).then((res) => {
|
||||||
|
if (res.ok) goto('/projects');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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('/projects')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] rounded-md hover:bg-[var(--color-surface-sunken)] transition-colors" title="Projects">
|
||||||
|
<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-[var(--color-ink)] tracking-tight truncate max-w-[200px] md:max-w-xs" title={projectName}>{projectName}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-0.5 text-[13px] font-medium text-[var(--color-ink-muted)] -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('/projects'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">All Projects</button>
|
||||||
|
<button onclick={() => { activeMenu = null; showInfoModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Project Info</button>
|
||||||
|
{#if !isViewer}
|
||||||
|
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||||
|
<button onclick={() => { activeMenu = null; renameName = projectName; 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-[var(--color-danger)] hover:bg-[var(--color-danger)]/10">Delete Project</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>
|
||||||
|
</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-[var(--color-surface)] 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-[var(--color-line)]"></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-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] 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-[var(--color-accent)] hover:opacity-90 rounded-md transition-colors">
|
||||||
|
<Icon icon="mdi:package-variant-closed" class="text-[16px]" /> Publish
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="w-px h-5 bg-[var(--color-line)]"></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-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)]' : 'text-[var(--color-accent)] bg-[var(--color-accent-soft)] hover:opacity-90'}" 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-[var(--color-ink-muted)] bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] 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-[var(--color-accent)] bg-[var(--color-accent-soft)] hover:opacity-90 rounded-md transition-colors {activeMenu === 'export' ? 'ring-2 ring-[var(--color-accent)]/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-[var(--color-surface-muted)] border-t border-[var(--color-line)] gap-4 overflow-x-auto no-scrollbar">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button onclick={() => applyFormat('*', '*', 'bold')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Bold"><Icon icon="mdi:format-bold" class="text-lg" /></button>
|
||||||
|
<button onclick={() => applyFormat('_', '_', 'italic')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Italic"><Icon icon="mdi:format-italic" class="text-lg" /></button>
|
||||||
|
<button onclick={() => applyFormat('`', '`', 'code')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Code"><Icon icon="mdi:code-tags" class="text-lg" /></button>
|
||||||
|
<div class="w-px h-4 mx-1 bg-[var(--color-line)]"></div>
|
||||||
|
<button onclick={() => applyFormat('$ ', ' $', 'x = y')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] rounded transition-colors" title="Math (Inline)"><Icon icon="mdi:sigma" class="text-lg" /></button>
|
||||||
|
<button onclick={() => applyFormat('- ', '', 'List item')} class="p-1.5 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] 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-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] 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-[var(--color-line)]"></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-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] 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-[var(--color-line)]"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="project-font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
|
||||||
|
<select id="project-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-[var(--color-line)]"></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-[var(--color-line)]"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 bg-[var(--color-surface)] border border-[var(--color-line)] rounded shadow-sm overflow-hidden">
|
||||||
|
<button onclick={() => $documentZoomStore = Math.max(10, $documentZoomStore - 10)} class="px-2 py-1 text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] 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-[var(--color-ink-muted)] 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-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)] transition-colors" title="Zoom In"><Icon icon="mdi:plus" class="text-sm" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
<Modal title={projectName} icon="ph:folder-star" onclose={() => showInfoModal = false}>
|
||||||
|
<div class="flex flex-col gap-4 text-xs">
|
||||||
|
<div><p class="mb-1 font-medium text-[var(--color-ink-muted)]">Type</p><p class="text-sm text-[var(--color-ink)]">Project (multi-file)</p></div>
|
||||||
|
<div><p class="mb-1 font-medium text-[var(--color-ink-muted)]">Entrypoint</p><p class="font-mono text-sm text-[var(--color-ink)]">{entrypoint}</p></div>
|
||||||
|
<div><p class="mb-1 font-medium text-[var(--color-ink-muted)]">Your role</p><p class="text-sm text-[var(--color-ink)] capitalize">{role}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button onclick={() => showInfoModal = false} class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90">Close</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showRenameModal}
|
||||||
|
<PromptModal
|
||||||
|
title="Rename project"
|
||||||
|
label="Project name"
|
||||||
|
icon="ph:pencil-simple"
|
||||||
|
value={renameName}
|
||||||
|
confirmLabel="Save"
|
||||||
|
onsubmit={submitRename}
|
||||||
|
onclose={() => showRenameModal = false}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showDeleteModal}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete project"
|
||||||
|
message="Delete this project and all its files? This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onconfirm={confirmDelete}
|
||||||
|
onclose={() => showDeleteModal = false}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||||
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
|
import { EditorSelection, Prec } from '@codemirror/state';
|
||||||
|
import type { Language } from '@codemirror/language';
|
||||||
|
|
||||||
|
const autoClosedDelimiters = ['(', '[', '{', '"', '$'];
|
||||||
|
|
||||||
|
const charactersAllowedAfterOpening = ')]}:;>,.$';
|
||||||
|
|
||||||
|
const markupDelimiters: Record<string, string> = {
|
||||||
|
'*': '*',
|
||||||
|
_: '_',
|
||||||
|
'`': '`',
|
||||||
|
'<': '>'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function typstBracketSettings(language: Language) {
|
||||||
|
return language.data.of({
|
||||||
|
closeBrackets: {
|
||||||
|
brackets: autoClosedDelimiters,
|
||||||
|
before: charactersAllowedAfterOpening
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function surroundSelection(view: EditorView, typedText: string) {
|
||||||
|
const closingText = markupDelimiters[typedText];
|
||||||
|
if (!closingText) return false;
|
||||||
|
|
||||||
|
const state = view.state;
|
||||||
|
if (state.readOnly || state.selection.ranges.every((range) => range.empty)) return false;
|
||||||
|
|
||||||
|
const changes = state.changeByRange((range) => {
|
||||||
|
if (range.empty) return { range };
|
||||||
|
const shift = typedText.length;
|
||||||
|
return {
|
||||||
|
changes: [
|
||||||
|
{ from: range.from, insert: typedText },
|
||||||
|
{ from: range.to, insert: closingText }
|
||||||
|
],
|
||||||
|
range: EditorSelection.range(range.anchor + shift, range.head + shift)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
view.dispatch(state.update(changes, { scrollIntoView: true, userEvent: 'input.type' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bracketExtensions = [
|
||||||
|
closeBrackets(),
|
||||||
|
Prec.high(
|
||||||
|
EditorView.inputHandler.of((view, _from, _to, typedText) => surroundSelection(view, typedText))
|
||||||
|
),
|
||||||
|
keymap.of(closeBracketsKeymap)
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
serverCompletion,
|
||||||
|
serverDiagnostics,
|
||||||
|
signatureHelp,
|
||||||
|
formatKeymap,
|
||||||
|
renameKeymap,
|
||||||
|
jumpToDefinitionKeymap,
|
||||||
|
findReferencesKeymap
|
||||||
|
} from '@codemirror/lsp-client';
|
||||||
|
import { keymap } from '@codemirror/view';
|
||||||
|
|
||||||
|
export function typstLspExtensions() {
|
||||||
|
return [
|
||||||
|
serverCompletion(),
|
||||||
|
signatureHelp(),
|
||||||
|
serverDiagnostics(),
|
||||||
|
keymap.of([...formatKeymap, ...renameKeymap, ...jumpToDefinitionKeymap, ...findReferencesKeymap])
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -34,14 +34,14 @@ if (typeof window !== 'undefined') {
|
|||||||
if (savedDark !== null) darkModeStore.set(savedDark === 'true');
|
if (savedDark !== null) darkModeStore.set(savedDark === 'true');
|
||||||
if (savedZoom !== null) documentZoomStore.set(parseInt(savedZoom, 10));
|
if (savedZoom !== null) documentZoomStore.set(parseInt(savedZoom, 10));
|
||||||
|
|
||||||
themeStore.subscribe(value => localStorage.setItem('editor-theme', value));
|
themeStore.subscribe(value => {
|
||||||
|
localStorage.setItem('editor-theme', value);
|
||||||
|
document.documentElement.dataset.colorTheme = value;
|
||||||
|
});
|
||||||
darkModeStore.subscribe(value => {
|
darkModeStore.subscribe(value => {
|
||||||
localStorage.setItem('editor-dark-mode', value.toString());
|
localStorage.setItem('editor-dark-mode', value.toString());
|
||||||
if (value) {
|
document.documentElement.dataset.theme = value ? 'dark' : 'light';
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.toggle('dark', value);
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
documentZoomStore.subscribe(value => localStorage.setItem('editor-document-zoom', value.toString()));
|
documentZoomStore.subscribe(value => localStorage.setItem('editor-document-zoom', value.toString()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,35 @@ export async function compileTypst(text: string, document_id?: string): Promise<
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function compileProject(project_id: string, files: Record<string, string>): Promise<CompileResponse> {
|
||||||
|
const res = await fetch('/api/compile', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ project_id, files }),
|
||||||
|
});
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportProject(project_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({ project_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) {
|
export function exportTypst(text: string, format: 'pdf' | 'png' | 'svg', title: string = 'document', document_id?: string) {
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
|
|||||||
@@ -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 projectId: string | null = null;
|
||||||
|
|
||||||
|
const TEXT_NAME = 'typst';
|
||||||
|
|
||||||
|
export function setProject(id: string) {
|
||||||
|
projectId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openFile(fileId: string, path: string): OpenFile {
|
||||||
|
const existing = open.get(fileId);
|
||||||
|
if (existing) return existing;
|
||||||
|
if (!projectId) throw new Error('Project 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`, `project:${projectId}:${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 cleanupProject() {
|
||||||
|
for (const fileId of Array.from(open.keys())) {
|
||||||
|
closeFile(fileId);
|
||||||
|
}
|
||||||
|
projectId = null;
|
||||||
|
connectionStatus.set('disconnected');
|
||||||
|
connectedUsers.set([]);
|
||||||
|
}
|
||||||
@@ -4,26 +4,26 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-[80vh] flex flex-col items-center justify-center p-4">
|
<div class="min-h-[80vh] flex flex-col items-center justify-center p-4">
|
||||||
<div class="text-center max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-xl border border-gray-200 dark:border-zinc-800 p-8">
|
<div class="text-center max-w-md bg-[var(--color-surface)] rounded-2xl shadow-xl border border-[var(--color-line)] p-8">
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-red-100 dark:bg-red-500/10 mb-6">
|
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-[var(--color-danger)]/10 mb-6">
|
||||||
<Icon icon="mdi:alert-circle-outline" class="h-10 w-10 text-red-600 dark:text-red-500" />
|
<Icon icon="mdi:alert-circle-outline" class="h-10 w-10 text-[var(--color-danger)]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-6xl font-bold text-gray-900 dark:text-white mb-2 tracking-tight">
|
<h1 class="text-6xl font-bold text-[var(--color-ink)] mb-2 tracking-tight">
|
||||||
{$page.status}
|
{$page.status}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
|
<h2 class="text-xl font-semibold text-[var(--color-ink)] mb-4">
|
||||||
Something went wrong
|
Something went wrong
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-base text-gray-600 dark:text-gray-400 mb-8 leading-relaxed">
|
<p class="text-base text-[var(--color-ink-muted)] mb-8 leading-relaxed">
|
||||||
{$page.error?.message || 'We experienced an unexpected error processing your request.'}
|
{$page.error?.message || 'We experienced an unexpected error processing your request.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="inline-flex items-center justify-center gap-2 px-6 py-3 border border-transparent text-sm font-semibold rounded-xl shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 transition-colors w-full"
|
class="inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-semibold rounded-xl shadow-sm text-white bg-[var(--color-accent)] hover:opacity-90 focus:outline-none transition w-full"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:home" class="text-lg" />
|
<Icon icon="mdi:home" class="text-lg" />
|
||||||
Return to Dashboard
|
Return to Dashboard
|
||||||
|
|||||||
@@ -40,21 +40,11 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div
|
<div class="min-h-screen w-full flex flex-col bg-[var(--color-surface-muted)] text-[var(--color-ink)] font-sans transition-colors duration-200">
|
||||||
class="min-h-screen w-full flex flex-col font-sans transition-colors duration-200"
|
|
||||||
style="
|
|
||||||
background-color: {currentColors.background};
|
|
||||||
color: {currentColors.text};
|
|
||||||
--theme-bg: {currentColors.background};
|
|
||||||
--theme-text: {currentColors.text};
|
|
||||||
--theme-border: {currentColors.selection};
|
|
||||||
--theme-cursor: {currentColors.cursor};
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="min-h-screen w-full flex items-center justify-center bg-gray-50 dark:bg-zinc-950">
|
<div class="min-h-screen w-full flex items-center justify-center bg-[var(--color-surface-muted)]">
|
||||||
<div class="text-gray-500 dark:text-gray-400 font-medium animate-pulse">Loading TypstDrive...</div>
|
<div class="text-[var(--color-ink-muted)] font-medium animate-pulse">Loading TypstDrive...</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -17,6 +17,6 @@
|
|||||||
<meta name="description" content="Collaborative Typst Editor." />
|
<meta name="description" content="Collaborative Typst Editor." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="h-full flex items-center justify-center text-gray-500">
|
<div class="h-full flex items-center justify-center text-[var(--color-ink-muted)]">
|
||||||
Redirecting...
|
Redirecting...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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(--color-surface)] shadow-sm border-b border-[var(--color-line)] 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-[var(--color-ink)] flex items-center gap-3">
|
||||||
|
<Icon icon="mdi:api" class="text-[var(--color-accent)] text-3xl" />
|
||||||
|
API Reference
|
||||||
|
</h1>
|
||||||
|
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-4 py-2 rounded-md 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-md text-sm font-medium transition-colors {activeSection === section.id
|
||||||
|
? 'bg-[var(--color-accent)] text-white shadow-sm'
|
||||||
|
: 'text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
|
||||||
|
>
|
||||||
|
<Icon icon={section.icon} class="text-lg flex-shrink-0" />
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="pt-4 mt-4 border-t border-[var(--color-line)]">
|
||||||
|
<a href="/settings" class="w-full flex items-center gap-3 px-4 py-2.5 rounded-md text-sm font-medium text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)] transition-colors">
|
||||||
|
<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-md text-xs font-medium transition-colors {activeSection === section.id ? 'bg-[var(--color-accent)] text-white' : 'bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]'}"
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeSection === 'overview'}
|
||||||
|
<div class="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--color-ink)] mb-4 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:book-open-outline" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
Overview
|
||||||
|
</h2>
|
||||||
|
<p class="text-[var(--color-ink-muted)] 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-[var(--color-accent)] mb-2" />
|
||||||
|
<p class="text-sm font-semibold text-[var(--color-ink)]">PNG output</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)] 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-[var(--color-ink)]">PDF output</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)] mt-1">Full multi-page PDF document</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-xl bg-[var(--color-success)]/10 border border-[var(--color-success)]/20">
|
||||||
|
<Icon icon="mdi:lightning-bolt" class="text-2xl text-green-500 mb-2" />
|
||||||
|
<p class="text-sm font-semibold text-[var(--color-ink)]">Cached results</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)] mt-1">Identical inputs skip recompilation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--color-surface-muted)] rounded-xl p-4 border border-[var(--color-line)]">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-muted)] mb-1">Base URL</p>
|
||||||
|
<code class="font-mono text-sm text-[var(--color-accent)]">{baseUrl}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'auth'}
|
||||||
|
<div class="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--color-ink)] mb-4 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:key-outline" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
Authentication
|
||||||
|
</h2>
|
||||||
|
<p class="text-[var(--color-ink-muted)] mb-6">
|
||||||
|
All requests must include an API key in the <code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-1.5 py-0.5 rounded">Authorization</code> header.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-[var(--color-ink-muted)] 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-[var(--color-ink-muted)] mb-1">Managing keys</p>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)]">
|
||||||
|
Create, regenerate, and revoke keys in
|
||||||
|
<a href="/settings" class="text-[var(--color-accent)] 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-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] p-6 sm:p-8 space-y-6">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:api" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
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-[var(--color-success)] rounded-md">POST</span>
|
||||||
|
<code class="font-mono text-sm text-[var(--color-ink)]">/v1/render</code>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)]">
|
||||||
|
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-[var(--color-line)]"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-[var(--color-ink)] mb-3">Request headers</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-[var(--color-line)]">
|
||||||
|
<th class="text-left py-2 pr-4 font-semibold text-[var(--color-ink-muted)] w-40">Header</th>
|
||||||
|
<th class="text-left py-2 font-semibold text-[var(--color-ink-muted)]">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-[var(--color-ink-muted)]">
|
||||||
|
<tr class="border-b border-[var(--color-line)]">
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs">Authorization</td>
|
||||||
|
<td class="py-2"><code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-1.5 py-0.5 rounded">Bearer <api-key></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-[var(--color-surface-sunken)] 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-[var(--color-ink)] 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-[var(--color-ink)] mb-3">Response</p>
|
||||||
|
<div class="p-3 rounded-lg bg-[var(--color-success)]/10 border border-[var(--color-success)]/20 text-sm">
|
||||||
|
<span class="font-mono text-xs font-bold text-[var(--color-success)]">200 OK</span>
|
||||||
|
<span class="text-[var(--color-ink-muted)] ml-2">Response body with <code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-2 rounded">Content-Type: image/png</code>, <code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-2 rounded">application/pdf</code>, or <code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-2 rounded">text/html</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-[var(--color-ink)] mb-3">Compilation errors</p>
|
||||||
|
<div class="p-3 mb-3 rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 text-sm">
|
||||||
|
<span class="font-mono text-xs font-bold text-[var(--color-danger)]">422 Unprocessable Entity</span>
|
||||||
|
<span class="text-[var(--color-ink-muted)] ml-2">JSON body describing every Typst error. <code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-2 rounded">error</code> is a readable summary; <code class="font-mono text-xs bg-[var(--color-surface-sunken)] 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-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] p-6 sm:p-8 space-y-8">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:code-braces" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
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-[var(--color-ink)] 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-[var(--color-ink-muted)] hover:text-[var(--color-accent)] transition-colors px-2 py-1 rounded-md hover:bg-[var(--color-surface-sunken)]">
|
||||||
|
<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-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--color-ink)] mb-6 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:speedometer" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
Rate Limits
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div class="p-4 rounded-xl bg-[var(--color-surface-muted)] border border-[var(--color-line)]">
|
||||||
|
<p class="text-2xl font-bold text-[var(--color-ink)]">60</p>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)] mt-1">requests / minute per key</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-xl bg-[var(--color-surface-muted)] border border-[var(--color-line)]">
|
||||||
|
<p class="text-2xl font-bold text-[var(--color-ink)]">10</p>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)] 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-[var(--color-ink-muted)] mb-2">When exceeded</p>
|
||||||
|
<div class="p-3 rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 text-sm">
|
||||||
|
<code class="font-mono text-xs font-bold text-[var(--color-danger)]">429 Too Many Requests</code>
|
||||||
|
<span class="text-[var(--color-ink-muted)] ml-2">— wait for the current 60-second window to reset.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'errors'}
|
||||||
|
<div class="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--color-ink)] mb-6 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:alert-circle-outline" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
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-[var(--color-line)] bg-[var(--color-surface-muted)]">
|
||||||
|
<code class="font-mono text-sm font-bold text-[var(--color-ink)] flex-shrink-0 w-8">{err.code}</code>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-[var(--color-ink)]">{err.name}</p>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)] mt-0.5">{err.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 p-4 rounded-xl bg-[var(--color-surface-muted)] border border-[var(--color-line)]">
|
||||||
|
<p class="text-sm font-semibold text-[var(--color-ink-muted)] mb-1">Error body</p>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)]">Compilation failures (<code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-1.5 py-0.5 rounded">422</code>) return a JSON body with an <code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-1.5 py-0.5 rounded">error</code> summary and a <code class="font-mono text-xs bg-[var(--color-surface-sunken)] 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>
|
||||||
@@ -2,19 +2,15 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { userStore } from '$lib/ts/auth';
|
import { userStore } from '$lib/ts/auth';
|
||||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
|
||||||
import Navbar from '$lib/components/dashboard/Navbar.svelte';
|
import Navbar from '$lib/components/dashboard/Navbar.svelte';
|
||||||
import FolderRow from '$lib/components/dashboard/FolderRow.svelte';
|
import FolderRow from '$lib/components/dashboard/FolderRow.svelte';
|
||||||
import DocCard from '$lib/components/dashboard/DocCard.svelte';
|
import DocCard from '$lib/components/dashboard/DocCard.svelte';
|
||||||
import FileCard from '$lib/components/dashboard/FileCard.svelte';
|
import FileCard from '$lib/components/dashboard/FileCard.svelte';
|
||||||
import ShareModal from '$lib/components/ShareModal.svelte';
|
import ShareModal from '$lib/components/ShareModal.svelte';
|
||||||
import DeleteModal from '$lib/components/dashboard/DeleteModal.svelte';
|
import PromptModal from '$lib/components/PromptModal.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import InfoModal from '$lib/components/dashboard/InfoModal.svelte';
|
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 CreateFolderModal from '$lib/components/dashboard/CreateFolderModal.svelte';
|
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
|
||||||
let documents = $state<any[]>([]);
|
let documents = $state<any[]>([]);
|
||||||
@@ -26,6 +22,7 @@
|
|||||||
let newFolderName = $state('');
|
let newFolderName = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let showCreateModal = $state(false);
|
let showCreateModal = $state(false);
|
||||||
|
let showCreateProjectModal = $state(false);
|
||||||
let newDocTitle = $state('');
|
let newDocTitle = $state('');
|
||||||
let showPlusDropdown = $state(false);
|
let showPlusDropdown = $state(false);
|
||||||
let dragOverFolderId = $state<string | null>(null);
|
let dragOverFolderId = $state<string | null>(null);
|
||||||
@@ -161,6 +158,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToBreadcrumb(index: number) {
|
function navigateToBreadcrumb(index: number) {
|
||||||
|
inSharedDrive = false;
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
folderPath = [];
|
folderPath = [];
|
||||||
currentFolderId = null;
|
currentFolderId = null;
|
||||||
@@ -197,6 +195,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCreateProjectModal() {
|
||||||
|
showPlusDropdown = false;
|
||||||
|
showCreateProjectModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(name: string) {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name.trim(), folder_id: currentFolderId || undefined })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const project = await res.json();
|
||||||
|
showCreateProjectModal = false;
|
||||||
|
goto(`/project/${project.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleImportUpload(e: Event) {
|
async function handleImportUpload(e: Event) {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
if (!target.files || target.files.length === 0) return;
|
if (!target.files || target.files.length === 0) return;
|
||||||
@@ -314,6 +333,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 showShareModal = $state(false);
|
||||||
let shareTarget = $state<any>(null);
|
let shareTarget = $state<any>(null);
|
||||||
|
|
||||||
@@ -337,7 +376,6 @@
|
|||||||
try {
|
try {
|
||||||
data = JSON.parse(dataString);
|
data = JSON.parse(dataString);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// For backward compatibility if it's just an id
|
|
||||||
data = { type: 'document', id: dataString };
|
data = { type: 'document', id: dataString };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,29 +419,33 @@
|
|||||||
|
|
||||||
<main class="max-w-7xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 grow block">
|
<main class="max-w-7xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 grow block">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">My Documents</h2>
|
<h2 class="text-3xl font-bold text-[var(--color-ink)] tracking-tight">My Documents</h2>
|
||||||
|
|
||||||
<div class="relative plus-dropdown-container">
|
<div class="relative plus-dropdown-container">
|
||||||
<button onclick={() => showPlusDropdown = !showPlusDropdown} class="flex items-center justify-center text-[var(--theme-text)] bg-[var(--theme-border)] opacity-90 hover:opacity-100 w-10 h-10 rounded-full shadow-md hover:shadow-lg transition-all duration-200 transform hover:-translate-y-0.5 border border-white/10 dark:border-black/20">
|
<button onclick={() => showPlusDropdown = !showPlusDropdown} class="flex items-center justify-center text-white bg-[var(--color-accent)] hover:opacity-90 w-10 h-10 rounded-full shadow-md transition">
|
||||||
<Icon icon="mdi:plus" class="text-2xl" />
|
<Icon icon="mdi:plus" class="text-2xl" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if showPlusDropdown}
|
{#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">
|
<div class="absolute right-0 mt-2 w-48 bg-[var(--color-surface)] rounded-md shadow-xl border border-[var(--color-line)] 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">
|
<button onclick={openCreateModal} class="w-full text-left px-4 py-2 text-sm text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
<Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" />
|
<Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" />
|
||||||
New Document
|
New Document
|
||||||
</button>
|
</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={openCreateProjectModal} class="w-full text-left px-4 py-2 text-sm text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:folder-multiple-plus" class="text-lg text-indigo-500" />
|
||||||
|
New Project
|
||||||
|
</button>
|
||||||
|
<button onclick={openCreateFolderModal} class="w-full text-left px-4 py-2 text-sm text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
<Icon icon="mdi:folder-plus" class="text-lg text-yellow-500" />
|
<Icon icon="mdi:folder-plus" class="text-lg text-yellow-500" />
|
||||||
New Folder
|
New Folder
|
||||||
</button>
|
</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-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2">
|
||||||
<Icon icon="mdi:upload" class="text-lg text-green-500" />
|
<Icon icon="mdi:upload" class="text-lg text-green-500" />
|
||||||
Upload File
|
Upload File
|
||||||
</button>
|
</button>
|
||||||
<div class="h-px bg-gray-100 dark:bg-zinc-700 my-1"></div>
|
<div class="h-px bg-[var(--color-line)] 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}>
|
<button onclick={() => { showPlusDropdown = false; importFileInput?.click(); }} class="w-full text-left px-4 py-2 text-sm text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] flex items-center gap-2" disabled={isImporting}>
|
||||||
{#if isImporting}
|
{#if isImporting}
|
||||||
<Icon icon="mdi:loading" class="text-lg text-purple-500 animate-spin" />
|
<Icon icon="mdi:loading" class="text-lg text-purple-500 animate-spin" />
|
||||||
Importing...
|
Importing...
|
||||||
@@ -420,57 +462,95 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-6 bg-white/50 dark:bg-black/20 p-3 rounded-lg border border-gray-200 dark:border-white/10">
|
<div class="flex items-center gap-2 text-sm text-[var(--color-ink-muted)] mb-6 bg-[var(--color-surface)] p-3 rounded-md border border-[var(--color-line)]">
|
||||||
<button
|
<button
|
||||||
onclick={() => navigateToBreadcrumb(-1)}
|
onclick={() => navigateToBreadcrumb(-1)}
|
||||||
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = -1; }}
|
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = -1; }}
|
||||||
ondragleave={() => dragOverBreadcrumbIndex = null}
|
ondragleave={() => dragOverBreadcrumbIndex = null}
|
||||||
ondrop={(e) => handleDrop(e, null)}
|
ondrop={(e) => handleDrop(e, null)}
|
||||||
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' : ''}">
|
class="hover:text-[var(--color-accent)] font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === -1 ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}">
|
||||||
<Icon icon="mdi:home" class="text-lg inline-block pb-0.5" /> Home
|
<Icon icon="mdi:home" class="text-lg inline-block pb-0.5" /> Home
|
||||||
</button>
|
</button>
|
||||||
|
{#if inSharedDrive}
|
||||||
|
<Icon icon="mdi:chevron-right" class="text-lg text-[var(--color-ink-muted)]" />
|
||||||
|
<span class="font-medium text-[var(--color-accent)] 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}
|
{#each folderPath as folder, index}
|
||||||
<Icon icon="mdi:chevron-right" class="text-lg text-gray-400" />
|
<Icon icon="mdi:chevron-right" class="text-lg text-[var(--color-ink-muted)]" />
|
||||||
<button
|
<button
|
||||||
onclick={() => navigateToBreadcrumb(index)}
|
onclick={() => navigateToBreadcrumb(index)}
|
||||||
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = index; }}
|
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = index; }}
|
||||||
ondragleave={() => dragOverBreadcrumbIndex = null}
|
ondragleave={() => dragOverBreadcrumbIndex = null}
|
||||||
ondrop={(e) => handleDrop(e, folder.id)}
|
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' : ''}">
|
class="hover:text-[var(--color-accent)] font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === index ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}">
|
||||||
{folder.name}
|
{folder.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if inSharedDrive}
|
||||||
|
{#if sharedDocsLoading}
|
||||||
<div class="min-h-[50vh] flex items-center justify-center">
|
<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">
|
<div class="flex flex-col items-center gap-4 text-[var(--color-ink-muted)] 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-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] max-w-md w-full">
|
||||||
|
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)] mb-6">
|
||||||
|
<Icon icon="mdi:folder-account-outline" class="text-4xl" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-[var(--color-ink)] mb-2">No shared documents</h3>
|
||||||
|
<p class="text-[var(--color-ink-muted)]">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-[var(--color-ink-muted)] animate-pulse">
|
||||||
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
|
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
|
||||||
<p class="text-lg font-medium">Loading your workspace...</p>
|
<p class="text-lg font-medium">Loading your workspace...</p>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{:else}
|
||||||
|
{#if currentFolderId === null || folders.length > 0}
|
||||||
{#if folders.length > 0}
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="px-2 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
<div class="px-2 py-3 text-sm font-semibold text-[var(--color-ink-muted)]">Folders</div>
|
||||||
Folders
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<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-[var(--color-surface)] border border-[var(--color-line)] rounded-lg shadow-sm hover:border-[var(--color-accent)] cursor-pointer group transition"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={enterSharedDrive}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && enterSharedDrive()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center w-10 h-10 bg-[var(--color-accent-soft)] rounded-md group-hover:scale-105 transition-transform pointer-events-none shrink-0 mr-3">
|
||||||
|
<Icon icon="mdi:folder-account" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-[var(--color-ink)] text-sm truncate w-full pointer-events-none">Shared with me</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#each folders as folder}
|
{#each folders as folder}
|
||||||
<FolderRow
|
<FolderRow
|
||||||
{folder}
|
{folder}
|
||||||
@@ -485,7 +565,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
{#if documents.length > 0 || files.length > 0}
|
{#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">
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
{#each documents as doc}
|
{#each documents as doc}
|
||||||
@@ -499,16 +578,27 @@
|
|||||||
{deleteDoc}
|
{deleteDoc}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each files as file}
|
{#each files as file}
|
||||||
<FileCard {file} {deleteFile} />
|
<FileCard {file} {deleteFile} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if documents.length === 0 && folders.length === 0 && files.length === 0}
|
{#if documents.length === 0 && files.length === 0 && currentFolderId !== null && folders.length === 0}
|
||||||
<div class="min-h-[50vh] flex items-center justify-center">
|
<div class="min-h-[30vh] flex items-center justify-center">
|
||||||
<p class="text-gray-500 dark:text-gray-400">This folder is empty.</p>
|
<p class="text-[var(--color-ink-muted)]">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-[var(--color-accent-soft)] text-[var(--color-accent)] mb-4">
|
||||||
|
<Icon icon="mdi:file-document-outline" class="text-3xl" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-[var(--color-ink)] mb-1">No documents yet</h3>
|
||||||
|
<p class="text-[var(--color-ink-muted)] mb-6 text-sm">Create your first Typst document to get started.</p>
|
||||||
|
<button onclick={openCreateModal} class="inline-flex items-center gap-2 bg-[var(--color-accent)] hover:opacity-90 text-white px-5 py-2.5 rounded-md shadow-sm text-sm font-medium transition">
|
||||||
|
<Icon icon="mdi:plus" class="text-lg" />
|
||||||
|
Create Document
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -519,7 +609,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showDeleteModal && deleteTarget}
|
{#if showDeleteModal && deleteTarget}
|
||||||
<DeleteModal {deleteTarget} {confirmDelete} onClose={() => showDeleteModal = false} />
|
<ConfirmModal
|
||||||
|
title={`Delete ${deleteTarget.type}`}
|
||||||
|
message={`Are you sure you want to delete '${deleteTarget.name}'? ${deleteTarget.type === 'folder' ? 'This will also delete all of its contents. ' : ''}This action cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onconfirm={confirmDelete}
|
||||||
|
onclose={() => showDeleteModal = false}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showInfoModal && selectedInfo}
|
{#if showInfoModal && selectedInfo}
|
||||||
@@ -527,15 +623,51 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showRenameModal}
|
{#if showRenameModal}
|
||||||
<RenameModal initialTitle={renameTitle} {handleRename} onClose={() => showRenameModal = false} />
|
<PromptModal
|
||||||
|
title="Rename"
|
||||||
|
label="New name"
|
||||||
|
icon="ph:pencil-simple"
|
||||||
|
value={renameTitle}
|
||||||
|
confirmLabel="Save"
|
||||||
|
onsubmit={handleRename}
|
||||||
|
onclose={() => showRenameModal = false}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showCreateModal}
|
{#if showCreateModal}
|
||||||
<CreateDocModal {createDoc} onClose={() => showCreateModal = false} />
|
<PromptModal
|
||||||
|
title="Create document"
|
||||||
|
label="Document title"
|
||||||
|
icon="ph:file-plus"
|
||||||
|
value="Untitled Document"
|
||||||
|
confirmLabel="Create"
|
||||||
|
onsubmit={createDoc}
|
||||||
|
onclose={() => showCreateModal = false}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCreateProjectModal}
|
||||||
|
<PromptModal
|
||||||
|
title="Create project"
|
||||||
|
label="Project name"
|
||||||
|
icon="ph:folder-star"
|
||||||
|
value="Untitled Project"
|
||||||
|
confirmLabel="Create"
|
||||||
|
onsubmit={createProject}
|
||||||
|
onclose={() => showCreateProjectModal = false}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showCreateFolderModal}
|
{#if showCreateFolderModal}
|
||||||
<CreateFolderModal {createFolder} onClose={() => showCreateFolderModal = false} />
|
<PromptModal
|
||||||
|
title="Create folder"
|
||||||
|
label="Folder name"
|
||||||
|
icon="ph:folder-plus"
|
||||||
|
value="New Folder"
|
||||||
|
confirmLabel="Create"
|
||||||
|
onsubmit={createFolder}
|
||||||
|
onclose={() => showCreateFolderModal = false}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
let svgs = $state<string[]>([]);
|
let svgs = $state<string[]>([]);
|
||||||
let errors = $state<Diagnostic[]>([]);
|
let errors = $state<Diagnostic[]>([]);
|
||||||
let timeoutId: number | undefined;
|
let timeoutId: number | undefined;
|
||||||
|
let lastCompiledContent: string | null = null;
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
let documentTitle = $state('Untitled Document');
|
let documentTitle = $state('Untitled Document');
|
||||||
let isViewer = $state(false);
|
let isViewer = $state(false);
|
||||||
@@ -24,7 +25,6 @@
|
|||||||
const view = $editorViewStore;
|
const view = $editorViewStore;
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
|
|
||||||
// Ensure context menu only triggers on editor
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (!target.closest('.cm-editor') && !target.closest('.cm-content')) return;
|
if (!target.closest('.cm-editor') && !target.closest('.cm-content')) return;
|
||||||
|
|
||||||
@@ -55,6 +55,8 @@
|
|||||||
function triggerCompile() {
|
function triggerCompile() {
|
||||||
if (!text || !$previewOpenStore) return;
|
if (!text || !$previewOpenStore) return;
|
||||||
const content = text.toString();
|
const content = text.toString();
|
||||||
|
if (content === lastCompiledContent) return;
|
||||||
|
lastCompiledContent = content;
|
||||||
const docId = $page.params.id;
|
const docId = $page.params.id;
|
||||||
compileTypst(content, docId)
|
compileTypst(content, docId)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error('Compilation fetch failed', e);
|
console.error('Compilation fetch failed', e);
|
||||||
|
lastCompiledContent = null;
|
||||||
errors = [{ message: 'Network or Server Error compiling document.', severity: 'error' }];
|
errors = [{ message: 'Network or Server Error compiling document.', severity: 'error' }];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -107,8 +110,6 @@
|
|||||||
timeoutId = window.setTimeout(triggerCompile, 500);
|
timeoutId = window.setTimeout(triggerCompile, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
triggerCompile();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
cleanupYjs();
|
cleanupYjs();
|
||||||
@@ -130,7 +131,7 @@
|
|||||||
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative" oncontextmenu={handleContextMenu}>
|
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative" oncontextmenu={handleContextMenu}>
|
||||||
|
|
||||||
{#if !isViewer}
|
{#if !isViewer}
|
||||||
<div class="flex flex-col relative min-h-[50%] md:min-h-0 bg-transparent z-10 shadow-[1px_0_10px_rgba(0,0,0,0.05)] dark:shadow-[1px_0_10px_rgba(0,0,0,0.2)] {$previewOpenStore ? 'w-full md:w-1/2 border-r border-gray-200 dark:border-white/10' : 'w-full'}">
|
<div class="flex flex-col relative min-h-[50%] md:min-h-0 bg-transparent z-10 {$previewOpenStore ? 'w-full md:w-1/2 border-r border-[var(--color-line)]' : 'w-full'}">
|
||||||
{#if initialized}
|
{#if initialized}
|
||||||
<Editor />
|
<Editor />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $previewOpenStore || isViewer}
|
{#if $previewOpenStore || isViewer}
|
||||||
<div class="{isViewer ? 'w-full' : 'w-full md:w-1/2'} relative bg-white/50 dark:bg-black/20 min-h-[50%] md:min-h-0 flex flex-col">
|
<div class="{isViewer ? 'w-full' : 'w-full md:w-1/2'} relative bg-[var(--color-surface)] min-h-[50%] md:min-h-0 flex flex-col">
|
||||||
<Preview {svgs} />
|
<Preview {svgs} />
|
||||||
<ErrorBanner {errors} />
|
<ErrorBanner {errors} />
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +149,6 @@
|
|||||||
<DocFooter />
|
<DocFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Context Menu for Editor -->
|
|
||||||
{#if contextMenu.show}
|
{#if contextMenu.show}
|
||||||
<div
|
<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"
|
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"
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||||
<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">
|
<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>
|
<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-[var(--color-ink-muted)]"><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
|
Copy Text
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,65 +51,60 @@
|
|||||||
<meta name="description" content="Sign in to TypstDrive." />
|
<meta name="description" content="Sign in to TypstDrive." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col relative overflow-hidden">
|
<div class="min-h-screen flex flex-col bg-[var(--color-surface-muted)]">
|
||||||
<div class="flex-grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10">
|
<div class="flex-grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8 rounded-xl border border-[var(--color-line)] bg-[var(--color-surface)] p-10 shadow-2xl">
|
||||||
<div class="absolute -top-40 -left-40 w-96 h-96 bg-blue-400/20 dark:bg-blue-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
|
|
||||||
<div class="absolute top-40 -right-40 w-96 h-96 bg-purple-400/20 dark:bg-purple-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
|
|
||||||
<div class="absolute -bottom-40 left-20 w-96 h-96 bg-indigo-400/20 dark:bg-indigo-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
|
|
||||||
|
|
||||||
<div class="max-w-md w-full space-y-8 bg-white/80 dark:bg-black/40 backdrop-blur-xl p-10 rounded-2xl shadow-2xl border border-gray-200/50 dark:border-white/10 relative z-10">
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<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-500 mb-6 mx-auto shadow-sm">
|
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-[var(--color-accent)] mb-6 mx-auto shadow-sm">
|
||||||
<Icon icon="mdi:script-text" class="text-3xl" />
|
<img src="/favicon.png" alt="TypstDrive" class="h-14 w-14" />
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">
|
<h2 class="text-3xl font-extrabold tracking-tight text-[var(--color-ink)]">
|
||||||
Welcome back
|
Welcome back
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 font-medium">
|
<p class="mt-2 text-sm text-[var(--color-ink-muted)] font-medium">
|
||||||
Sign in to your TypstDrive workspace
|
Sign in to your TypstDrive workspace
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="mt-8 space-y-6" onsubmit={login}>
|
<form class="mt-8 space-y-6" onsubmit={login}>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Email Address</label>
|
<label for="email" class="block text-sm font-semibold text-[var(--color-ink-muted)] mb-1.5">Email address</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon icon="mdi:email" class="text-gray-400 dark:text-gray-500" />
|
<Icon icon="mdi:email" class="text-[var(--color-ink-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
<input id="email" name="email" type="email" required bind:value={email} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="user@example.com">
|
<input id="email" name="email" type="email" required bind:value={email} class="block w-full rounded-md border border-[var(--color-line)] pl-10 pr-3 py-2.5 bg-[var(--color-surface)] text-[var(--color-ink)] placeholder-[var(--color-ink-muted)] focus:border-[var(--color-accent)] focus:outline-none sm:text-sm transition-colors" placeholder="user@example.com">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
<label for="password" class="block text-sm font-semibold text-[var(--color-ink-muted)] mb-1.5">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon icon="mdi:lock" class="text-gray-400 dark:text-gray-500" />
|
<Icon icon="mdi:lock" class="text-[var(--color-ink-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
<input id="password" name="password" type="password" required bind:value={password} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="••••••••">
|
<input id="password" name="password" type="password" required bind:value={password} class="block w-full rounded-md border border-[var(--color-line)] pl-10 pr-3 py-2.5 bg-[var(--color-surface)] text-[var(--color-ink)] placeholder-[var(--color-ink-muted)] focus:border-[var(--color-accent)] focus:outline-none sm:text-sm transition-colors" placeholder="••••••••">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if errorMsg}
|
{#if errorMsg}
|
||||||
<div class="flex items-center gap-2 text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg text-sm border border-red-200 dark:border-red-500/20 shadow-sm animate-in slide-in-from-top-1 fade-in duration-200">
|
<div class="flex items-center gap-2 text-[var(--color-danger)] bg-[var(--color-danger)]/10 p-3 rounded-md text-sm border border-[var(--color-danger)]/20">
|
||||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||||
<span class="font-medium">{errorMsg}</span>
|
<span class="font-medium">{errorMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<button type="submit" class="group w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent text-sm font-bold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 focus:ring-blue-500 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
|
<button type="submit" class="group w-full flex justify-center items-center gap-2 py-3 px-4 text-sm font-bold rounded-md text-white bg-[var(--color-accent)] hover:opacity-90 focus:outline-none transition">
|
||||||
Sign In
|
Sign in
|
||||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{#if registrationEnabled}
|
{#if registrationEnabled}
|
||||||
<div class="text-sm text-center mt-6 pt-4 border-t border-gray-200 dark:border-white/10">
|
<div class="text-sm text-center mt-6 pt-4 border-t border-[var(--color-line)]">
|
||||||
<span class="text-gray-500 dark:text-gray-400">New to TypstDrive? </span>
|
<span class="text-[var(--color-ink-muted)]">New to TypstDrive? </span>
|
||||||
<a href="/register" class="font-bold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors hover:underline">
|
<a href="/register" class="font-bold text-[var(--color-accent)] hover:underline transition-colors">
|
||||||
Create an account
|
Create an account
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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-[var(--color-surface-muted)]">
|
||||||
|
<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-[var(--color-ink-muted)] hover:text-[var(--color-ink)] 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-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:package-variant-closed" class="text-purple-500" />
|
||||||
|
Packages
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)] mt-1">
|
||||||
|
Instance-local Typst packages, published from Projects and importable as
|
||||||
|
<code class="font-mono text-xs bg-[var(--color-surface-sunken)] px-1.5 py-0.5 rounded">@typstdrive/<name>:<version></code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-[var(--color-ink-muted)]">Loading…</p>
|
||||||
|
{:else if packages.length === 0}
|
||||||
|
<div class="text-center py-16 text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:package-variant" class="text-5xl mx-auto mb-3 opacity-50" />
|
||||||
|
<p>No packages published yet. Open a Project and use “Publish” to create one.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each packages as pkg (pkg.id)}
|
||||||
|
<div class="bg-[var(--color-surface)] rounded-xl border border-[var(--color-line)] 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-[var(--color-ink)] 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-[var(--color-ink-muted)] mt-1 truncate">{pkg.description}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)] mt-1">by {pkg.owner_name ?? 'unknown'}</p>
|
||||||
|
<pre class="mt-2 text-xs font-mono bg-[var(--color-surface-muted)] border border-[var(--color-line)] 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-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] 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-[var(--color-ink-muted)] hover:text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 flex items-center gap-1">
|
||||||
|
<Icon icon="mdi:trash-can-outline" class="text-sm" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<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/project/FileTree.svelte';
|
||||||
|
import ProjectToolbar from '$lib/components/project/ProjectToolbar.svelte';
|
||||||
|
import PublishPackageModal from '$lib/components/PublishPackageModal.svelte';
|
||||||
|
import { compileProject } from '$lib/ts/typst-api';
|
||||||
|
import type { Diagnostic } from '$lib/ts/typst-api';
|
||||||
|
import { editorErrors, documentStatsStore, previewOpenStore, editorViewStore } from '$lib/ts/store';
|
||||||
|
import { setProject, openFile, getOpenFile, closeFile, renameOpenFile, getAllText, cleanupProject } from '$lib/ts/yjs-project';
|
||||||
|
|
||||||
|
interface ProjectFile {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = $page.params.id as string;
|
||||||
|
|
||||||
|
let projectName = $state('Project');
|
||||||
|
let entrypoint = $state('main.typ');
|
||||||
|
let role = $state('owner');
|
||||||
|
let files = $state<ProjectFile[]>([]);
|
||||||
|
let activeFileId = $state('');
|
||||||
|
let svgs = $state<string[]>([]);
|
||||||
|
let errors = $state<Diagnostic[]>([]);
|
||||||
|
let showPublish = $state(false);
|
||||||
|
let ready = $state(false);
|
||||||
|
let timeoutId: number | undefined;
|
||||||
|
let lastCompiledSources: string | null = null;
|
||||||
|
|
||||||
|
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 recompileAfterFileChange() {
|
||||||
|
lastCompiledSources = null;
|
||||||
|
scheduleCompile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerCompile() {
|
||||||
|
if (!$previewOpenStore) return;
|
||||||
|
const sources = getAllText();
|
||||||
|
const fingerprint = JSON.stringify(sources);
|
||||||
|
if (fingerprint === lastCompiledSources) return;
|
||||||
|
lastCompiledSources = fingerprint;
|
||||||
|
compileProject(projectId, sources)
|
||||||
|
.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(() => {
|
||||||
|
lastCompiledSources = null;
|
||||||
|
errors = [{ message: 'Network or server error compiling project.', severity: 'error' }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/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: ProjectFile) {
|
||||||
|
if (file.kind !== 'text') return;
|
||||||
|
activeFileId = file.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFile(path: string) {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/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/projects/${projectId}/files/upload`, { method: 'POST', body: form });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadFiles();
|
||||||
|
recompileAfterFileChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameFile(file: ProjectFile, path: string) {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/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);
|
||||||
|
recompileAfterFileChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(file: ProjectFile) {
|
||||||
|
if (!confirm(`Delete ${file.path}?`)) return;
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/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 ?? '';
|
||||||
|
}
|
||||||
|
recompileAfterFileChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEntry(file: ProjectFile) {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ entrypoint: file.path })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
entrypoint = file.path;
|
||||||
|
recompileAfterFileChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
setProject(projectId);
|
||||||
|
fetch(`/api/projects/${projectId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((p) => {
|
||||||
|
if (p && p.name) projectName = p.name;
|
||||||
|
if (p && p.entrypoint) entrypoint = p.entrypoint;
|
||||||
|
if (p && p.effective_role) role = p.effective_role;
|
||||||
|
})
|
||||||
|
.then(loadFiles)
|
||||||
|
.then(() => {
|
||||||
|
ready = true;
|
||||||
|
triggerCompile();
|
||||||
|
})
|
||||||
|
.catch((e) => console.error('Failed to load project', e));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
cleanupProject();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{projectName} - TypstDrive</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<svelte:window onclick={closeContextMenu} />
|
||||||
|
|
||||||
|
<div class="flex flex-col h-screen relative">
|
||||||
|
<ProjectToolbar
|
||||||
|
{projectName}
|
||||||
|
{projectId}
|
||||||
|
{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-[var(--color-line)]' : '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-[var(--color-surface)] 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-[var(--color-ink-muted)]"><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 {projectId} onClose={() => (showPublish = false)} />
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<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 ProjectCard from '$lib/components/dashboard/ProjectCard.svelte';
|
||||||
|
import PromptModal from '$lib/components/PromptModal.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
entrypoint: string;
|
||||||
|
thumbnail_svg?: string;
|
||||||
|
updated_at: string;
|
||||||
|
effective_role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let projects = $state<Project[]>([]);
|
||||||
|
let shared = $state<Project[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let showCreate = $state(false);
|
||||||
|
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 infoProject = $state<Project | null>(null);
|
||||||
|
let deleteTarget = $state<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
function setActiveMenu(id: string | null) { activeMenu = id; }
|
||||||
|
function openInfo(project: Project) { activeMenu = null; infoProject = project; showInfo = true; }
|
||||||
|
function openRename(id: string, name: string) { activeMenu = null; renameId = id; renameName = name; showRename = true; }
|
||||||
|
|
||||||
|
async function submitRename(name: string) {
|
||||||
|
const res = await fetch(`/api/projects/${renameId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
projects = projects.map((p) => (p.id === renameId ? { ...p, name } : p));
|
||||||
|
}
|
||||||
|
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/projects').then((r) => (r.ok ? r.json() : [])),
|
||||||
|
fetch('/api/projects/shared').then((r) => (r.ok ? r.json() : []))
|
||||||
|
]);
|
||||||
|
projects = own;
|
||||||
|
shared = sh;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(name: string) {
|
||||||
|
creating = true;
|
||||||
|
const res = await fetch('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
creating = false;
|
||||||
|
if (res.ok) {
|
||||||
|
const project = await res.json();
|
||||||
|
goto(`/project/${project.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(id: string, name: string) {
|
||||||
|
activeMenu = null;
|
||||||
|
deleteTarget = { id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteProject() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const res = await fetch(`/api/projects/${deleteTarget.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) projects = projects.filter((p) => p.id !== deleteTarget!.id);
|
||||||
|
deleteTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Projects - TypstDrive</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleWindowClick} />
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-[var(--color-surface-muted)]">
|
||||||
|
<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-[var(--color-ink-muted)] hover:text-[var(--color-ink)] 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-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:folder-multiple-outline" class="text-[var(--color-accent)]" />
|
||||||
|
Projects
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)] mt-1">Multi-file Typst workspaces with their own <code class="font-mono text-xs">typst.toml</code>.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick={() => (showCreate = true)} class="px-4 py-2 text-sm rounded-md bg-[var(--color-accent)] text-white hover:opacity-90 transition flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:plus" class="text-lg" /> New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-[var(--color-ink-muted)]">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
{#if projects.length === 0}
|
||||||
|
<div class="text-center py-16 text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:folder-multiple-outline" class="text-5xl mx-auto mb-3 opacity-50" />
|
||||||
|
<p>No projects 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 projects as project (project.id)}
|
||||||
|
<ProjectCard
|
||||||
|
{project}
|
||||||
|
{activeMenu}
|
||||||
|
{setActiveMenu}
|
||||||
|
{openInfo}
|
||||||
|
{openRename}
|
||||||
|
deleteProject={requestDelete}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if shared.length > 0}
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--color-ink)] mt-10 mb-4 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:account-group-outline" class="text-[var(--color-accent)]" /> Shared with me
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{#each shared as project (project.id)}
|
||||||
|
<button onclick={() => goto(`/project/${project.id}`)} class="text-left bg-[var(--color-surface)] rounded-lg border border-[var(--color-line)] overflow-hidden hover:border-[var(--color-accent)] transition">
|
||||||
|
<div class="h-32 bg-[var(--color-surface-muted)] flex items-center justify-center overflow-hidden border-b border-[var(--color-line)]">
|
||||||
|
{#if project.thumbnail_svg}
|
||||||
|
{@html project.thumbnail_svg}
|
||||||
|
{:else}
|
||||||
|
<Icon icon="mdi:folder-multiple-outline" class="text-4xl text-[var(--color-ink-muted)]" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="font-medium text-[var(--color-ink)] truncate">{project.name}</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)] mt-0.5">{project.effective_role}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<PromptModal
|
||||||
|
title="New project"
|
||||||
|
label="Project name"
|
||||||
|
icon="ph:folder-star"
|
||||||
|
placeholder="Untitled Project"
|
||||||
|
confirmLabel="Create"
|
||||||
|
onsubmit={create}
|
||||||
|
onclose={() => (showCreate = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showRename}
|
||||||
|
<PromptModal
|
||||||
|
title="Rename project"
|
||||||
|
label="Project name"
|
||||||
|
icon="ph:pencil-simple"
|
||||||
|
value={renameName}
|
||||||
|
confirmLabel="Save"
|
||||||
|
onsubmit={submitRename}
|
||||||
|
onclose={() => (showRename = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showInfo && infoProject}
|
||||||
|
<Modal title={infoProject.name} icon="ph:folder-star" onclose={() => (showInfo = false)}>
|
||||||
|
<div class="flex flex-col gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 font-medium text-[var(--color-ink-muted)]">Entrypoint</p>
|
||||||
|
<p class="font-mono text-sm text-[var(--color-ink)]">{infoProject.entrypoint}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 font-medium text-[var(--color-ink-muted)]">Last modified</p>
|
||||||
|
<p class="text-sm text-[var(--color-ink)]">{new Date(infoProject.updated_at.endsWith('Z') ? infoProject.updated_at : infoProject.updated_at + 'Z').toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button
|
||||||
|
onclick={() => (showInfo = false)}
|
||||||
|
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if deleteTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete project"
|
||||||
|
message={`'${deleteTarget.name}' will be permanently deleted. This cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onconfirm={confirmDeleteProject}
|
||||||
|
onclose={() => (deleteTarget = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto login after successful registration using email
|
|
||||||
const loginRes = await fetch('/api/auth/login', {
|
const loginRes = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -63,31 +62,26 @@
|
|||||||
<meta name="description" content="Create a new TypstDrive account." />
|
<meta name="description" content="Create a new TypstDrive account." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col relative overflow-hidden">
|
<div class="min-h-screen flex flex-col bg-[var(--color-surface-muted)]">
|
||||||
<div class="flex-grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10">
|
<div class="flex-grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<!-- Background decorative elements -->
|
<div class="max-w-md w-full space-y-8 rounded-xl border border-[var(--color-line)] bg-[var(--color-surface)] p-10 shadow-2xl">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="max-w-md w-full space-y-8 bg-white/80 dark:bg-black/40 backdrop-blur-xl p-10 rounded-2xl shadow-2xl border border-gray-200/50 dark:border-white/10 relative z-10">
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<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-500 mb-6 mx-auto shadow-sm">
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)] mb-6 mx-auto shadow-sm">
|
||||||
<Icon icon="mdi:account-plus" class="text-3xl" />
|
<Icon icon="mdi:account-plus" class="text-3xl" />
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">
|
<h2 class="text-3xl font-extrabold tracking-tight text-[var(--color-ink)]">
|
||||||
Create an account
|
Create an account
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 font-medium">
|
<p class="mt-2 text-sm text-[var(--color-ink-muted)] font-medium">
|
||||||
Join TypstDrive to start collaborating
|
Join TypstDrive to start collaborating
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if registrationDisabled}
|
{#if registrationDisabled}
|
||||||
<div class="flex flex-col items-center gap-4 py-4">
|
<div class="flex flex-col items-center gap-4 py-4">
|
||||||
<div class="flex items-center gap-3 w-full bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 p-4 rounded-xl border border-amber-200 dark:border-amber-500/20">
|
<div class="flex items-center gap-3 w-full bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 p-4 rounded-md border border-amber-200 dark:border-amber-500/20">
|
||||||
<Icon icon="mdi:lock-outline" class="text-2xl flex-shrink-0" />
|
<Icon icon="mdi:lock-outline" class="text-2xl flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-sm">Registration Disabled</p>
|
<p class="font-semibold text-sm">Registration disabled</p>
|
||||||
<p class="text-xs mt-0.5 text-amber-600 dark:text-amber-500">New account creation has been disabled by the administrator. Please contact your administrator to get an account.</p>
|
<p class="text-xs mt-0.5 text-amber-600 dark:text-amber-500">New account creation has been disabled by the administrator. Please contact your administrator to get an account.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,52 +90,52 @@
|
|||||||
<form class="mt-8 space-y-6" onsubmit={register}>
|
<form class="mt-8 space-y-6" onsubmit={register}>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Username</label>
|
<label for="username" class="block text-sm font-semibold text-[var(--color-ink-muted)] mb-1.5">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon icon="mdi:account" class="text-gray-400 dark:text-gray-500" />
|
<Icon icon="mdi:account" class="text-[var(--color-ink-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
<input id="username" name="username" type="text" required bind:value={username} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="Choose a username">
|
<input id="username" name="username" type="text" required bind:value={username} class="block w-full rounded-md border border-[var(--color-line)] pl-10 pr-3 py-2.5 bg-[var(--color-surface)] text-[var(--color-ink)] placeholder-[var(--color-ink-muted)] focus:border-[var(--color-accent)] focus:outline-none sm:text-sm transition-colors" placeholder="Choose a username">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Email Address</label>
|
<label for="email" class="block text-sm font-semibold text-[var(--color-ink-muted)] mb-1.5">Email address</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon icon="mdi:email" class="text-gray-400 dark:text-gray-500" />
|
<Icon icon="mdi:email" class="text-[var(--color-ink-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
<input id="email" name="email" type="email" required bind:value={email} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="user@example.com">
|
<input id="email" name="email" type="email" required bind:value={email} class="block w-full rounded-md border border-[var(--color-line)] pl-10 pr-3 py-2.5 bg-[var(--color-surface)] text-[var(--color-ink)] placeholder-[var(--color-ink-muted)] focus:border-[var(--color-accent)] focus:outline-none sm:text-sm transition-colors" placeholder="user@example.com">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
<label for="password" class="block text-sm font-semibold text-[var(--color-ink-muted)] mb-1.5">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Icon icon="mdi:lock" class="text-gray-400 dark:text-gray-500" />
|
<Icon icon="mdi:lock" class="text-[var(--color-ink-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
<input id="password" name="password" type="password" required bind:value={password} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="••••••••">
|
<input id="password" name="password" type="password" required bind:value={password} class="block w-full rounded-md border border-[var(--color-line)] pl-10 pr-3 py-2.5 bg-[var(--color-surface)] text-[var(--color-ink)] placeholder-[var(--color-ink-muted)] focus:border-[var(--color-accent)] focus:outline-none sm:text-sm transition-colors" placeholder="••••••••">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if errorMsg}
|
{#if errorMsg}
|
||||||
<div class="flex items-center gap-2 text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg text-sm border border-red-200 dark:border-red-500/20 shadow-sm animate-in slide-in-from-top-1 fade-in duration-200">
|
<div class="flex items-center gap-2 text-[var(--color-danger)] bg-[var(--color-danger)]/10 p-3 rounded-md text-sm border border-[var(--color-danger)]/20">
|
||||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||||
<span class="font-medium">{errorMsg}</span>
|
<span class="font-medium">{errorMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<button type="submit" class="group w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent text-sm font-bold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 focus:ring-blue-500 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
|
<button type="submit" class="group w-full flex justify-center items-center gap-2 py-3 px-4 text-sm font-bold rounded-md text-white bg-[var(--color-accent)] hover:opacity-90 focus:outline-none transition">
|
||||||
Register
|
Register
|
||||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-sm text-center mt-6 pt-4 border-t border-gray-200 dark:border-white/10">
|
<div class="text-sm text-center mt-6 pt-4 border-t border-[var(--color-line)]">
|
||||||
<span class="text-gray-500 dark:text-gray-400">Already have an account? </span>
|
<span class="text-[var(--color-ink-muted)]">Already have an account? </span>
|
||||||
<a href="/login" class="font-bold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors hover:underline">
|
<a href="/login" class="font-bold text-[var(--color-accent)] hover:underline transition-colors">
|
||||||
Sign in
|
Sign in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { userStore } from '$lib/ts/auth';
|
import { userStore } from '$lib/ts/auth';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||||
import Footer from '$lib/components/Footer.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 = {
|
type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +16,24 @@
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApiKey = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
rate_limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Device = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
connected: boolean;
|
||||||
|
connected_since: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
let activeSection = $state('account');
|
let activeSection = $state('account');
|
||||||
|
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
@@ -37,6 +57,34 @@
|
|||||||
let deletingUserId = $state<string | null>(null);
|
let deletingUserId = $state<string | null>(null);
|
||||||
let confirmDeleteId = $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);
|
||||||
|
|
||||||
|
let devices = $state<Device[]>([]);
|
||||||
|
let devicesLoading = $state(false);
|
||||||
|
let devicesError = $state('');
|
||||||
|
let confirmRevokeDeviceId = $state<string | null>(null);
|
||||||
|
let revokingDeviceId = $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 showCreateForm = $state(false);
|
||||||
let createUsername = $state('');
|
let createUsername = $state('');
|
||||||
let createEmail = $state('');
|
let createEmail = $state('');
|
||||||
@@ -67,6 +115,200 @@
|
|||||||
} catch {}
|
} 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 loadDevices() {
|
||||||
|
devicesLoading = true;
|
||||||
|
devicesError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/devices');
|
||||||
|
if (res.ok) {
|
||||||
|
devices = await res.json();
|
||||||
|
} else {
|
||||||
|
devicesError = 'Failed to load devices.';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
devicesError = 'Network error.';
|
||||||
|
}
|
||||||
|
devicesLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDevice(id: string) {
|
||||||
|
revokingDeviceId = id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/devices/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
devices = devices.filter(d => d.id !== id);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
revokingDeviceId = null;
|
||||||
|
confirmRevokeDeviceId = 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() {
|
async function loadAdminUsers() {
|
||||||
adminLoading = true;
|
adminLoading = true;
|
||||||
adminError = '';
|
adminError = '';
|
||||||
@@ -83,12 +325,30 @@
|
|||||||
adminLoading = false;
|
adminLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (activeSection === 'api-keys') {
|
||||||
|
loadApiKeys();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (activeSection === 'api-keys') {
|
||||||
|
loadUsage(usagePeriod);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeSection === 'admin' && $userStore?.is_admin) {
|
if (activeSection === 'admin' && $userStore?.is_admin) {
|
||||||
loadAdminUsers();
|
loadAdminUsers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (activeSection === 'devices') {
|
||||||
|
loadDevices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function toggleAdmin(user: AdminUser) {
|
async function toggleAdmin(user: AdminUser) {
|
||||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -196,6 +456,8 @@
|
|||||||
{ id: 'account', label: 'Account', icon: 'mdi:account-outline' },
|
{ id: 'account', label: 'Account', icon: 'mdi:account-outline' },
|
||||||
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
|
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
|
||||||
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
|
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
|
||||||
|
{ id: 'api-keys', label: 'API Keys', icon: 'mdi:key-outline' },
|
||||||
|
{ id: 'devices', label: 'Devices', icon: 'mdi:devices' },
|
||||||
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
|
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
@@ -206,27 +468,26 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col">
|
<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">
|
<nav class="bg-[var(--color-surface)] shadow-sm border-b border-[var(--color-line)] 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">
|
<h1 class="text-2xl font-bold text-[var(--color-ink)] flex items-center gap-3">
|
||||||
<Icon icon="mdi:cog" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
<Icon icon="mdi:cog" class="text-[var(--color-accent)] text-3xl" />
|
||||||
Settings
|
Settings
|
||||||
</h1>
|
</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">
|
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] transition-colors bg-[var(--color-surface-muted)] hover:bg-[var(--color-surface-sunken)] px-4 py-2 rounded-md flex items-center gap-2">
|
||||||
<Icon icon="mdi:arrow-left" class="text-lg" />
|
<Icon icon="mdi:arrow-left" class="text-lg" />
|
||||||
Back to Dashboard
|
Back to Dashboard
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</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">
|
<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">
|
<aside class="w-56 flex-shrink-0">
|
||||||
<nav class="sticky top-24 space-y-1">
|
<nav class="sticky top-24 space-y-1">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<button
|
<button
|
||||||
onclick={() => activeSection = item.id}
|
onclick={() => activeSection = item.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 === item.id
|
class="w-full flex items-center gap-3 px-4 py-2.5 rounded-md text-sm font-medium transition-colors {activeSection === item.id
|
||||||
? 'bg-blue-600 text-white shadow-sm'
|
? 'bg-[var(--color-accent)] text-white shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5'}"
|
: 'text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
|
||||||
>
|
>
|
||||||
<Icon icon={item.icon} class="text-lg flex-shrink-0" />
|
<Icon icon={item.icon} class="text-lg flex-shrink-0" />
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -236,8 +497,8 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-white/10">
|
<div class="pt-4 mt-4 border-t border-[var(--color-line)]">
|
||||||
<button onclick={logout} class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all duration-150">
|
<button onclick={logout} class="w-full flex items-center gap-3 px-4 py-2.5 rounded-md text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 transition-colors">
|
||||||
<Icon icon="mdi:logout" class="text-lg flex-shrink-0" />
|
<Icon icon="mdi:logout" class="text-lg flex-shrink-0" />
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
@@ -245,25 +506,23 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<main class="flex-1 min-w-0 space-y-6 pb-16">
|
<main class="flex-1 min-w-0 space-y-6 pb-16">
|
||||||
|
|
||||||
<!-- Account Section -->
|
|
||||||
{#if activeSection === 'account'}
|
{#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="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] overflow-hidden">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8">
|
||||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
<h2 class="text-xl font-bold text-[var(--color-ink)] mb-6 flex items-center gap-2">
|
||||||
<Icon icon="mdi:account-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
<Icon icon="mdi:account-outline" class="text-2xl text-[var(--color-accent)]" />
|
||||||
Account Settings
|
Account Settings
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mb-6">
|
<div class="flex items-center gap-4 mb-6">
|
||||||
<div class="h-14 w-14 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 text-2xl font-bold border border-blue-500/20 flex-shrink-0">
|
<div class="h-14 w-14 rounded-full bg-[var(--color-accent-soft)] flex items-center justify-center text-[var(--color-accent)] text-2xl font-bold flex-shrink-0">
|
||||||
{$userStore?.username?.[0]?.toUpperCase() || '?'}
|
{$userStore?.username?.[0]?.toUpperCase() || '?'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-base font-bold text-gray-900 dark:text-white">{$userStore?.username}</p>
|
<p class="text-base font-bold text-[var(--color-ink)]">{$userStore?.username}</p>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{$userStore?.email}</p>
|
<p class="text-sm text-[var(--color-ink-muted)]">{$userStore?.email}</p>
|
||||||
{#if $userStore?.is_admin}
|
{#if $userStore?.is_admin}
|
||||||
<span class="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 mt-1">
|
<span class="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 mt-1">
|
||||||
<Icon icon="mdi:shield-crown-outline" class="text-sm" />
|
<Icon icon="mdi:shield-crown-outline" class="text-sm" />
|
||||||
@@ -273,27 +532,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-px bg-gray-200 dark:bg-white/10 mb-6"></div>
|
<div class="h-px bg-[var(--color-line)] mb-6"></div>
|
||||||
|
|
||||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-4">Profile</h3>
|
<h3 class="text-sm font-bold text-[var(--color-ink)] mb-4">Profile</h3>
|
||||||
|
|
||||||
{#if profileError}
|
{#if profileError}
|
||||||
<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">{profileError}</div>
|
<div class="bg-[var(--color-danger)]/10 text-[var(--color-danger)] p-3 rounded-md text-sm mb-4">{profileError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if profileSuccess}
|
{#if profileSuccess}
|
||||||
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm mb-4">Profile updated successfully.</div>
|
<div class="bg-[var(--color-success)]/10 text-[var(--color-success)] p-3 rounded-md text-sm mb-4">Profile updated successfully.</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="username-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
<label for="username-input" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">Username</label>
|
||||||
<input id="username-input" type="text" bind:value={username} 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-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
<input id="username-input" type="text" bind:value={username} class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2 focus:border-[var(--color-accent)] focus:outline-none transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
|
<label for="email-input" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">Email address</label>
|
||||||
<input id="email-input" type="email" bind:value={email} 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-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
<input id="email-input" type="email" bind:value={email} class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2 focus:border-[var(--color-accent)] focus:outline-none transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<button onclick={saveProfile} disabled={isSaving || (username === $userStore?.username && email === $userStore?.email)} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2 shadow-sm">
|
<button onclick={saveProfile} disabled={isSaving || (username === $userStore?.username && email === $userStore?.email)} class="bg-[var(--color-accent)] hover:opacity-90 text-white px-5 py-2 rounded-md text-sm font-semibold transition disabled:opacity-50 flex items-center gap-2 shadow-sm">
|
||||||
{#if isSaving}
|
{#if isSaving}
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||||
Saving...
|
Saving...
|
||||||
@@ -304,31 +563,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-px bg-gray-200 dark:bg-white/10 my-6"></div>
|
<div class="h-px bg-[var(--color-line)] my-6"></div>
|
||||||
|
|
||||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-4">Change Password</h3>
|
<h3 class="text-sm font-bold text-[var(--color-ink)] mb-4">Change Password</h3>
|
||||||
|
|
||||||
{#if passwordError}
|
{#if passwordError}
|
||||||
<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">{passwordError}</div>
|
<div class="bg-[var(--color-danger)]/10 text-[var(--color-danger)] p-3 rounded-md text-sm mb-4">{passwordError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if passwordSuccess}
|
{#if passwordSuccess}
|
||||||
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm mb-4">Password changed successfully.</div>
|
<div class="bg-[var(--color-success)]/10 text-[var(--color-success)] p-3 rounded-md text-sm mb-4">Password changed successfully.</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
<label for="current-password" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">Current password</label>
|
||||||
<input id="current-password" type="password" bind:value={currentPassword} 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-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
<input id="current-password" type="password" bind:value={currentPassword} class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2 focus:border-[var(--color-accent)] focus:outline-none transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
<label for="new-password" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">New password</label>
|
||||||
<input id="new-password" type="password" bind:value={newPassword} 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-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
<input id="new-password" type="password" bind:value={newPassword} class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2 focus:border-[var(--color-accent)] focus:outline-none transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
|
<label for="confirm-password" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">Confirm new password</label>
|
||||||
<input id="confirm-password" type="password" bind:value={confirmPassword} 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-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
<input id="confirm-password" type="password" bind:value={confirmPassword} class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2 focus:border-[var(--color-accent)] focus:outline-none transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<button onclick={changePassword} disabled={isSavingPassword || !currentPassword || !newPassword || !confirmPassword} class="bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white px-5 py-2 rounded-lg text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
<button onclick={changePassword} disabled={isSavingPassword || !currentPassword || !newPassword || !confirmPassword} class="bg-[var(--color-surface-sunken)] hover:opacity-90 text-[var(--color-ink)] px-5 py-2 rounded-md text-sm font-semibold transition disabled:opacity-50 flex items-center gap-2">
|
||||||
{#if isSavingPassword}
|
{#if isSavingPassword}
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||||
Updating...
|
Updating...
|
||||||
@@ -342,49 +601,47 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Theme Section -->
|
|
||||||
{#if activeSection === 'theme'}
|
{#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="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] overflow-hidden">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8">
|
||||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
<h2 class="text-xl font-bold text-[var(--color-ink)] mb-2 flex items-center gap-2">
|
||||||
<Icon icon="mdi:palette-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
<Icon icon="mdi:palette-outline" class="text-2xl text-[var(--color-accent)]" />
|
||||||
Theme Settings
|
Theme Settings
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Customize the appearance of your editor and dashboard. These settings are saved to your browser.</p>
|
<p class="text-sm text-[var(--color-ink-muted)] mb-6">Customize the appearance of your editor and dashboard. These settings are saved to your browser.</p>
|
||||||
<ThemePicker />
|
<ThemePicker />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Storage Section -->
|
|
||||||
{#if activeSection === 'storage'}
|
{#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="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] overflow-hidden">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8">
|
||||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
<h2 class="text-xl font-bold text-[var(--color-ink)] mb-6 flex items-center gap-2">
|
||||||
<Icon icon="mdi:harddisk" class="text-2xl text-blue-500 dark:text-blue-400" />
|
<Icon icon="mdi:harddisk" class="text-2xl text-[var(--color-accent)]" />
|
||||||
Storage
|
Storage
|
||||||
</h2>
|
</h2>
|
||||||
<div class="mb-4 flex justify-between items-end">
|
<div class="mb-4 flex justify-between items-end">
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Total Space Used</span>
|
<span class="text-sm font-medium text-[var(--color-ink-muted)]">Total space used</span>
|
||||||
<span class="text-sm font-bold text-gray-900 dark:text-white">
|
<span class="text-sm font-bold text-[var(--color-ink)]">
|
||||||
{storageStats ? formatBytes(storageStats.total_size_bytes) : 'Loading...'}
|
{storageStats ? formatBytes(storageStats.total_size_bytes) : 'Loading...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div class="bg-gray-50 dark:bg-black/30 p-4 rounded-xl border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
<div class="bg-[var(--color-surface-muted)] p-4 rounded-lg border border-[var(--color-line)] flex items-center gap-3">
|
||||||
<div class="w-3 h-3 rounded-full bg-blue-500 flex-shrink-0"></div>
|
<div class="w-3 h-3 rounded-full bg-[var(--color-accent)] flex-shrink-0"></div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 dark:text-gray-400 text-xs">Documents</p>
|
<p class="text-[var(--color-ink-muted)] text-xs">Documents</p>
|
||||||
<p class="font-semibold text-gray-900 dark:text-white">
|
<p class="font-semibold text-[var(--color-ink)]">
|
||||||
{storageStats ? formatBytes(storageStats.documents_size_bytes) : '...'}
|
{storageStats ? formatBytes(storageStats.documents_size_bytes) : '...'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 dark:bg-black/30 p-4 rounded-xl border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
<div class="bg-[var(--color-surface-muted)] p-4 rounded-lg border border-[var(--color-line)] flex items-center gap-3">
|
||||||
<div class="w-3 h-3 rounded-full bg-purple-500 flex-shrink-0"></div>
|
<div class="w-3 h-3 rounded-full bg-purple-500 flex-shrink-0"></div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 dark:text-gray-400 text-xs">Images & Assets</p>
|
<p class="text-[var(--color-ink-muted)] text-xs">Images & assets</p>
|
||||||
<p class="font-semibold text-gray-900 dark:text-white">
|
<p class="font-semibold text-[var(--color-ink)]">
|
||||||
{storageStats ? formatBytes(storageStats.files_size_bytes) : '...'}
|
{storageStats ? formatBytes(storageStats.files_size_bytes) : '...'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,20 +651,312 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Admin Section -->
|
{#if activeSection === 'api-keys'}
|
||||||
|
<div class="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] 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-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:key-outline" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
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-md transition-colors {showCreateKeyForm ? 'bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]' : 'bg-[var(--color-accent)] hover:opacity-90 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-[var(--color-ink-muted)] mb-4">
|
||||||
|
Use API keys to render Typst documents programmatically via <code class="font-mono text-xs bg-[var(--color-surface-sunken)] 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-[var(--color-accent)] hover:underline ml-1">View API docs →</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-6 p-4 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-muted)]">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-muted)]">
|
||||||
|
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-[var(--color-ink-muted)]">
|
||||||
|
({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-[var(--color-accent)] text-white' : 'text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]'}"
|
||||||
|
>{label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-32">
|
||||||
|
{#if usageLoading}
|
||||||
|
<div class="h-full flex items-center justify-center text-[var(--color-ink-muted)] 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-[var(--color-ink-muted)] 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-lg border border-[var(--color-success)]/30 bg-[var(--color-success)]/10">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-[var(--color-success)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:check-circle" class="text-lg" />
|
||||||
|
Key created: {newlyCreatedKey.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-[var(--color-success)] mt-0.5 opacity-90">Copy this key now — it will not be shown again.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick={() => newlyCreatedKey = null} class="text-[var(--color-success)] hover:opacity-70 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-[var(--color-surface)] border border-[var(--color-success)]/30 text-[var(--color-ink)] px-3 py-2 rounded-md 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-md transition-colors {copiedKey ? 'bg-[var(--color-success)] text-white' : 'bg-[var(--color-surface-sunken)] hover:opacity-90 text-[var(--color-ink-muted)]'}"
|
||||||
|
>
|
||||||
|
<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-lg border border-[var(--color-accent)]/30 bg-[var(--color-accent-soft)] space-y-3">
|
||||||
|
<h3 class="text-sm font-bold text-[var(--color-ink)] flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:key-plus" class="text-[var(--color-accent)]" />
|
||||||
|
Create API Key
|
||||||
|
</h3>
|
||||||
|
{#if createKeyError}
|
||||||
|
<div class="bg-[var(--color-danger)]/10 text-[var(--color-danger)] px-3 py-2 rounded-md text-sm">{createKeyError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label for="create-key-name" class="block text-xs font-medium text-[var(--color-ink-muted)] mb-1">Key name</label>
|
||||||
|
<input
|
||||||
|
id="create-key-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
bind:value={createKeyName}
|
||||||
|
placeholder="e.g. My App, CI Pipeline"
|
||||||
|
class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createKeyLoading || !createKeyName.trim()}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-[var(--color-accent)] hover:opacity-90 disabled:opacity-50 text-white text-sm font-semibold rounded-md transition 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-[var(--color-danger)]/10 text-[var(--color-danger)] p-3 rounded-md text-sm mb-4">{apiKeysError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if apiKeysLoading}
|
||||||
|
<div class="flex items-center justify-center py-12 text-[var(--color-ink-muted)]">
|
||||||
|
<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-[var(--color-ink-muted)]">
|
||||||
|
<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-lg border border-[var(--color-line)] bg-[var(--color-surface-muted)]">
|
||||||
|
<div class="h-9 w-9 rounded-full bg-[var(--color-accent-soft)] flex items-center justify-center text-[var(--color-accent)] 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-[var(--color-ink)] truncate">{key.name}</p>
|
||||||
|
<p class="text-xs font-mono text-[var(--color-ink-muted)]">{key.key_prefix}... · {key.rate_limit}/min</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right flex-shrink-0 hidden sm:block">
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)]">Created {formatDate(key.created_at)}</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)]">{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-[var(--color-ink-muted)]">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-[var(--color-surface-sunken)] hover:opacity-90 text-[var(--color-ink-muted)] font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if confirmDeleteKeyId === key.id}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-[var(--color-ink-muted)]">Delete?</span>
|
||||||
|
<button
|
||||||
|
onclick={() => deleteApiKey(key.id)}
|
||||||
|
disabled={deletingKeyId === key.id}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-[var(--color-danger)] hover:opacity-90 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-[var(--color-surface-sunken)] hover:opacity-90 text-[var(--color-ink-muted)] font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => { confirmRegenerateId = key.id; confirmDeleteKeyId = null; }}
|
||||||
|
title="Regenerate key"
|
||||||
|
class="p-1.5 rounded-md text-[var(--color-ink-muted)] 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-md text-[var(--color-ink-muted)] hover:text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:delete-outline" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'devices'}
|
||||||
|
<div class="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] overflow-hidden">
|
||||||
|
<div class="p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-[var(--color-ink)] flex items-center gap-2 mb-2">
|
||||||
|
<Icon icon="mdi:devices" class="text-2xl text-[var(--color-accent)]" />
|
||||||
|
Devices
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-[var(--color-ink-muted)] mb-6">
|
||||||
|
Typst Desktop apps signed in to your account. A device stays connected while it is running with live sync; revoke a device to sign it out immediately.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if devicesError}
|
||||||
|
<div class="bg-[var(--color-danger)]/10 text-[var(--color-danger)] p-3 rounded-md text-sm mb-4">{devicesError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if devicesLoading}
|
||||||
|
<div class="flex items-center justify-center py-12 text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:loading" class="animate-spin text-2xl mr-2" />
|
||||||
|
Loading devices...
|
||||||
|
</div>
|
||||||
|
{:else if devices.length === 0}
|
||||||
|
<div class="text-center py-12 text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="mdi:devices" class="text-4xl mb-2 opacity-40" />
|
||||||
|
<p class="text-sm">No devices signed in yet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each devices as device (device.id)}
|
||||||
|
<div class="flex items-center gap-4 px-4 py-3 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-muted)]">
|
||||||
|
<div class="h-9 w-9 rounded-full bg-[var(--color-accent-soft)] flex items-center justify-center text-[var(--color-accent)] flex-shrink-0">
|
||||||
|
<Icon icon="mdi:laptop" class="text-lg" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-[var(--color-ink)] truncate flex items-center gap-2">
|
||||||
|
{device.name}
|
||||||
|
{#if device.connected}
|
||||||
|
<span class="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-success)]">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-success)]"></span>
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)]">{device.last_used_at ? `Last used ${formatDate(device.last_used_at)}` : 'Never used'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right flex-shrink-0 hidden sm:block">
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)]">Added {formatDate(device.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{#if confirmRevokeDeviceId === device.id}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-[var(--color-ink-muted)]">Revoke?</span>
|
||||||
|
<button
|
||||||
|
onclick={() => revokeDevice(device.id)}
|
||||||
|
disabled={revokingDeviceId === device.id}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-[var(--color-danger)] hover:opacity-90 text-white font-semibold transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{revokingDeviceId === device.id ? '...' : 'Yes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => confirmRevokeDeviceId = null}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-[var(--color-surface-sunken)] hover:opacity-90 text-[var(--color-ink-muted)] font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => confirmRevokeDeviceId = device.id}
|
||||||
|
title="Revoke device"
|
||||||
|
class="p-1.5 rounded-md text-[var(--color-ink-muted)] hover:text-[var(--color-danger)] hover:bg-[var(--color-danger)]/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}
|
{#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="bg-[var(--color-surface)] rounded-xl shadow-sm border border-[var(--color-line)] overflow-hidden">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
<h2 class="text-xl font-bold text-[var(--color-ink)] flex items-center gap-2">
|
||||||
<Icon icon="mdi:shield-crown-outline" class="text-2xl text-amber-500 dark:text-amber-400" />
|
<Icon icon="mdi:shield-crown-outline" class="text-2xl text-amber-500 dark:text-amber-400" />
|
||||||
User Management
|
User Management
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">{adminUsers.length} user{adminUsers.length !== 1 ? 's' : ''}</span>
|
<span class="text-sm text-[var(--color-ink-muted)]">{adminUsers.length} user{adminUsers.length !== 1 ? 's' : ''}</span>
|
||||||
<button
|
<button
|
||||||
onclick={() => { showCreateForm = !showCreateForm; createError = ''; }}
|
onclick={() => { showCreateForm = !showCreateForm; createError = ''; }}
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-semibold rounded-lg transition-colors {showCreateForm ? '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'}"
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-semibold rounded-md transition-colors {showCreateForm ? 'bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]' : 'bg-[var(--color-accent)] hover:opacity-90 text-white shadow-sm'}"
|
||||||
>
|
>
|
||||||
<Icon icon={showCreateForm ? 'mdi:close' : 'mdi:account-plus-outline'} class="text-base" />
|
<Icon icon={showCreateForm ? 'mdi:close' : 'mdi:account-plus-outline'} class="text-base" />
|
||||||
{showCreateForm ? 'Cancel' : 'New User'}
|
{showCreateForm ? 'Cancel' : 'New User'}
|
||||||
@@ -416,52 +965,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCreateForm}
|
{#if showCreateForm}
|
||||||
<form onsubmit={createUser} 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">
|
<form onsubmit={createUser} class="mb-6 p-4 rounded-lg border border-[var(--color-accent)]/30 bg-[var(--color-accent-soft)] space-y-3">
|
||||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
<h3 class="text-sm font-bold text-[var(--color-ink)] mb-3 flex items-center gap-2">
|
||||||
<Icon icon="mdi:account-plus-outline" class="text-blue-500" />
|
<Icon icon="mdi:account-plus-outline" class="text-[var(--color-accent)]" />
|
||||||
Create New User
|
Create New User
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{#if createError}
|
{#if createError}
|
||||||
<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">{createError}</div>
|
<div class="bg-[var(--color-danger)]/10 text-[var(--color-danger)] px-3 py-2 rounded-md text-sm">{createError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
<label for="create-username" class="block text-xs font-medium text-[var(--color-ink-muted)] mb-1">Username</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-username"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
bind:value={createUsername}
|
bind:value={createUsername}
|
||||||
placeholder="username"
|
placeholder="username"
|
||||||
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"
|
class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
<label for="create-email" class="block text-xs font-medium text-[var(--color-ink-muted)] mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
bind:value={createEmail}
|
bind:value={createEmail}
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
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"
|
class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Temporary Password</label>
|
<label for="create-password" class="block text-xs font-medium text-[var(--color-ink-muted)] mb-1">Temporary password</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-password"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
bind:value={createPassword}
|
bind:value={createPassword}
|
||||||
placeholder="Set a password the user can change later"
|
placeholder="Set a password the user can change later"
|
||||||
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 font-mono"
|
class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none transition-colors font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between pt-1">
|
<div class="flex items-center justify-between pt-1">
|
||||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||||
<input type="checkbox" bind:checked={createIsAdmin} class="w-4 h-4 rounded accent-amber-500" />
|
<input type="checkbox" bind:checked={createIsAdmin} class="w-4 h-4 rounded accent-amber-500" />
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
|
<span class="text-sm text-[var(--color-ink-muted)] flex items-center gap-1">
|
||||||
<Icon icon="mdi:shield-crown-outline" class="text-amber-500 text-base" />
|
<Icon icon="mdi:shield-crown-outline" class="text-amber-500 text-base" />
|
||||||
Grant admin privileges
|
Grant admin privileges
|
||||||
</span>
|
</span>
|
||||||
@@ -469,7 +1021,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createLoading}
|
disabled={createLoading}
|
||||||
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"
|
class="flex items-center gap-2 px-4 py-2 bg-[var(--color-accent)] hover:opacity-90 disabled:opacity-50 text-white text-sm font-semibold rounded-md transition shadow-sm"
|
||||||
>
|
>
|
||||||
{#if createLoading}
|
{#if createLoading}
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-base" />
|
<Icon icon="mdi:loading" class="animate-spin text-base" />
|
||||||
@@ -484,56 +1036,56 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if adminError}
|
{#if adminError}
|
||||||
<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">{adminError}</div>
|
<div class="bg-[var(--color-danger)]/10 text-[var(--color-danger)] p-3 rounded-md text-sm mb-4">{adminError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if adminLoading}
|
{#if adminLoading}
|
||||||
<div class="flex items-center justify-center py-12 text-gray-400 dark:text-gray-500">
|
<div class="flex items-center justify-center py-12 text-[var(--color-ink-muted)]">
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-2xl mr-2" />
|
<Icon icon="mdi:loading" class="animate-spin text-2xl mr-2" />
|
||||||
Loading users...
|
Loading users...
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each adminUsers as user (user.id)}
|
{#each adminUsers as user (user.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 group">
|
<div class="flex items-center gap-4 px-4 py-3 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-muted)] group">
|
||||||
<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 font-bold text-sm flex-shrink-0">
|
<div class="h-9 w-9 rounded-full bg-[var(--color-accent-soft)] flex items-center justify-center text-[var(--color-accent)] font-bold text-sm flex-shrink-0">
|
||||||
{user.username[0].toUpperCase()}
|
{user.username[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">{user.username}</p>
|
<p class="text-sm font-semibold text-[var(--color-ink)] truncate">{user.username}</p>
|
||||||
{#if user.is_admin}
|
{#if user.is_admin}
|
||||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded-md bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 flex-shrink-0">Admin</span>
|
<span class="text-xs font-bold px-1.5 py-0.5 rounded-md bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 flex-shrink-0">Admin</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if user.id === $userStore?.id}
|
{#if user.id === $userStore?.id}
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded-md bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 flex-shrink-0">You</span>
|
<span class="text-xs px-1.5 py-0.5 rounded-md bg-[var(--color-accent-soft)] text-[var(--color-accent)] flex-shrink-0">You</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
|
<p class="text-xs text-[var(--color-ink-muted)] truncate">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0 hidden sm:block">{formatDate(user.created_at)}</p>
|
<p class="text-xs text-[var(--color-ink-muted)] flex-shrink-0 hidden sm:block">{formatDate(user.created_at)}</p>
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
{#if user.id !== $userStore?.id}
|
{#if user.id !== $userStore?.id}
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleAdmin(user)}
|
onclick={() => toggleAdmin(user)}
|
||||||
title={user.is_admin ? 'Revoke admin' : 'Grant admin'}
|
title={user.is_admin ? 'Revoke admin' : 'Grant admin'}
|
||||||
class="p-1.5 rounded-lg transition-colors {user.is_admin ? 'text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10' : 'text-gray-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10'}"
|
class="p-1.5 rounded-md transition-colors {user.is_admin ? 'text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10' : 'text-[var(--color-ink-muted)] hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10'}"
|
||||||
>
|
>
|
||||||
<Icon icon={user.is_admin ? 'mdi:shield-crown' : 'mdi:shield-crown-outline'} class="text-lg" />
|
<Icon icon={user.is_admin ? 'mdi:shield-crown' : 'mdi:shield-crown-outline'} class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
{#if confirmDeleteId === user.id}
|
{#if confirmDeleteId === user.id}
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">Delete?</span>
|
<span class="text-xs text-[var(--color-ink-muted)]">Delete?</span>
|
||||||
<button
|
<button
|
||||||
onclick={() => deleteUser(user.id)}
|
onclick={() => deleteUser(user.id)}
|
||||||
disabled={deletingUserId === user.id}
|
disabled={deletingUserId === user.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"
|
class="text-xs px-2 py-1 rounded-md bg-[var(--color-danger)] hover:opacity-90 text-white font-semibold transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{deletingUserId === user.id ? '...' : 'Yes'}
|
{deletingUserId === user.id ? '...' : 'Yes'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => confirmDeleteId = null}
|
onclick={() => confirmDeleteId = 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"
|
class="text-xs px-2 py-1 rounded-md bg-[var(--color-surface-sunken)] hover:opacity-90 text-[var(--color-ink-muted)] font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
No
|
No
|
||||||
</button>
|
</button>
|
||||||
@@ -542,7 +1094,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={() => confirmDeleteId = user.id}
|
onclick={() => confirmDeleteId = user.id}
|
||||||
title="Delete user"
|
title="Delete user"
|
||||||
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"
|
class="p-1.5 rounded-md text-[var(--color-ink-muted)] hover:text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 transition-colors"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:delete-outline" class="text-lg" />
|
<Icon icon="mdi:delete-outline" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,84 +51,84 @@
|
|||||||
<title>Setup - TypstDrive</title>
|
<title>Setup - TypstDrive</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center px-4 py-16">
|
<div class="min-h-screen flex items-center justify-center px-4 py-16 bg-[var(--color-surface-muted)]">
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 text-white mb-4 shadow-lg">
|
<div class="inline-flex items-center justify-center w-24 h-24 rounded-2xl bg-[var(--color-accent)] text-white mb-4 shadow-lg">
|
||||||
<Icon icon="mdi:shield-crown-outline" class="text-3xl" />
|
<img src="/favicon.png" alt="TypstDrive" class="h-14 w-14" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Welcome to TypstDrive</h1>
|
<h1 class="text-3xl font-bold text-[var(--color-ink)]">Welcome to TypstDrive</h1>
|
||||||
<p class="mt-2 text-gray-500 dark:text-gray-400">Create your admin account to get started.</p>
|
<p class="mt-2 text-[var(--color-ink-muted)]">Create your admin account to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-black/20 rounded-2xl shadow-xl border border-gray-200 dark:border-white/10 p-8">
|
<div class="rounded-xl border border-[var(--color-line)] bg-[var(--color-surface)] p-8 shadow-xl">
|
||||||
<div class="flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-sm px-4 py-3 rounded-lg mb-6 border border-blue-200 dark:border-blue-800/50">
|
<div class="flex items-center gap-2 bg-[var(--color-accent-soft)] text-[var(--color-accent)] text-sm px-4 py-3 rounded-md mb-6">
|
||||||
<Icon icon="mdi:information-outline" class="text-lg flex-shrink-0" />
|
<Icon icon="mdi:information-outline" class="text-lg flex-shrink-0" />
|
||||||
<span>This is a one-time setup. The account you create here will have full admin privileges.</span>
|
<span>This is a one-time setup. The account you create here will have full admin privileges.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if errorMsg}
|
{#if errorMsg}
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm mb-5 border border-red-200 dark:border-red-800/50">
|
<div class="bg-[var(--color-danger)]/10 text-[var(--color-danger)] px-4 py-3 rounded-md text-sm mb-5 border border-[var(--color-danger)]/20">
|
||||||
{errorMsg}
|
{errorMsg}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSetup} class="space-y-4">
|
<form onsubmit={handleSetup} class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
<label for="username" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">Username</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
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-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2.5 focus:border-[var(--color-accent)] focus:outline-none transition-colors"
|
||||||
placeholder="admin"
|
placeholder="admin"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
<label for="email" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
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-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2.5 focus:border-[var(--color-accent)] focus:outline-none transition-colors"
|
||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
<label for="password" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
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-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2.5 focus:border-[var(--color-accent)] focus:outline-none transition-colors"
|
||||||
placeholder="Min. 8 characters"
|
placeholder="Min. 8 characters"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
|
<label for="confirm-password" class="block text-sm font-medium text-[var(--color-ink-muted)] mb-1">Confirm password</label>
|
||||||
<input
|
<input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
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-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
class="w-full bg-[var(--color-surface)] border border-[var(--color-line)] text-[var(--color-ink)] rounded-md px-4 py-2.5 focus:border-[var(--color-accent)] focus:outline-none transition-colors"
|
||||||
placeholder="Repeat password"
|
placeholder="Repeat password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold py-2.5 rounded-lg transition-colors mt-2 shadow-sm"
|
class="w-full flex items-center justify-center gap-2 bg-[var(--color-accent)] hover:opacity-90 disabled:opacity-60 text-white font-semibold py-2.5 rounded-md transition mt-2"
|
||||||
>
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||||
Creating account...
|
Creating account...
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="mdi:shield-check-outline" class="text-lg" />
|
<Icon icon="mdi:shield-check-outline" class="text-lg" />
|
||||||
Create Admin Account
|
Create admin account
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 30 KiB |