Initial Commit
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Bun
|
||||
.bun
|
||||
bun.lock
|
||||
|
||||
# Rust
|
||||
target/
|
||||
Cargo.lock
|
||||
|
||||
# Data
|
||||
*.db
|
||||
|
||||
# Editor
|
||||
.vscode
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Build Frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Build Backend
|
||||
FROM rust:1.82-alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache musl-dev sqlite-dev openssl-dev pkgconfig
|
||||
COPY typst/ typst/
|
||||
COPY server/Cargo.* server/
|
||||
COPY server/src server/src
|
||||
WORKDIR /app/server
|
||||
RUN cargo build --release
|
||||
|
||||
# Final Runtime Image
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libgcc sqlite-libs openssl
|
||||
COPY --from=frontend-builder /app/build /app/build
|
||||
COPY --from=backend-builder /app/server/target/release/server /app/server
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["/app/server"]
|
||||
@@ -0,0 +1,91 @@
|
||||
# TypstDrive
|
||||
|
||||
[](https://github.com/your-username/typstdrive)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://kit.svelte.dev/)
|
||||
[](https://tailwindcss.com/)
|
||||
[](https://bun.sh/)
|
||||
[](https://www.sqlite.org/)
|
||||
[](https://www.docker.com/)
|
||||
|
||||
TypstDrive is a real-time collaborative web editor for Typst. With built-in dark mode, multiple themes, and a clean Google Docs-like interface, it makes creating and sharing documents effortless.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-Time Collaboration**: Powered by Yjs and CodeMirror 6, see changes and cursors from other users instantly.
|
||||
- **Instant Preview**: Compile Typst to SVG on the fly with sub-second latency, featuring interactive document zoom controls.
|
||||
- **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, or SVG.
|
||||
- **User Authentication**: Secure accounts and workspaces for all your documents.
|
||||
- **Link Sharing**: Share documents with configurable permissions (Viewer / Editor).
|
||||
- **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents.
|
||||
|
||||
## Fonts & Images
|
||||
|
||||
TypstDrive allows you to upload custom `.ttf` or `.otf` fonts and image files (`.png`, `.jpg`, `.svg`, etc.) to your folders or directly to a document's workspace.
|
||||
|
||||
### Custom Fonts
|
||||
|
||||
When you upload a font file (e.g., `JetBrainsMono-Regular.ttf`), it is automatically made available to the Typst compiler. You can use the font in two ways:
|
||||
|
||||
1. **By Typographic Family Name:** You can use the internal font family name embedded in the file.
|
||||
```typst
|
||||
#set text(font: "JetBrains Mono")
|
||||
```
|
||||
2. **By Filename (Convenience Alias):** You can also use the exact name of the uploaded file (without the extension), which is extremely helpful if you are unsure of the exact typographic family name.
|
||||
```typst
|
||||
#set text(font: "JetBrainsMono-Regular")
|
||||
```
|
||||
|
||||
### Images
|
||||
|
||||
Uploaded images can be referenced natively using the `#image` function in Typst. Simply upload your image file (e.g., `logo.png`) to your dashboard and reference it by its exact filename in your `.typ` document.
|
||||
|
||||
```typst
|
||||
#image("logo.png", width: 50%)
|
||||
```
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
TypstDrive is completely self-hostable. We provide a Docker image that packages both the Rust backend and the SvelteKit frontend.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/your-username/typstdrive.git
|
||||
cd typstdrive
|
||||
```
|
||||
|
||||
2. Start the application:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. Open your browser and navigate to:
|
||||
```
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
### Data Storage
|
||||
|
||||
The SQLite database containing users and documents is stored in the `./data` directory relative to your `docker-compose.yml` file. This is automatically mounted by Docker Compose to ensure your data persists across container restarts.
|
||||
|
||||
## Local Development
|
||||
|
||||
If you'd like to contribute or run TypstDrive without Docker:
|
||||
|
||||
### Frontend
|
||||
1. Install dependencies: `npm install`
|
||||
2. Run the dev server: `npm run dev`
|
||||
|
||||
### Backend
|
||||
1. Navigate to the `server/` directory.
|
||||
2. Build and run: `cargo run`
|
||||
|
||||
Note: The frontend expects the backend to be running on port 3000. During local development via Vite, API calls are proxied automatically.
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
typstdrive:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "typstdrive",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.56.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-typst": "^0.4.0",
|
||||
"y-codemirror.next": "^0.3.5",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.30"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["ws", "multipart", "macros"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "cookie-signed"] }
|
||||
tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
|
||||
tower = "0.5"
|
||||
argon2 = "0.5"
|
||||
futures-util = "0.3"
|
||||
ecow = "0.2"
|
||||
|
||||
typst = { version = "0.14.2", path = "../typst/crates/typst" }
|
||||
typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
|
||||
typst-layout = { path = "../typst/crates/typst-layout" }
|
||||
typst-pdf = { path = "../typst/crates/typst-pdf" }
|
||||
typst-render = { path = "../typst/crates/typst-render" }
|
||||
typst-svg = { path = "../typst/crates/typst-svg" }
|
||||
|
||||
yrs = "0.18.8"
|
||||
yrs-axum = "0.8"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{User, RegisterRequest, LoginRequest, ChangePasswordRequest, UpdateProfileRequest},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<Json<User>, (StatusCode, String)> {
|
||||
if payload.username.is_empty() || payload.password.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Username and password cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(payload.password.as_bytes(), &salt)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let user_id = Uuid::new_v4().to_string();
|
||||
|
||||
let result = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?) RETURNING id, username, password_hash"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.username)
|
||||
.bind(&password_hash)
|
||||
.fetch_one(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(user) => Ok(Json(user)),
|
||||
Err(sqlx::Error::Database(err)) if err.is_unique_violation() => {
|
||||
Err((StatusCode::CONFLICT, "Username already exists".to_string()))
|
||||
}
|
||||
Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<(SignedCookieJar, Json<User>), (StatusCode, String)> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE username = ?")
|
||||
.bind(&payload.username)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => return Err((StatusCode::UNAUTHORIZED, "Invalid username or password".to_string())),
|
||||
};
|
||||
|
||||
let parsed_hash = PasswordHash::new(&user.password_hash)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if Argon2::default().verify_password(payload.password.as_bytes(), &parsed_hash).is_err() {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid username or password".to_string()));
|
||||
}
|
||||
|
||||
let mut cookie = Cookie::new("session_user_id", user.id.clone());
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_same_site(SameSite::Lax);
|
||||
cookie.set_path("/");
|
||||
|
||||
let jar = jar.add(cookie);
|
||||
|
||||
Ok((jar, Json(user)))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<User>, (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()))?;
|
||||
|
||||
if payload.username.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Username cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query("UPDATE users SET username = ? WHERE id = ?")
|
||||
.bind(&payload.username)
|
||||
.bind(&user_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
Ok(Json(user))
|
||||
}
|
||||
Err(sqlx::Error::Database(err)) if err.is_unique_violation() => {
|
||||
Err((StatusCode::CONFLICT, "Username already exists".to_string()))
|
||||
}
|
||||
Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(jar: SignedCookieJar) -> Result<(SignedCookieJar, StatusCode), (StatusCode, String)> {
|
||||
let jar = jar.remove(Cookie::from("session_user_id"));
|
||||
Ok((jar, StatusCode::OK))
|
||||
}
|
||||
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<User>, (StatusCode, String)> {
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string());
|
||||
|
||||
let user_id = match user_id {
|
||||
Some(id) => id,
|
||||
None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())),
|
||||
};
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match user {
|
||||
Some(u) => Ok(Json(u)),
|
||||
None => Err((StatusCode::UNAUTHORIZED, "User not found".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<ChangePasswordRequest>,
|
||||
) -> 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()))?;
|
||||
|
||||
if payload.current_password.is_empty() || payload.new_password.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?")
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||
|
||||
let parsed_hash = PasswordHash::new(&user.password_hash)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if Argon2::default().verify_password(payload.current_password.as_bytes(), &parsed_hash).is_err() {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid current password".to_string()));
|
||||
}
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let new_password_hash = Argon2::default()
|
||||
.hash_password(payload.new_password.as_bytes(), &salt)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(&new_password_hash)
|
||||
.bind(&user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::world::MemoryWorld;
|
||||
use std::collections::HashMap;
|
||||
use typst::diag::{SourceDiagnostic, Warned};
|
||||
use typst_layout::PagedDocument;
|
||||
use typst_pdf::{pdf, PdfOptions};
|
||||
use typst_render::render;
|
||||
|
||||
pub struct TypstCompiler;
|
||||
|
||||
impl TypstCompiler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn compile_svg(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<(Vec<String>, String), Vec<SourceDiagnostic>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
let svgs = doc
|
||||
.pages()
|
||||
.iter()
|
||||
.map(|page| typst_svg::svg(page))
|
||||
.collect();
|
||||
let thumbnail = if let Some(page) = doc.pages().first() {
|
||||
typst_svg::svg(page)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok((svgs, thumbnail))
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => {
|
||||
let diag = errors.into_iter().collect();
|
||||
Err(diag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_pdf(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
let opts = PdfOptions::default();
|
||||
match pdf(&doc, &opts) {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(_) => Err(vec![]),
|
||||
}
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(errors.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_png(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
if let Some(page) = doc.pages().first() {
|
||||
let pixmap = render(page, 2.0);
|
||||
if let Ok(encoded) = pixmap.encode_png() {
|
||||
return Ok(encoded);
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(errors.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
|
||||
pub async fn init_db() -> Pool<Sqlite> {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect("sqlite:typstdrive.db?mode=rwc")
|
||||
.await
|
||||
.expect("Failed to create pool.");
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id),
|
||||
FOREIGN KEY(parent_id) REFERENCES folders(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
folder_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
content BLOB,
|
||||
thumbnail_svg TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id),
|
||||
FOREIGN KEY(folder_id) REFERENCES folders(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
document_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id),
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to initialize database schema");
|
||||
|
||||
|
||||
let _ = sqlx::query("ALTER TABLE documents ADD COLUMN folder_id TEXT REFERENCES folders(id)")
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
|
||||
let _ = sqlx::query("ALTER TABLE documents ADD COLUMN thumbnail_svg TEXT")
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
pool
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Multipart},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::SignedCookieJar;
|
||||
use uuid::Uuid;
|
||||
use yrs::{Doc, ReadTxn, Transact, Text};
|
||||
|
||||
use crate::{
|
||||
models::{Document, CreateDocumentRequest},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ListDocsQuery {
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_documents(
|
||||
axum::extract::Query(query): axum::extract::Query<ListDocsQuery>,
|
||||
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 = if let Some(folder_id) = query.folder_id {
|
||||
sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&folder_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
} else {
|
||||
sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents 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(docs))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn create_document(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateDocumentRequest>,
|
||||
) -> Result<Json<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 doc_id = Uuid::new_v4().to_string();
|
||||
|
||||
let content = {
|
||||
let ydoc = Doc::new();
|
||||
let text = ydoc.get_or_insert_text("typst");
|
||||
let initial_text = payload.content.clone().unwrap_or_else(|| "== New Document".to_string());
|
||||
println!("Creating document with content length: {}", initial_text.len());
|
||||
text.insert(&mut ydoc.transact_mut(), 0, &initial_text);
|
||||
let encoded = ydoc.transact().encode_state_as_update_v1(&yrs::StateVector::default());
|
||||
println!("Encoded Yjs state length: {}", encoded.len());
|
||||
encoded
|
||||
};
|
||||
|
||||
let doc = sqlx::query_as::<_, Document>(
|
||||
"INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES (?, ?, ?, ?, ?) RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at"
|
||||
)
|
||||
.bind(&doc_id)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.folder_id)
|
||||
.bind(&payload.title)
|
||||
.bind(&content)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(doc))
|
||||
}
|
||||
|
||||
pub async fn get_document(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<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 doc = sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents 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()))?;
|
||||
|
||||
match doc {
|
||||
Some(d) => Ok(Json(d)),
|
||||
None => Err((StatusCode::NOT_FOUND, "Document not found".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_document(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<crate::models::UpdateDocumentRequest>,
|
||||
) -> Result<Json<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 mut doc = sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents 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, "Document not found".to_string()))?;
|
||||
|
||||
if let Some(new_title) = payload.title {
|
||||
doc.title = new_title;
|
||||
}
|
||||
if let Some(new_folder_id) = payload.folder_id {
|
||||
if new_folder_id.is_empty() {
|
||||
doc.folder_id = None;
|
||||
} else {
|
||||
doc.folder_id = Some(new_folder_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let doc = sqlx::query_as::<_, Document>(
|
||||
"UPDATE documents SET title = ?, folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at"
|
||||
)
|
||||
.bind(&doc.title)
|
||||
.bind(&doc.folder_id)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(doc))
|
||||
}
|
||||
|
||||
pub async fn delete_document(
|
||||
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 result = sqlx::query("DELETE FROM documents 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, "Document not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn upload_file(
|
||||
State(state): State<AppState>,
|
||||
Path(doc_id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (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 doc_exists = sqlx::query_as::<_, (String, Option<String>)>("SELECT id, folder_id FROM documents WHERE id = ? AND owner_id = ?")
|
||||
.bind(&doc_id)
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if doc_exists.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "Document not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
let (_, folder_id) = doc_exists.unwrap();
|
||||
|
||||
let mut uploaded_filename = String::new();
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||
let file_name = field.file_name().unwrap_or("unnamed").to_string();
|
||||
let content_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 file_id = Uuid::new_v4().to_string();
|
||||
|
||||
sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.bind(&file_id)
|
||||
.bind(&user_id)
|
||||
.bind(&doc_id)
|
||||
.bind(&folder_id)
|
||||
.bind(&file_name)
|
||||
.bind(&content_type)
|
||||
.bind(&data)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
uploaded_filename = file_name;
|
||||
break;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"filename": uploaded_filename})))
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query, Multipart},
|
||||
http::{StatusCode, header},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::SignedCookieJar;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{File},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListFilesQuery {
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_files(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Query(query): Query<ListFilesQuery>,
|
||||
) -> Result<Json<Vec<File>>, (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 files = if let Some(folder_id) = query.folder_id {
|
||||
sqlx::query_as::<_, File>(
|
||||
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = ? AND folder_id = ? ORDER BY name ASC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&folder_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
} else {
|
||||
sqlx::query_as::<_, File>(
|
||||
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = ? AND folder_id IS NULL ORDER BY name ASC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
};
|
||||
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadFileQuery {
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn upload_file_global(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Query(query): Query<UploadFileQuery>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (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 uploaded_files = vec![];
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
|
||||
let file_name = field.file_name().unwrap_or("unnamed").to_string();
|
||||
let content_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 file_id = Uuid::new_v4().to_string();
|
||||
|
||||
sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(&file_id)
|
||||
.bind(&user_id)
|
||||
.bind(&query.folder_id)
|
||||
.bind(&file_name)
|
||||
.bind(&content_type)
|
||||
.bind(&data)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
uploaded_files.push(file_name);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"files": uploaded_files})))
|
||||
}
|
||||
|
||||
pub async fn get_file_data(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<impl IntoResponse, (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 file = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT mime_type, data FROM files 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()))?;
|
||||
|
||||
if let Some((mime_type, data)) = file {
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, mime_type)],
|
||||
data,
|
||||
))
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "File not found".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_file(
|
||||
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 result = sqlx::query("DELETE FROM files 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, "File not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFileRequest {
|
||||
pub name: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_file(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateFileRequest>,
|
||||
) -> Result<Json<File>, (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 file = sqlx::query_as::<_, File>(
|
||||
"SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files 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, "File not found".to_string()))?;
|
||||
|
||||
if let Some(new_name) = payload.name {
|
||||
file.name = new_name;
|
||||
}
|
||||
if let Some(new_folder_id) = payload.folder_id {
|
||||
if new_folder_id.is_empty() {
|
||||
file.folder_id = None;
|
||||
} else {
|
||||
file.folder_id = Some(new_folder_id);
|
||||
}
|
||||
}
|
||||
|
||||
let file = sqlx::query_as::<_, File>(
|
||||
"UPDATE files SET name = ?, folder_id = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, document_id, folder_id, name, mime_type, created_at"
|
||||
)
|
||||
.bind(&file.name)
|
||||
.bind(&file.folder_id)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(file))
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::SignedCookieJar;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{Folder, CreateFolderRequest},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListFoldersQuery {
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_folders(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Query(query): Query<ListFoldersQuery>,
|
||||
) -> Result<Json<Vec<Folder>>, (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 folders = if let Some(parent_id) = query.parent_id {
|
||||
sqlx::query_as::<_, Folder>(
|
||||
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id = ? ORDER BY name ASC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&parent_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
} else {
|
||||
sqlx::query_as::<_, Folder>(
|
||||
"SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id IS NULL ORDER BY name ASC"
|
||||
)
|
||||
.bind(&user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
};
|
||||
|
||||
Ok(Json(folders))
|
||||
}
|
||||
|
||||
pub async fn create_folder(
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> Result<Json<Folder>, (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 folder_id = Uuid::new_v4().to_string();
|
||||
|
||||
let folder = sqlx::query_as::<_, Folder>(
|
||||
"INSERT INTO folders (id, owner_id, parent_id, name) VALUES (?, ?, ?, ?) RETURNING id, owner_id, parent_id, name, created_at"
|
||||
)
|
||||
.bind(&folder_id)
|
||||
.bind(&user_id)
|
||||
.bind(&payload.parent_id)
|
||||
.bind(&payload.name)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(folder))
|
||||
}
|
||||
|
||||
pub async fn delete_folder(
|
||||
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 result = sqlx::query("DELETE FROM folders 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, "Folder not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFolderRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> Result<Json<Folder>, (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 folder = sqlx::query_as::<_, Folder>(
|
||||
"UPDATE folders SET name = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, parent_id, name, created_at"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match folder {
|
||||
Some(f) => Ok(Json(f)),
|
||||
None => Err((StatusCode::NOT_FOUND, "Folder not found".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use yrs_axum::ws::{AxumSink, AxumStream};
|
||||
use yrs_axum::broadcast::BroadcastGroup;
|
||||
use yrs::sync::Awareness;
|
||||
use yrs::{Doc, ReadTxn, Transact, Update};
|
||||
use yrs::updates::decoder::Decode;
|
||||
use futures_util::stream::StreamExt;
|
||||
use crate::AppState;
|
||||
use crate::models::Document;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CompileRequest {
|
||||
pub text: String,
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompileResponse {
|
||||
pub svgs: Option<Vec<String>>,
|
||||
pub errors: Option<Vec<Diagnostic>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Diagnostic {
|
||||
pub message: String,
|
||||
pub severity: String,
|
||||
}
|
||||
|
||||
pub async fn yjs_handler(
|
||||
ws: axum::extract::ws::WebSocketUpgrade,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let mut bcast_map = state.bcast_map.lock().await;
|
||||
let bcast = if let Some(bcast) = bcast_map.get(&id) {
|
||||
bcast.clone()
|
||||
} else {
|
||||
let doc = sqlx::query_as::<_, Document>(
|
||||
"SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let ydoc = Doc::new();
|
||||
|
||||
if let Ok(Some(db_doc)) = doc {
|
||||
if let Some(content) = db_doc.content {
|
||||
if let Ok(update) = Update::decode_v1(&content) {
|
||||
ydoc.transact_mut().apply_update(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let awareness = Arc::new(RwLock::new(Awareness::new(ydoc)));
|
||||
let new_bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 10).await);
|
||||
bcast_map.insert(id.clone(), new_bcast.clone());
|
||||
|
||||
let save_db = state.db.clone();
|
||||
let save_id = id.clone();
|
||||
let save_awareness = awareness.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let doc = save_awareness.read().await;
|
||||
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
|
||||
let _ = sqlx::query("UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(&save_id)
|
||||
.execute(&save_db)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
new_bcast
|
||||
};
|
||||
|
||||
drop(bcast_map);
|
||||
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
let (sink, stream) = socket.split();
|
||||
let sink = Arc::new(Mutex::new(AxumSink(sink)));
|
||||
let stream = AxumStream(stream);
|
||||
let sub = bcast.subscribe(sink, stream);
|
||||
match sub.completed().await {
|
||||
Ok(_) => println!("broadcasting for channel finished successfully"),
|
||||
Err(e) => eprintln!("broadcasting for channel finished abruptly: {}", e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn compile_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CompileRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let mut files_map = std::collections::HashMap::new();
|
||||
if let Some(doc_id) = &payload.document_id {
|
||||
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?").bind(doc_id).fetch_one(&state.db).await {
|
||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
||||
.bind(doc.owner_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
for (name, data) in files {
|
||||
files_map.insert(name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
match compiler.compile_svg(payload.text, files_map) {
|
||||
Ok((svgs, thumbnail)) => {
|
||||
if let Some(doc_id) = &payload.document_id {
|
||||
let _ = sqlx::query("UPDATE documents SET thumbnail_svg = ? WHERE id = ?")
|
||||
.bind(&thumbnail)
|
||||
.bind(doc_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
|
||||
Json(CompileResponse {
|
||||
svgs: Some(svgs),
|
||||
errors: None,
|
||||
})
|
||||
}
|
||||
Err(diags) => {
|
||||
let errors = diags
|
||||
.into_iter()
|
||||
.map(|d| Diagnostic {
|
||||
message: d.message.to_string(),
|
||||
severity: format!("{:?}", d.severity),
|
||||
})
|
||||
.collect();
|
||||
Json(CompileResponse {
|
||||
svgs: None,
|
||||
errors: Some(errors),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn export_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(format): Path<String>,
|
||||
Json(payload): Json<CompileRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let mut files_map = std::collections::HashMap::new();
|
||||
if let Some(doc_id) = &payload.document_id {
|
||||
if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?").bind(doc_id).fetch_one(&state.db).await {
|
||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
||||
.bind(doc.owner_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
for (name, data) in files {
|
||||
files_map.insert(name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
|
||||
match format.as_str() {
|
||||
"pdf" => match compiler.export_pdf(payload.text, files_map.clone()) {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/pdf")],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
"png" => match compiler.export_png(payload.text, files_map.clone()) {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "image/png")],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
"svg" => match compiler.compile_svg(payload.text, files_map.clone()) {
|
||||
Ok((svgs, _)) => {
|
||||
|
||||
|
||||
let mut combined = String::new();
|
||||
for svg in svgs {
|
||||
combined.push_str(&svg);
|
||||
combined.push_str("\n");
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "image/svg+xml")],
|
||||
combined.into_bytes(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
_ => (StatusCode::NOT_FOUND, "Format not supported").into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::{
|
||||
routing::{get, post, put, delete},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::extract::cookie::Key;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
use yrs_axum::broadcast::BroadcastGroup;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod auth;
|
||||
mod compiler;
|
||||
mod db;
|
||||
mod docs;
|
||||
mod folders;
|
||||
mod files;
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod world;
|
||||
|
||||
use compiler::TypstCompiler;
|
||||
use handlers::{compile_handler, export_handler, yjs_handler};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub compiler: Arc<Mutex<TypstCompiler>>,
|
||||
pub bcast_map: Arc<Mutex<HashMap<String, Arc<BroadcastGroup>>>>,
|
||||
pub db: Pool<Sqlite>,
|
||||
pub key: Key,
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> for Key {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "server=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting TypstDrive Server");
|
||||
|
||||
let db = db::init_db().await;
|
||||
|
||||
|
||||
let key = Key::generate();
|
||||
|
||||
let state = AppState {
|
||||
compiler: Arc::new(Mutex::new(TypstCompiler::new())),
|
||||
bcast_map: Arc::new(Mutex::new(HashMap::new())),
|
||||
db,
|
||||
key,
|
||||
};
|
||||
|
||||
let api_routes = Router::new()
|
||||
.route("/compile", post(compile_handler))
|
||||
.route("/export/{format}", post(export_handler))
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me).put(auth::update_profile))
|
||||
.route("/auth/change-password", put(auth::change_password))
|
||||
.route("/folders", get(folders::list_folders).post(folders::create_folder))
|
||||
.route("/folders/{id}", delete(folders::delete_folder).patch(folders::update_folder))
|
||||
.route("/files", get(files::list_files).post(files::upload_file_global))
|
||||
.route("/files/{id}", delete(files::delete_file).patch(files::update_file))
|
||||
.route("/files/{id}/data", get(files::get_file_data))
|
||||
.route("/docs", get(docs::list_documents).post(docs::create_document))
|
||||
.route("/docs/{id}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document))
|
||||
.route("/docs/{id}/files", post(docs::upload_file));
|
||||
|
||||
let yjs_routes = Router::new()
|
||||
.route("/{id}", get(yjs_handler));
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", api_routes.layer(TraceLayer::new_for_http()))
|
||||
.nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http()))
|
||||
.fallback_service(ServeDir::new("../build").fallback(ServeFile::new("../build/index.html")))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||
.await
|
||||
.unwrap();
|
||||
tracing::info!("Server listening on http://0.0.0.0:3000");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Folder {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub name: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct File {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub document_id: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub name: String,
|
||||
pub mime_type: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Document {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub content: Option<Vec<u8>>,
|
||||
pub thumbnail_svg: Option<String>,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateDocumentRequest {
|
||||
pub title: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
pub title: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateFileRequest {
|
||||
pub name: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use chrono::Datelike;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use typst::diag::{FileError, FileResult};
|
||||
use typst::foundations::{Bytes, Datetime, Duration};
|
||||
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
|
||||
use typst::text::{Font, FontBook};
|
||||
use typst::World;
|
||||
use typst::{Library, LibraryExt};
|
||||
use typst_kit::downloader::SystemDownloader;
|
||||
use typst_kit::fonts::FontStore;
|
||||
use typst_kit::packages::SystemPackages;
|
||||
|
||||
pub struct MemoryWorld {
|
||||
library: typst::utils::LazyHash<Library>,
|
||||
main: FileId,
|
||||
source: Source,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
fonts: std::sync::LazyLock<FontStore, Box<dyn Fn() -> FontStore + Send + Sync>>,
|
||||
packages: SystemPackages,
|
||||
}
|
||||
|
||||
impl MemoryWorld {
|
||||
pub fn new(text: String, files: HashMap<String, Vec<u8>>) -> Self {
|
||||
let main = FileId::new(RootedPath::new(
|
||||
VirtualRoot::Project,
|
||||
VirtualPath::new("main.typ").unwrap(),
|
||||
));
|
||||
let source = Source::new(main, text);
|
||||
let files_clone = files.clone();
|
||||
let downloader = SystemDownloader::new("TypstDrive (typst-kit)");
|
||||
let packages = SystemPackages::new(downloader);
|
||||
|
||||
Self {
|
||||
library: typst::utils::LazyHash::new(Library::builder().build()),
|
||||
main,
|
||||
source,
|
||||
fonts: std::sync::LazyLock::new(Box::new(move || {
|
||||
let mut store = FontStore::new();
|
||||
store.extend(typst_kit::fonts::embedded());
|
||||
|
||||
for (name, data) in &files {
|
||||
if name.ends_with(".ttf") || name.ends_with(".otf") {
|
||||
for font in Font::iter(Bytes::new(data.clone())) {
|
||||
let info = font.info().clone();
|
||||
store.push((font.clone(), info.clone()));
|
||||
|
||||
let mut custom_info = info;
|
||||
if let Some(stem) = std::path::Path::new(name).file_stem() {
|
||||
if let Some(stem_str) = stem.to_str() {
|
||||
custom_info.family = stem_str.to_string();
|
||||
store.push((font, custom_info));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store
|
||||
})),
|
||||
files: files_clone,
|
||||
packages,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl World for MemoryWorld {
|
||||
fn library(&self) -> &typst::utils::LazyHash<Library> {
|
||||
&self.library
|
||||
}
|
||||
|
||||
fn book(&self) -> &typst::utils::LazyHash<FontBook> {
|
||||
self.fonts.book()
|
||||
}
|
||||
|
||||
fn main(&self) -> FileId {
|
||||
self.main
|
||||
}
|
||||
|
||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||
if id == self.main {
|
||||
Ok(self.source.clone())
|
||||
} else if let typst::syntax::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 = String::from_utf8(data.to_vec()).map_err(|_| FileError::InvalidUtf8)?;
|
||||
Ok(Source::new(id, text))
|
||||
} else {
|
||||
Err(FileError::NotFound(
|
||||
std::path::Path::new(id.vpath().get_without_slash()).into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
if id == self.main {
|
||||
Ok(Bytes::from_string(self.source.text().to_string()))
|
||||
} else if let typst::syntax::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()) {
|
||||
Ok(Bytes::new(data.clone()))
|
||||
} else {
|
||||
Err(FileError::NotFound(
|
||||
std::path::Path::new(id.vpath().get_without_slash()).into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn font(&self, index: usize) -> Option<Font> {
|
||||
self.fonts.font(index)
|
||||
}
|
||||
|
||||
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
|
||||
let now = chrono::Local::now();
|
||||
let date = if let Some(offset) = offset {
|
||||
let offset = chrono::FixedOffset::east_opt(offset.seconds() as i32)?;
|
||||
now.with_timezone(&offset).date_naive()
|
||||
} else {
|
||||
now.date_naive()
|
||||
};
|
||||
|
||||
Datetime::from_ymd(date.year(), date.month() as u8, date.day() as u8)
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--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';
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { typst, TypstParser, typstHighlight } from 'codemirror-lang-typst';
|
||||
import { Language } from '@codemirror/language';
|
||||
import { yCollab } from 'y-codemirror.next';
|
||||
import { text, provider } from '../ts/yjs-setup';
|
||||
import { getThemeExtension } from '../ts/themes';
|
||||
import { themeStore, darkModeStore, editorViewStore } from '../ts/store';
|
||||
|
||||
let editorContainer: HTMLElement;
|
||||
let view: EditorView;
|
||||
let themeCompartment = new Compartment();
|
||||
let unsubscribeTheme: () => void;
|
||||
let unsubscribeDark: () => void;
|
||||
|
||||
let currentTheme = 'Catppuccin';
|
||||
let isDark = true;
|
||||
let state: EditorState;
|
||||
|
||||
onMount(() => {
|
||||
if (!text || !provider) return;
|
||||
|
||||
themeStore.subscribe(t => { currentTheme = t; })();
|
||||
darkModeStore.subscribe(d => { isDark = d; })();
|
||||
|
||||
const t = typst();
|
||||
const myParser = new (TypstParser as any)(typstHighlight);
|
||||
const myLang = new Language(
|
||||
t.language.data,
|
||||
myParser,
|
||||
[myParser.updateListener()],
|
||||
'typst'
|
||||
);
|
||||
|
||||
state = EditorState.create({
|
||||
doc: text.toString(),
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap] as any),
|
||||
myLang,
|
||||
yCollab(text, provider.awareness),
|
||||
themeCompartment.of(getThemeExtension(currentTheme as any, isDark)),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '14px' },
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
view = new EditorView({
|
||||
state,
|
||||
parent: editorContainer,
|
||||
});
|
||||
|
||||
editorViewStore.set(view);
|
||||
|
||||
unsubscribeTheme = themeStore.subscribe((themeName) => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension(themeName as any, isDark))
|
||||
});
|
||||
currentTheme = themeName;
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribeDark = darkModeStore.subscribe((dark) => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension(currentTheme as any, dark))
|
||||
});
|
||||
isDark = dark;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribeTheme) unsubscribeTheme();
|
||||
if (unsubscribeDark) unsubscribeDark();
|
||||
if (view) {
|
||||
view.destroy();
|
||||
}
|
||||
editorViewStore.set(null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full w-full overflow-hidden focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-500/20" bind:this={editorContainer}></div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Diagnostic } from '../ts/typst-api';
|
||||
|
||||
let { errors = [] } = $props<{ errors: Diagnostic[] }>();
|
||||
</script>
|
||||
|
||||
{#if errors && errors.length > 0}
|
||||
<div class="absolute bottom-4 left-4 right-4 z-50 rounded-lg shadow-2xl bg-red-900/90 text-white p-4 max-h-48 overflow-y-auto backdrop-blur-sm border border-red-500/50">
|
||||
<h3 class="font-bold flex items-center gap-2 mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1.2em" height="1.2em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 17q.425 0 .713-.288T13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17m-1-4h2V7h-2zm1 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>
|
||||
Compilation Errors
|
||||
</h3>
|
||||
<ul class="text-sm font-mono flex flex-col gap-1">
|
||||
{#each errors as error}
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-red-300">[{error.severity}]</span>
|
||||
<span>{error.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let props = $props<{
|
||||
onClose: () => void;
|
||||
onApply: (settings: Record<string, string>, docSettings: Record<string, string>) => void;
|
||||
currentSettings?: Record<string, string>;
|
||||
}>();
|
||||
|
||||
let settings = $derived(props.currentSettings || {});
|
||||
|
||||
|
||||
let paper = $state("");
|
||||
let margin = $state("");
|
||||
let width = $state("");
|
||||
let height = $state("");
|
||||
let flipped = $state(false);
|
||||
let columns = $state(1);
|
||||
let fill = $state("");
|
||||
let numbering = $state("");
|
||||
let header = $state("");
|
||||
let footer = $state("");
|
||||
|
||||
|
||||
let docTitle = $state("");
|
||||
let author = $state("");
|
||||
|
||||
$effect(() => {
|
||||
paper = settings.paper || 'a4';
|
||||
margin = settings.margin || 'auto';
|
||||
width = settings.width || 'auto';
|
||||
height = settings.height || 'auto';
|
||||
flipped = settings.flipped === 'true';
|
||||
columns = parseInt(settings.columns || '1') || 1;
|
||||
fill = settings.fill || 'auto';
|
||||
numbering = settings.numbering || 'none';
|
||||
header = settings.header || 'auto';
|
||||
footer = settings.footer || 'auto';
|
||||
docTitle = settings.docTitle || '';
|
||||
author = settings.author || '';
|
||||
});
|
||||
|
||||
function apply() {
|
||||
const newPageSettings: Record<string, string> = {};
|
||||
if (paper !== 'a4') newPageSettings.paper = `"${paper}"`;
|
||||
if (margin !== 'auto') newPageSettings.margin = margin;
|
||||
if (width !== 'auto') newPageSettings.width = width;
|
||||
if (height !== 'auto') newPageSettings.height = height;
|
||||
if (flipped) newPageSettings.flipped = 'true';
|
||||
if (columns !== 1) newPageSettings.columns = columns.toString();
|
||||
if (fill !== 'auto') newPageSettings.fill = fill;
|
||||
if (numbering !== 'none') newPageSettings.numbering = `"${numbering}"`;
|
||||
if (header !== 'auto') newPageSettings.header = header;
|
||||
if (footer !== 'auto') newPageSettings.footer = footer;
|
||||
|
||||
const newDocSettings: Record<string, string> = {};
|
||||
if (docTitle) newDocSettings.title = `"${docTitle}"`;
|
||||
if (author) newDocSettings.author = `"${author}"`;
|
||||
|
||||
props.onApply(newPageSettings, newDocSettings);
|
||||
props.onClose();
|
||||
}
|
||||
</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={() => props.onClose()} onkeydown={(e) => { if (e.key === "Enter") { props.onClose(); } }}>
|
||||
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 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 justify-between items-center p-5 border-b border-gray-100 dark:border-zinc-800">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white 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="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>
|
||||
|
||||
<div class="p-6 space-y-8 overflow-y-auto flex-1">
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Document Metadata</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="docTitle" class="text-sm font-medium text-gray-700 dark:text-gray-300">PDF Title</label>
|
||||
<input id="docTitle" type="text" bind:value={docTitle} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="My Report" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="author" class="text-sm font-medium text-gray-700 dark:text-gray-300">Author</label>
|
||||
<input id="author" type="text" bind:value={author} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="Jane Doe" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Page Layout</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="paper" class="text-sm font-medium text-gray-700 dark:text-gray-300">Paper Size</label>
|
||||
<select id="paper" bind:value={paper} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a4">A4</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="us-letter">US Letter</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a5">A5</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="presentation-16-9">16:9 Presentation</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="presentation-4-3">4:3 Presentation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="margin" class="text-sm font-medium text-gray-700 dark:text-gray-300">Margin</label>
|
||||
<input id="margin" type="text" bind:value={margin} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or 1in" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="width" class="text-sm font-medium text-gray-700 dark:text-gray-300">Width</label>
|
||||
<input id="width" type="text" bind:value={width} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="height" class="text-sm font-medium text-gray-700 dark:text-gray-300">Height</label>
|
||||
<input id="height" type="text" bind:value={height} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="columns" class="text-sm font-medium text-gray-700 dark:text-gray-300">Columns</label>
|
||||
<input id="columns" type="number" min="1" max="10" bind:value={columns} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="fill" class="text-sm font-medium text-gray-700 dark:text-gray-300">Background Fill</label>
|
||||
<input id="fill" type="text" bind:value={fill} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or rgb(200, 200, 200)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-gray-50 dark:bg-zinc-900 border-gray-300 dark:border-zinc-700" />
|
||||
<label for="flipped" class="text-sm font-medium text-gray-700 dark:text-gray-300">Landscape Orientation (Flipped)</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Headers & Footers</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="numbering" class="text-sm font-medium text-gray-700 dark:text-gray-300">Page Numbering</label>
|
||||
<select id="numbering" bind:value={numbering} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="none">None</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="1">1, 2, 3</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="1/1">1/3, 2/3, 3/3</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a">a, b, c</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="i">i, ii, iii</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="I">I, II, III</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="header" class="text-sm font-medium text-gray-700 dark:text-gray-300">Header Content</label>
|
||||
<input id="header" type="text" bind:value={header} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
||||
</div>
|
||||
<div class="space-y-2 sm:col-span-2">
|
||||
<label for="footer" class="text-sm font-medium text-gray-700 dark:text-gray-300">Footer Content</label>
|
||||
<input id="footer" type="text" bind:value={footer} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="p-5 border-t border-gray-100 dark:border-zinc-800 flex justify-end gap-3 bg-gray-50/50 dark:bg-zinc-900/50">
|
||||
<button onclick={() => props.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-zinc-800 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</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">
|
||||
Apply Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { Diagnostic } from '../ts/typst-api';
|
||||
import { documentZoomStore } from '../ts/store';
|
||||
|
||||
let { svgs = [] } = $props<{ svgs?: string[] }>();
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-auto bg-transparent py-8 px-4 flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-8 transition-transform duration-200" style="transform: scale({$documentZoomStore / 100}); transform-origin: top center;">
|
||||
{#if svgs.length > 0}
|
||||
{#each svgs as svg, i}
|
||||
<div class="preview-container shadow-xl bg-white max-w-full lg:max-w-[95%] w-auto inline-block flex-shrink-0 transition-transform duration-200">
|
||||
{@html svg}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-gray-400 flex flex-col items-center justify-center h-full">
|
||||
<p>Document is empty or compiling...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
:global(.preview-container svg) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { onClose, docId = undefined } = $props<{ onClose: () => void, docId?: string }>();
|
||||
|
||||
let link = $state('');
|
||||
let copied = $state(false);
|
||||
let role = $state('editor');
|
||||
|
||||
onMount(() => {
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
const docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href;
|
||||
|
||||
|
||||
link = `${docUrl}?role=${role}`;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
let docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href.split('?')[0];
|
||||
link = `${docUrl}?role=${role}`;
|
||||
});
|
||||
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(link);
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 2000);
|
||||
}
|
||||
</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 dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="share-dialog-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-100 dark:border-zinc-800">
|
||||
<h2 id="share-dialog-title" class="text-lg font-semibold text-gray-900 dark:text-white">Share 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">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="share-link-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Share Link</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="share-link-input"
|
||||
type="text"
|
||||
readonly
|
||||
value={link}
|
||||
class="flex-1 bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-600 dark:text-gray-400 text-sm rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onclick={copyLink}
|
||||
aria-label="Copy link"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm min-w-[80px]"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<label for="general-access-select" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">General Access</label>
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-zinc-950/50 rounded-lg border border-gray-200 dark:border-zinc-800">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 p-2 rounded-full">
|
||||
<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" class="text-gray-600 dark:text-gray-400"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Anyone with the link</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Can view and collaborate</p>
|
||||
</div>
|
||||
<select id="general-access-select" bind:value={role} class="bg-transparent text-sm font-medium text-gray-700 dark:text-gray-300 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 class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="editor">Editor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-zinc-950/50 border-t border-gray-100 dark:border-zinc-800 flex justify-end">
|
||||
<button onclick={onClose} aria-label="Done" 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-zinc-800 rounded-md transition-colors">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { themeStore, darkModeStore } from '../ts/store';
|
||||
import { themes } from '../ts/themes';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { class: className = '' } = $props();
|
||||
|
||||
const themeOptions = Object.keys(themes).flatMap(themeName => [
|
||||
{ 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>
|
||||
|
||||
<div class="flex items-center gap-2 {className}">
|
||||
<Icon icon={themes[$themeStore]?.icon || 'mdi:palette'} class="text-xl text-gray-500 dark:text-gray-400" />
|
||||
<select
|
||||
value={selectedValue}
|
||||
onchange={handleChange}
|
||||
class="bg-white/50 dark:bg-black/20 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/20 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 hover:border-gray-400 dark:hover:border-white/30 transition-colors outline-none"
|
||||
>
|
||||
{#each themeOptions as opt}
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value={opt.name}>{opt.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -0,0 +1,625 @@
|
||||
<script lang="ts">
|
||||
import { exportTypst } from '../ts/typst-api';
|
||||
import { text } from '../ts/yjs-setup';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore } from '../ts/store';
|
||||
import { themes } from '../ts/themes';
|
||||
import { goto } from '$app/navigation';
|
||||
import ShareModal from './ShareModal.svelte';
|
||||
import PageSettingsModal from './PageSettingsModal.svelte';
|
||||
import ThemePicker from './ThemePicker.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let isShareModalOpen = $state(false);
|
||||
let isPageSettingsOpen = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
let { title = 'Untitled Document', docId = undefined } = $props<{ title?: string, docId?: string }>();
|
||||
|
||||
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
const safeTitle = title.replace(/[^a-z0-9_-]/gi, '_');
|
||||
|
||||
if (format === 'typ') {
|
||||
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 = `${safeTitle}.typ`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
exportTypst(content, format, safeTitle, docId).catch((e) => {
|
||||
console.error(`Export to ${format} failed:`, e);
|
||||
alert(`Failed to export as ${format.toUpperCase()}`);
|
||||
});
|
||||
}
|
||||
|
||||
function insertTypstConfig(setting: string, value: string) {
|
||||
if (!text) return;
|
||||
const content = text.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}`;
|
||||
}
|
||||
|
||||
const newRule = `#set ${setting}(${newArgs})`;
|
||||
const lengthToReplace = match[0].length;
|
||||
text.delete(index, lengthToReplace);
|
||||
text.insert(index, newRule);
|
||||
} else {
|
||||
text.insert(0, `#set ${setting}(${value})\n`);
|
||||
}
|
||||
}
|
||||
|
||||
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 handleImageUpload(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
const file = target.files[0];
|
||||
if (!docId) return alert('Please save the document before uploading images.');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
fetch(`/api/docs/${docId}/files`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(res => res.json()).then(data => {
|
||||
if (data.filename) {
|
||||
const view = $editorViewStore;
|
||||
if (view) {
|
||||
const selection = view.state.selection.main;
|
||||
const isFont = data.filename.toLowerCase().endsWith('.ttf') || data.filename.toLowerCase().endsWith('.otf');
|
||||
const replacement = isFont ? `#set text(font: ("New Computer Modern", "${data.filename.replace(/\.[^/.]+$/, "")}"))\n` : `#image("${data.filename}")\n`;
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: replacement },
|
||||
selection: { anchor: selection.from + replacement.length }
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
alert('Failed to upload image');
|
||||
});
|
||||
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
function handlePageSettings(settings: Record<string, string>, docSettings: Record<string, string>) {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
|
||||
|
||||
if (Object.keys(settings).length > 0) {
|
||||
let args = Object.entries(settings).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
const regex = new RegExp(`^#set\\s+page\\s*\\(([^)]*)\\)`, 'm');
|
||||
const match = content.match(regex);
|
||||
|
||||
if (match) {
|
||||
const index = match.index!;
|
||||
const lengthToReplace = match[0].length;
|
||||
text.delete(index, lengthToReplace);
|
||||
text.insert(index, `#set page(${args})`);
|
||||
} else {
|
||||
text.insert(0, `#set page(${args})\n`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (Object.keys(docSettings).length > 0) {
|
||||
let docArgs = Object.entries(docSettings).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
const docRegex = new RegExp(`^#set\\s+document\\s*\\(([^)]*)\\)`, 'm');
|
||||
const docMatch = text.toString().match(docRegex);
|
||||
|
||||
if (docMatch) {
|
||||
const index = docMatch.index!;
|
||||
const lengthToReplace = docMatch[0].length;
|
||||
text.delete(index, lengthToReplace);
|
||||
text.insert(index, `#set document(${docArgs})`);
|
||||
} else {
|
||||
text.insert(0, `#set document(${docArgs})\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const themeNames = Object.keys(themes);
|
||||
|
||||
function parseSettings() {
|
||||
if (!text) return {};
|
||||
const content = text.toString();
|
||||
const settings: Record<string, string> = {};
|
||||
|
||||
const pageMatch = content.match(/^#set\s+page\s*\(([^)]*)\)/m);
|
||||
if (pageMatch) {
|
||||
const args = pageMatch[1].split(',').map(s => s.trim());
|
||||
for (const arg of args) {
|
||||
const [k, ...vParts] = arg.split(':').map(s => s.trim());
|
||||
if (k && vParts.length) {
|
||||
let v = vParts.join(':').trim();
|
||||
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
|
||||
|
||||
if (v === 'true') v = 'true';
|
||||
if (v === 'false') v = 'false';
|
||||
settings[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const docMatch = content.match(/^#set\s+document\s*\(([^)]*)\)/m);
|
||||
if (docMatch) {
|
||||
const args = docMatch[1].split(',').map(s => s.trim());
|
||||
for (const arg of args) {
|
||||
const [k, ...vParts] = arg.split(':').map(s => s.trim());
|
||||
if (k && vParts.length) {
|
||||
let v = vParts.join(':').trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
|
||||
|
||||
if (k === 'title') settings.docTitle = v;
|
||||
if (k === 'author') settings.author = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
let isMenuOpen = $state(false);
|
||||
let activeMenu = $state<string | null>(null);
|
||||
let showInfoModal = $state(false);
|
||||
let showRenameModal = $state(false);
|
||||
let showDeleteModal = $state(false);
|
||||
let renameTitle = $state("");
|
||||
$effect(() => { renameTitle = title; });
|
||||
let docInfo = $state<any>(null);
|
||||
|
||||
|
||||
function openInfo() {
|
||||
fetch(`/api/docs/${docId}`).then(res => res.json()).then(doc => {
|
||||
docInfo = doc;
|
||||
showInfoModal = true;
|
||||
}).catch(e => {
|
||||
console.error(e);
|
||||
showInfoModal = true;
|
||||
});
|
||||
}
|
||||
|
||||
function openRename() {
|
||||
renameTitle = title;
|
||||
showRenameModal = true;
|
||||
}
|
||||
|
||||
function submitRename(e: Event) {
|
||||
e.preventDefault();
|
||||
if (renameTitle && renameTitle !== title) {
|
||||
fetch(`/api/docs/${docId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: renameTitle })
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
showRenameModal = false;
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
fetch(`/api/docs/${docId}`, { method: 'DELETE' }).then(res => {
|
||||
if (res.ok) goto('/dashboard');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteDoc() {
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.action-menu-container')) {
|
||||
isMenuOpen = false;
|
||||
activeMenu = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<header class="flex flex-col border-b border-gray-200 dark:border-white/10 bg-white/80 dark:bg-black/20 backdrop-blur-md select-none w-full relative z-[60]">
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
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"
|
||||
aria-label="Back to dashboard"
|
||||
title="Dashboard"
|
||||
>
|
||||
<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="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<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}>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-1.5 ml-2 px-2 py-0.5 rounded-full text-[11px] font-medium {
|
||||
$connectionStatus === 'connected' ? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800/30' :
|
||||
'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30'
|
||||
} border">
|
||||
<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>
|
||||
{$connectionStatus === 'connected' ? 'Synced' : 'Connecting...'}
|
||||
</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="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'file' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
>
|
||||
File
|
||||
</button>
|
||||
{#if activeMenu === 'file'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; goto('/dashboard'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">New / Open</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; openRename(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Rename</button>
|
||||
<button onclick={() => { activeMenu = null; isShareModalOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Share</button>
|
||||
<button onclick={() => { activeMenu = null; openInfo(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Document Info</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Page Settings</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Download</div>
|
||||
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.typ source</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.pdf document</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.png image</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.svg graphics</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; deleteDoc(); }} class="w-full text-left px-4 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10">Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'edit' ? null : 'edit'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'edit' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{#if activeMenu === 'edit'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('undo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Undo (Ctrl+Z)</button>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('redo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Redo (Ctrl+Y)</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('cut'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Cut (Ctrl+X)</button>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('copy'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Copy (Ctrl+C)</button>
|
||||
<button onclick={() => { activeMenu = null; navigator.clipboard.readText().then(t => document.execCommand('insertText', false, t)); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Paste (Ctrl+V)</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'view' ? null : 'view'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'view' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{#if activeMenu === 'view'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; $darkModeStore = !$darkModeStore; document.documentElement.classList.toggle('dark', $darkModeStore); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center justify-between">
|
||||
Dark Mode
|
||||
<Icon icon={$darkModeStore ? "mdi:check" : ""} class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
{#if $connectedUsers.length > 0}
|
||||
<div class="flex items-center -space-x-2 mr-2">
|
||||
{#each $connectedUsers as user}
|
||||
<div
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-white dark:border-zinc-950 shadow-sm"
|
||||
style="background-color: {user.color}; z-index: {user.isLocal ? 10 : 1};"
|
||||
title={user.name + (user.isLocal ? ' (You)' : '')}
|
||||
>
|
||||
{getInitials(user.name)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="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">
|
||||
<Icon icon="mdi:book-open-page-variant-outline" class="text-[18px]" />
|
||||
</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">
|
||||
<Icon icon="mdi:earth" class="text-[18px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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
|
||||
</button>
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center bg-gray-100/50 dark:bg-zinc-900/50 rounded-md p-0.5 border border-gray-200 dark:border-white/10">
|
||||
<button onclick={() => handleExport('typ')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Download .typ source">TYP</button>
|
||||
<button onclick={() => handleExport('svg')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as SVG">SVG</button>
|
||||
<button onclick={() => handleExport('png')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as PNG">PNG</button>
|
||||
<button onclick={() => handleExport('pdf')} class="flex items-center gap-1 px-3 py-1 text-xs font-bold 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 transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h2"/><path d="M8 17h2"/><path d="M14 13h2"/><path d="M14 17h2"/></svg>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center px-4 py-1.5 bg-white/50 dark:bg-black/10 border-t border-gray-200/60 dark:border-white/10 gap-4 overflow-x-auto no-scrollbar">
|
||||
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick={() => applyFormat('*', '*', 'bold')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Bold">
|
||||
<Icon icon="mdi:format-bold" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => applyFormat('_', '_', 'italic')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Italic">
|
||||
<Icon icon="mdi:format-italic" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => applyFormat('`', '`', 'code')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Code">
|
||||
<Icon icon="mdi:code-tags" class="text-lg" />
|
||||
</button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
||||
<button onclick={() => applyFormat('$ ', ' $', 'x = y')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Math (Inline)">
|
||||
<Icon icon="mdi:sigma" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => applyFormat('$ \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)">
|
||||
<Icon icon="mdi:math-integral" class="text-lg" />
|
||||
</button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></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">
|
||||
<Icon icon="mdi:format-list-bulleted" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => applyFormat('+ ', '', 'Numbered item')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Numbered List">
|
||||
<Icon icon="mdi:format-list-numbered" class="text-lg" />
|
||||
</button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
||||
<input type="file" bind:this={fileInput} onchange={handleImageUpload} class="hidden" accept="image/*,.ttf,.otf" />
|
||||
<button onclick={() => fileInput.click()} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Upload Image / Font">
|
||||
<Icon icon="mdi:image-plus" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="font-select" class="text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Font</label>
|
||||
<select
|
||||
id="font-select"
|
||||
onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)}
|
||||
class="bg-white dark:bg-black/20 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/20 text-xs rounded shadow-sm focus:ring-blue-500 focus:border-blue-500 block py-1 pl-2 pr-6 appearance-none cursor-pointer hover:border-gray-400 dark:hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="New Computer Modern">Default (New CM)</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="Libertinus Serif">Libertinus Serif</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="PT Sans">PT Sans</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="Roboto">Roboto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => (isPageSettingsOpen = true)}
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold text-gray-600 hover:text-gray-900 bg-white hover:bg-gray-100 border border-gray-300 rounded shadow-sm dark:text-gray-300 dark:bg-black/20 dark:border-white/20 dark:hover:bg-white/10 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<Icon icon="mdi:file-document-edit-outline" class="text-sm" />
|
||||
Page Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></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">
|
||||
<button
|
||||
onclick={() => $documentZoomStore = Math.max(10, $documentZoomStore - 10)}
|
||||
class="px-2 py-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<Icon icon="mdi:minus" class="text-sm" />
|
||||
</button>
|
||||
<span role="button" tabindex="0" onkeydown={(e) => { if (e.key === "Enter") $documentZoomStore = 100; }} class="text-[11px] font-semibold text-gray-700 dark:text-gray-200 min-w-[3rem] text-center select-none" ondblclick={() => $documentZoomStore = 100}>
|
||||
{$documentZoomStore}%
|
||||
</span>
|
||||
<button
|
||||
onclick={() => $documentZoomStore = Math.min(500, $documentZoomStore + 10)}
|
||||
class="px-2 py-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
|
||||
title="Zoom In"
|
||||
>
|
||||
<Icon icon="mdi:plus" class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemePicker />
|
||||
</div>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if isShareModalOpen}
|
||||
<ShareModal onClose={() => (isShareModalOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if isPageSettingsOpen}
|
||||
<PageSettingsModal onClose={() => (isPageSettingsOpen = false)} onApply={handlePageSettings} currentSettings={parseSettings()} />
|
||||
{/if}
|
||||
|
||||
{#if showInfoModal}
|
||||
|
||||
<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="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>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 capitalize">Document</p>
|
||||
</div>
|
||||
{#if docInfo?.created_at}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Created At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(docInfo.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if docInfo?.updated_at}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(docInfo.updated_at).toLocaleString()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</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">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRenameModal}
|
||||
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={() => showRenameModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showRenameModal = false; } }}>
|
||||
<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={submitRename} 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={() => 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 showDeleteModal}
|
||||
|
||||
<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; } }}>
|
||||
<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 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}
|
||||
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
@@ -0,0 +1,36 @@
|
||||
<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>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { doc, activeMenu, setActiveMenu, openInfo, openRename, shareItem, deleteDoc } = $props<{
|
||||
doc: any;
|
||||
activeMenu: string | null;
|
||||
setActiveMenu: (id: string | null) => void;
|
||||
openInfo: (doc: any, type: string) => void;
|
||||
openRename: (id: string, title: string, type: 'document'|'folder'|'file') => void;
|
||||
shareItem: (item: any) => void;
|
||||
deleteDoc: (id: string, name: string) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 flex flex-col hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500/50 transition-all duration-200 relative group transform hover:-translate-y-1 cursor-pointer overflow-visible"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => goto(`/doc/${doc.id}`)}
|
||||
onkeydown={(e) => e.key === 'Enter' && goto(`/doc/${doc.id}`)}
|
||||
draggable="true"
|
||||
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">
|
||||
{#if doc.thumbnail_svg}
|
||||
<div class="w-full h-full flex items-center justify-center p-2 bg-white transition-transform duration-300 group-hover:scale-110">
|
||||
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(doc.thumbnail_svg)))}`} class="max-w-full max-h-full object-contain shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full transition-transform duration-300 group-hover:scale-110">
|
||||
<Icon icon="mdi:file-document" class="text-4xl" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="p-4 flex flex-col flex-grow">
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate pr-2 pointer-events-none" title={doc.title}>{doc.title}</h3>
|
||||
|
||||
|
||||
<div class="relative action-menu-container">
|
||||
<button
|
||||
aria-label="Document actions"
|
||||
onclick={(e) => { e.stopPropagation(); setActiveMenu(activeMenu === doc.id ? null : doc.id); }}
|
||||
class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors p-1 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 pointer-events-auto"
|
||||
>
|
||||
<Icon icon="mdi:dots-vertical" class="text-xl" />
|
||||
</button>
|
||||
|
||||
{#if activeMenu === doc.id}
|
||||
<div class="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={(e) => { e.stopPropagation(); openInfo(doc, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:information-outline" class="text-lg text-blue-500" />
|
||||
View Info
|
||||
</button>
|
||||
<button onclick={(e) => { e.stopPropagation(); openRename(doc.id, doc.title, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={(e) => { e.stopPropagation(); shareItem(doc); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:share-variant-outline" class="text-lg text-green-500" />
|
||||
Share
|
||||
</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={(e) => { e.stopPropagation(); deleteDoc(doc.id, doc.title); }} class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 flex items-center gap-2">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-2 pointer-events-none">
|
||||
<Icon icon="mdi:clock-outline" class="text-sm" />
|
||||
Edited {new Date(doc.updated_at.endsWith('Z') ? doc.updated_at : doc.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { file, deleteFile } = $props<{
|
||||
file: any;
|
||||
deleteFile: (id: string, name: string) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<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 hover:shadow-lg hover:border-green-400 dark:hover:border-green-500/50 transition-all duration-200 relative group transform hover:-translate-y-1 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => window.open(`/api/files/${file.id}/data`, '_blank')}
|
||||
onkeydown={(e) => e.key === 'Enter' && window.open(`/api/files/${file.id}/data`, '_blank')}
|
||||
draggable="true"
|
||||
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="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">
|
||||
{#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" />
|
||||
{:else}
|
||||
<Icon icon="mdi:image-outline" class="text-2xl" />
|
||||
{/if}
|
||||
</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">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||
</button>
|
||||
</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>
|
||||
<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">
|
||||
<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' })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { folder, dragOverFolderId, navigateToFolder, handleDrop, deleteFolder, setDragOverFolderId } = $props<{
|
||||
folder: any;
|
||||
dragOverFolderId: string | null;
|
||||
navigateToFolder: (folder: any) => void;
|
||||
handleDrop: (e: DragEvent, folderId: string) => void;
|
||||
deleteFolder: (id: string, name: string) => void;
|
||||
setDragOverFolderId: (id: string | null) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer group transition-colors {dragOverFolderId === folder.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => navigateToFolder(folder)}
|
||||
onkeydown={(e) => e.key === 'Enter' && navigateToFolder(folder)}
|
||||
ondragover={(e) => { e.preventDefault(); setDragOverFolderId(folder.id); }}
|
||||
ondragleave={() => setDragOverFolderId(null)}
|
||||
ondrop={(e) => handleDrop(e, folder.id)}
|
||||
>
|
||||
<div class="flex items-center gap-3 pointer-events-none">
|
||||
<Icon icon="mdi:folder" class="text-2xl text-yellow-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{folder.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 hidden sm:block pointer-events-none">
|
||||
{new Date(folder.created_at ? (folder.created_at.endsWith('Z') ? folder.created_at : folder.created_at + 'Z') : Date.now()).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<button aria-label="Delete folder" onclick={(e) => { e.stopPropagation(); deleteFolder(folder.id, folder.name); }} class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity p-2 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
let { selectedInfo, onClose } = $props<{
|
||||
selectedInfo: {type: string, title?: string, name?: string, created_at: string, updated_at?: string},
|
||||
onClose: () => void
|
||||
}>();
|
||||
</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()}>
|
||||
<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>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 capitalize">{selectedInfo.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Created At</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>
|
||||
</div>
|
||||
{#if selectedInfo.updated_at}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">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>
|
||||
</div>
|
||||
{/if}
|
||||
</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">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import Icon from '@iconify/svelte';
|
||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
userStore.set(null);
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="bg-white/80 dark:bg-black/20 backdrop-blur-md 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">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Icon icon="mdi:script-text" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
||||
TypstDrive
|
||||
</h1>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300 font-medium">
|
||||
<Icon icon="mdi:account-circle" class="text-xl" />
|
||||
{$userStore?.username}
|
||||
</div>
|
||||
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
||||
|
||||
|
||||
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-gray-600 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Typst Docs">
|
||||
<Icon icon="mdi:book-open-page-variant-outline" class="text-xl" />
|
||||
</a>
|
||||
<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">
|
||||
<Icon icon="mdi:earth" class="text-xl" />
|
||||
</a>
|
||||
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
||||
|
||||
|
||||
<ThemePicker />
|
||||
|
||||
<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 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">
|
||||
<Icon icon="mdi:logout" class="text-lg" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,48 @@
|
||||
<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 @@
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export const userStore = writable<User | null>(null);
|
||||
|
||||
export async function fetchUser() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
userStore.set(user);
|
||||
} else {
|
||||
userStore.set(null);
|
||||
}
|
||||
} catch {
|
||||
userStore.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const themeStore = writable('Catppuccin');
|
||||
export const darkModeStore = writable(true);
|
||||
export const connectionStatus = writable('connecting');
|
||||
export const editorViewStore = writable<any>(null);
|
||||
export const documentZoomStore = writable(100);
|
||||
|
||||
export interface AwarenessUser {
|
||||
clientId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
colorLight: string;
|
||||
isLocal?: boolean;
|
||||
}
|
||||
|
||||
export const connectedUsers = writable<AwarenessUser[]>([]);
|
||||
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTheme = localStorage.getItem('editor-theme');
|
||||
const savedDark = localStorage.getItem('editor-dark-mode');
|
||||
const savedZoom = localStorage.getItem('editor-document-zoom');
|
||||
|
||||
if (savedTheme) themeStore.set(savedTheme);
|
||||
if (savedDark !== null) darkModeStore.set(savedDark === 'true');
|
||||
if (savedZoom !== null) documentZoomStore.set(parseInt(savedZoom, 10));
|
||||
|
||||
themeStore.subscribe(value => localStorage.setItem('editor-theme', value));
|
||||
darkModeStore.subscribe(value => {
|
||||
localStorage.setItem('editor-dark-mode', value.toString());
|
||||
if (value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
});
|
||||
documentZoomStore.subscribe(value => localStorage.setItem('editor-document-zoom', value.toString()));
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
|
||||
export interface ThemeColors {
|
||||
background: string;
|
||||
text: string;
|
||||
selection: string;
|
||||
cursor: string;
|
||||
keyword: string;
|
||||
string: string;
|
||||
number: string;
|
||||
comment: string;
|
||||
variable: string;
|
||||
function: string;
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
icon: string;
|
||||
dark: ThemeColors;
|
||||
light: ThemeColors;
|
||||
}
|
||||
|
||||
export const themes: Record<string, ThemeConfig> = {
|
||||
Cerberus: {
|
||||
icon: "mdi:dog",
|
||||
dark: {
|
||||
background: "#171717", text: "#f5f5f5", selection: "#262626", cursor: "#f5f5f5",
|
||||
keyword: "#e879f9", string: "#2dd4bf", number: "#fbbf24", comment: "#737373", variable: "#f5f5f5", function: "#818cf8"
|
||||
},
|
||||
light: {
|
||||
background: "#ffffff", text: "#171717", selection: "#f5f5f5", cursor: "#171717",
|
||||
keyword: "#c026d3", string: "#0d9488", number: "#d97706", comment: "#525252", variable: "#171717", function: "#4f46e5"
|
||||
}
|
||||
},
|
||||
Catppuccin: {
|
||||
icon: "mdi:cat",
|
||||
dark: {
|
||||
background: "#1e1e2e", text: "#cdd6f4", selection: "#313244", cursor: "#f5e0dc",
|
||||
keyword: "#cba6f7", string: "#a6e3a1", number: "#fab387", comment: "#6c7086", variable: "#cdd6f4", function: "#89b4fa"
|
||||
},
|
||||
light: {
|
||||
background: "#eff1f5", text: "#4c4f69", selection: "#e6e9ef", cursor: "#dc8a78",
|
||||
keyword: "#8839ef", string: "#40a02b", number: "#fe640b", comment: "#9ca0b0", variable: "#4c4f69", function: "#1e66f5"
|
||||
}
|
||||
},
|
||||
"Arch Linux": {
|
||||
icon: "mdi:penguin",
|
||||
dark: {
|
||||
background: "#0d1117", text: "#c9d1d9", selection: "#21262d", cursor: "#c9d1d9",
|
||||
keyword: "#bc8cff", string: "#3fb950", number: "#ffa657", comment: "#6e7681", variable: "#c9d1d9", function: "#1793d1"
|
||||
},
|
||||
light: {
|
||||
background: "#ffffff", text: "#24292f", selection: "#f6f8fa", cursor: "#24292f",
|
||||
keyword: "#8250df", string: "#1a7f37", number: "#bc4c00", comment: "#6e7781", variable: "#24292f", function: "#1793d1"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function getThemeExtension(themeName: keyof typeof themes, isDark: boolean) {
|
||||
const colors = themes[themeName][isDark ? 'dark' : 'light'];
|
||||
|
||||
const theme = EditorView.theme({
|
||||
"&": {
|
||||
color: colors.text,
|
||||
backgroundColor: colors.background,
|
||||
height: "100%",
|
||||
fontSize: "14px"
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: colors.cursor
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: colors.cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { backgroundColor: colors.selection },
|
||||
".cm-panels": { backgroundColor: colors.background, color: colors.text },
|
||||
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
|
||||
".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" },
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "#72a1ff59",
|
||||
outline: "1px solid #457dff"
|
||||
},
|
||||
".cm-searchMatch.cm-searchMatch-selected": {
|
||||
backgroundColor: "#6199ff2f"
|
||||
},
|
||||
".cm-activeLine": { backgroundColor: colors.selection },
|
||||
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
|
||||
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
|
||||
backgroundColor: "#bad0f847"
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: colors.background,
|
||||
color: colors.comment,
|
||||
border: "none"
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: colors.selection
|
||||
},
|
||||
".cm-foldPlaceholder": {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "#ddd"
|
||||
},
|
||||
".cm-tooltip": {
|
||||
border: "none",
|
||||
backgroundColor: colors.background
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: "transparent",
|
||||
borderBottomColor: "transparent"
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: colors.background,
|
||||
borderBottomColor: colors.background
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: colors.selection,
|
||||
color: colors.text
|
||||
}
|
||||
}
|
||||
}, { dark: isDark });
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: t.keyword, color: colors.keyword },
|
||||
{ tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: colors.variable },
|
||||
{ tag: [t.function(t.variableName), t.labelName], color: colors.function },
|
||||
{ tag: [t.color, t.constant(t.name), t.standard(t.name)], color: colors.function },
|
||||
{ tag: [t.definition(t.name), t.separator], color: colors.variable },
|
||||
{ tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: colors.number },
|
||||
{ tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], color: colors.keyword },
|
||||
{ tag: [t.meta, t.comment], color: colors.comment },
|
||||
{ tag: t.strong, fontWeight: "bold" },
|
||||
{ tag: t.emphasis, fontStyle: "italic" },
|
||||
{ tag: t.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: t.link, color: colors.comment, textDecoration: "underline" },
|
||||
{ tag: t.heading, fontWeight: "bold", color: colors.function },
|
||||
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: colors.number },
|
||||
{ tag: [t.processingInstruction, t.string, t.inserted], color: colors.string },
|
||||
{ tag: t.invalid, color: "#ff0000" },
|
||||
]);
|
||||
|
||||
return [theme, syntaxHighlighting(highlightStyle)];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface Diagnostic {
|
||||
message: string;
|
||||
severity: string;
|
||||
}
|
||||
|
||||
export interface CompileResponse {
|
||||
svgs: string[] | null;
|
||||
errors: Diagnostic[] | null;
|
||||
}
|
||||
|
||||
export async function compileTypst(text: string, document_id?: string): Promise<CompileResponse> {
|
||||
const res = await fetch('/api/compile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, document_id }),
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export function exportTypst(text: string, format: 'pdf' | 'png' | 'svg', title: string = 'document', document_id?: string) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/api/export/${format}`;
|
||||
form.target = '_blank';
|
||||
|
||||
|
||||
|
||||
|
||||
return fetch(`/api/export/${format}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, document_id }),
|
||||
})
|
||||
.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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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 let doc: Y.Doc | null = null;
|
||||
export let text: Y.Text | null = null;
|
||||
export let provider: WebsocketProvider | null = null;
|
||||
|
||||
const userColors = [
|
||||
'#30bced', '#6eeb83', '#ffbc42', '#ecd444', '#ee6352',
|
||||
'#9ac2c9', '#8acb88', '#1be7ff', '#ff0054', '#9e0059'
|
||||
];
|
||||
|
||||
export function initYjs(docId: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
if (provider) {
|
||||
provider.disconnect();
|
||||
provider = null;
|
||||
}
|
||||
|
||||
doc = new Y.Doc();
|
||||
text = doc.getText('typst');
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
|
||||
connectionStatus.set('connecting');
|
||||
|
||||
provider = new WebsocketProvider(
|
||||
`${protocol}//${host}/yjs`,
|
||||
docId,
|
||||
doc
|
||||
);
|
||||
|
||||
const user = get(userStore);
|
||||
const color = userColors[Math.floor(Math.random() * userColors.length)];
|
||||
|
||||
provider.awareness.setLocalStateField('user', {
|
||||
name: user?.username || 'Anonymous',
|
||||
color: color,
|
||||
colorLight: color + '33'
|
||||
});
|
||||
|
||||
provider.on('status', (event: { status: string }) => {
|
||||
connectionStatus.set(event.status);
|
||||
console.log(`Yjs connection status for ${docId}:`, event.status);
|
||||
});
|
||||
|
||||
provider.awareness.on('change', () => {
|
||||
if (!provider) return;
|
||||
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()));
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupYjs() {
|
||||
if (provider) {
|
||||
provider.disconnect();
|
||||
provider = null;
|
||||
}
|
||||
doc = null;
|
||||
text = null;
|
||||
connectionStatus.set('disconnected');
|
||||
connectedUsers.set([]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Icon from '@iconify/svelte';
|
||||
</script>
|
||||
|
||||
<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="inline-flex items-center justify-center w-20 h-20 rounded-full bg-red-100 dark:bg-red-500/10 mb-6">
|
||||
<Icon icon="mdi:alert-circle-outline" class="h-10 w-10 text-red-600 dark:text-red-500" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-6xl font-bold text-gray-900 dark:text-white mb-2 tracking-tight">
|
||||
{$page.status}
|
||||
</h1>
|
||||
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
|
||||
Something went wrong
|
||||
</h2>
|
||||
|
||||
<p class="text-base text-gray-600 dark:text-gray-400 mb-8 leading-relaxed">
|
||||
{$page.error?.message || 'We experienced an unexpected error processing your request.'}
|
||||
</p>
|
||||
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<Icon icon="mdi:home" class="text-lg" />
|
||||
Return to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { fetchUser } from '$lib/ts/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||
import { themes } from '$lib/ts/themes';
|
||||
|
||||
let { children } = $props();
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await fetchUser();
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
let currentTheme = $derived(themes[$themeStore as keyof typeof themes] || themes['Catppuccin']);
|
||||
let currentColors = $derived($darkModeStore ? currentTheme.dark : currentTheme.light);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>TypstDrive</title>
|
||||
<meta name="description" content="TypstDrive - A collaborative Typst editor and document manager." />
|
||||
<meta name="theme-color" content={currentColors.background} />
|
||||
<meta property="og:title" content="TypstDrive" />
|
||||
<meta property="og:description" content="A collaborative Typst editor and document manager." />
|
||||
<meta property="og:type" content="website" />
|
||||
</svelte:head>
|
||||
|
||||
{#if loaded}
|
||||
<div
|
||||
class="h-screen w-screen flex flex-col font-sans transition-colors duration-200"
|
||||
style="background-color: {currentColors.background}; color: {currentColors.text};"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-zinc-950">
|
||||
<div class="text-gray-500 dark:text-gray-400 font-medium animate-pulse">Loading TypstDrive...</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
|
||||
onMount(() => {
|
||||
if ($userStore) {
|
||||
goto('/dashboard');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>TypstDrive</title>
|
||||
<meta name="description" content="Collaborative Typst Editor." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full flex items-center justify-center text-gray-500">
|
||||
Redirecting...
|
||||
</div>
|
||||
@@ -0,0 +1,477 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||
import Icon from '@iconify/svelte';
|
||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||
import Navbar from '$lib/components/dashboard/Navbar.svelte';
|
||||
import FolderRow from '$lib/components/dashboard/FolderRow.svelte';
|
||||
import DocCard from '$lib/components/dashboard/DocCard.svelte';
|
||||
import FileCard from '$lib/components/dashboard/FileCard.svelte';
|
||||
import ShareModal from '$lib/components/ShareModal.svelte';
|
||||
import DeleteModal from '$lib/components/dashboard/DeleteModal.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';
|
||||
|
||||
let documents = $state<any[]>([]);
|
||||
let folders = $state<any[]>([]);
|
||||
let files = $state<any[]>([]);
|
||||
let currentFolderId = $state<string | null>(null);
|
||||
let folderPath = $state<{id: string, name: string}[]>([]);
|
||||
let showCreateFolderModal = $state(false);
|
||||
let newFolderName = $state('');
|
||||
let loading = $state(true);
|
||||
let showCreateModal = $state(false);
|
||||
let newDocTitle = $state('');
|
||||
let showPlusDropdown = $state(false);
|
||||
let dragOverFolderId = $state<string | null>(null);
|
||||
let dragOverBreadcrumbIndex = $state<number | null>(null);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let showDeleteModal = $state(false);
|
||||
let deleteTarget = $state<{id: string, type: 'document'|'folder'|'file', name: string} | null>(null);
|
||||
|
||||
function openDelete(id: string, type: 'document'|'folder'|'file', name: string) {
|
||||
deleteTarget = { id, type, name };
|
||||
showDeleteModal = true;
|
||||
activeMenu = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
const { id, type } = deleteTarget;
|
||||
|
||||
let endpoint = `/api/docs/${id}`;
|
||||
if (type === 'folder') endpoint = `/api/folders/${id}`;
|
||||
if (type === 'file') endpoint = `/api/files/${id}`;
|
||||
|
||||
const res = await fetch(endpoint, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
if (type === 'document') documents = documents.filter(d => d.id !== id);
|
||||
if (type === 'folder') folders = folders.filter(f => f.id !== id);
|
||||
if (type === 'file') files = files.filter(f => f.id !== id);
|
||||
}
|
||||
showDeleteModal = false;
|
||||
deleteTarget = null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
|
||||
for (let i = 0; i < target.files.length; i++) {
|
||||
const file = target.files[i];
|
||||
|
||||
if (file.name.endsWith('.typ')) {
|
||||
|
||||
const content = await file.text();
|
||||
|
||||
const title = file.name.replace(/\.typ$/i, '');
|
||||
|
||||
await fetch('/api/docs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
folder_id: currentFolderId || undefined,
|
||||
content: content
|
||||
})
|
||||
});
|
||||
} else {
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const query = currentFolderId ? `?folder_id=${currentFolderId}` : '';
|
||||
await fetch(`/api/files${query}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
}
|
||||
target.value = '';
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
async function deleteFile(id: string, name: string) {
|
||||
openDelete(id, 'file', name);
|
||||
}
|
||||
|
||||
async function loadDocs() {
|
||||
loading = true;
|
||||
try {
|
||||
const folderQuery = currentFolderId ? `?parent_id=${currentFolderId}` : '';
|
||||
const docQuery = currentFolderId ? `?folder_id=${currentFolderId}` : '';
|
||||
const fileQuery = currentFolderId ? `?folder_id=${currentFolderId}` : '';
|
||||
|
||||
const [resFolders, resDocs, resFiles] = await Promise.all([
|
||||
fetch(`/api/folders${folderQuery}`),
|
||||
fetch(`/api/docs${docQuery}`),
|
||||
fetch(`/api/files${fileQuery}`)
|
||||
]);
|
||||
|
||||
if (resFolders.ok) {
|
||||
folders = await resFolders.json();
|
||||
}
|
||||
if (resDocs.ok) {
|
||||
documents = await resDocs.json();
|
||||
}
|
||||
if (resFiles.ok) {
|
||||
files = await resFiles.json();
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateFolderModal() {
|
||||
showPlusDropdown = false;
|
||||
newFolderName = 'New Folder';
|
||||
showCreateFolderModal = true;
|
||||
}
|
||||
|
||||
async function createFolder(name: string) {
|
||||
if (!name.trim()) return;
|
||||
|
||||
const body: any = { name: name.trim() };
|
||||
if (currentFolderId) body.parent_id = currentFolderId;
|
||||
|
||||
const res = await fetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showCreateFolderModal = false;
|
||||
loadDocs();
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToFolder(folder: {id: string, name: string}) {
|
||||
folderPath = [...folderPath, folder];
|
||||
currentFolderId = folder.id;
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
function navigateToBreadcrumb(index: number) {
|
||||
if (index === -1) {
|
||||
folderPath = [];
|
||||
currentFolderId = null;
|
||||
} else {
|
||||
folderPath = folderPath.slice(0, index + 1);
|
||||
currentFolderId = folderPath[folderPath.length - 1].id;
|
||||
}
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
async function deleteFolder(id: string, name: string) {
|
||||
openDelete(id, 'folder', name);
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
showPlusDropdown = false;
|
||||
newDocTitle = 'Untitled Document';
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
async function createDoc(title: string) {
|
||||
if (!title.trim()) return;
|
||||
|
||||
const res = await fetch('/api/docs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title.trim(), folder_id: currentFolderId || undefined })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const doc = await res.json();
|
||||
showCreateModal = false;
|
||||
goto(`/doc/${doc.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDoc(id: string, name: string) {
|
||||
openDelete(id, 'document', name);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!$userStore) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
loadDocs();
|
||||
});
|
||||
|
||||
let activeMenu = $state<string | null>(null);
|
||||
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.plus-dropdown-container')) {
|
||||
showPlusDropdown = false;
|
||||
}
|
||||
if (!target.closest('.action-menu-container')) {
|
||||
activeMenu = null;
|
||||
}
|
||||
}
|
||||
|
||||
let showInfoModal = $state(false);
|
||||
let selectedInfo = $state<any>(null);
|
||||
|
||||
let showRenameModal = $state(false);
|
||||
let renameId = $state<string | null>(null);
|
||||
let renameType = $state<'document'|'folder'|'file'>('document');
|
||||
let renameTitle = $state('');
|
||||
|
||||
function openInfo(item: any, type: string) {
|
||||
selectedInfo = { ...item, type };
|
||||
showInfoModal = true;
|
||||
activeMenu = null;
|
||||
}
|
||||
|
||||
function openRename(id: string, currentTitle: string, type: 'document'|'folder'|'file') {
|
||||
renameId = id;
|
||||
renameTitle = currentTitle;
|
||||
renameType = type;
|
||||
showRenameModal = true;
|
||||
activeMenu = null;
|
||||
}
|
||||
|
||||
async function handleRename(newTitle: string) {
|
||||
if (!newTitle.trim() || !renameId) return;
|
||||
|
||||
let endpoint = `/api/docs/${renameId}`;
|
||||
if (renameType === 'folder') endpoint = `/api/folders/${renameId}`;
|
||||
if (renameType === 'file') return;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newTitle.trim(), name: newTitle.trim() })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showRenameModal = false;
|
||||
loadDocs();
|
||||
}
|
||||
}
|
||||
|
||||
let showShareModal = $state(false);
|
||||
let shareTarget = $state<any>(null);
|
||||
|
||||
function shareItem(item: any) {
|
||||
|
||||
showShareModal = true;
|
||||
shareTarget = item;
|
||||
activeMenu = null;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent, targetFolderId: string | null) {
|
||||
e.preventDefault();
|
||||
dragOverFolderId = null;
|
||||
dragOverBreadcrumbIndex = null;
|
||||
|
||||
const dataString = e.dataTransfer?.getData('text/plain');
|
||||
if (!dataString) return;
|
||||
|
||||
try {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(dataString);
|
||||
} catch (err) {
|
||||
// For backward compatibility if it's just an id
|
||||
data = { type: 'document', id: dataString };
|
||||
}
|
||||
|
||||
const { type, id } = data;
|
||||
|
||||
let endpoint = '';
|
||||
if (type === 'document') {
|
||||
endpoint = `/api/docs/${id}`;
|
||||
} else if (type === 'file') {
|
||||
endpoint = `/api/files/${id}`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_id: targetFolderId || "" })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadDocs();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to move item', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - TypstDrive</title>
|
||||
<meta name="description" content="Manage your Typst documents and folders." />
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<div class="min-h-screen bg-transparent flex flex-col">
|
||||
|
||||
<Navbar />
|
||||
|
||||
|
||||
<main class="max-w-7xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 flex-grow flex flex-col overflow-y-auto">
|
||||
<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>
|
||||
|
||||
<div class="relative plus-dropdown-container">
|
||||
<button onclick={() => showPlusDropdown = !showPlusDropdown} class="flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 rounded-full shadow-md hover:shadow-lg transition-all duration-200 focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-950 focus:ring-blue-500 transform hover:-translate-y-0.5">
|
||||
<Icon icon="mdi:plus" class="text-2xl" />
|
||||
</button>
|
||||
|
||||
{#if showPlusDropdown}
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-gray-100 dark:border-zinc-700 py-1 z-20">
|
||||
<button onclick={openCreateModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 flex items-center gap-2">
|
||||
<Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" />
|
||||
New Document
|
||||
</button>
|
||||
<button onclick={openCreateFolderModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 flex items-center gap-2">
|
||||
<Icon icon="mdi:folder-plus" class="text-lg text-yellow-500" />
|
||||
New Folder
|
||||
</button>
|
||||
<button onclick={() => { showPlusDropdown = false; fileInput?.click(); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 flex items-center gap-2">
|
||||
<Icon icon="mdi:upload" class="text-lg text-green-500" />
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<input type="file" bind:this={fileInput} accept="image/*,font/*,.typ,.ttf,.otf" multiple onchange={handleFileUpload} class="hidden" />
|
||||
</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">
|
||||
<button
|
||||
onclick={() => navigateToBreadcrumb(-1)}
|
||||
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = -1; }}
|
||||
ondragleave={() => dragOverBreadcrumbIndex = 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' : ''}">
|
||||
<Icon icon="mdi:home" class="text-lg inline-block pb-0.5" /> Home
|
||||
</button>
|
||||
{#each folderPath as folder, index}
|
||||
<Icon icon="mdi:chevron-right" class="text-lg text-gray-400" />
|
||||
<button
|
||||
onclick={() => navigateToBreadcrumb(index)}
|
||||
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = index; }}
|
||||
ondragleave={() => dragOverBreadcrumbIndex = null}
|
||||
ondrop={(e) => handleDrop(e, folder.id)}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === index ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : ''}">
|
||||
{folder.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex-grow flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 text-gray-500 dark:text-gray-400 animate-pulse">
|
||||
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
|
||||
<p class="text-lg font-medium">Loading your workspace...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if documents.length === 0 && folders.length === 0 && files.length === 0 && currentFolderId === null}
|
||||
<div class="flex-grow flex items-center justify-center">
|
||||
<div class="text-center p-12 bg-white/50 dark:bg-black/20 backdrop-blur-sm rounded-2xl shadow-sm border border-gray-200 dark:border-white/10 max-w-md w-full">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-blue-100/50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 mb-6">
|
||||
<Icon icon="mdi:file-document-outline" class="text-4xl" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">No documents yet</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-8">Get started by creating your first Typst document. It's fast, collaborative, and beautiful.</p>
|
||||
<button onclick={openCreateModal} class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg shadow-sm text-base font-medium transition-colors">
|
||||
<Icon icon="mdi:plus" class="text-xl" />
|
||||
Create Document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
{#if folders.length > 0}
|
||||
<div class="mb-8 bg-white/50 dark:bg-black/20 backdrop-blur-sm rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-white/40 dark:bg-white/5 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Folders
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{#each folders as folder}
|
||||
<FolderRow
|
||||
{folder}
|
||||
{dragOverFolderId}
|
||||
{navigateToFolder}
|
||||
{handleDrop}
|
||||
{deleteFolder}
|
||||
setDragOverFolderId={(id) => dragOverFolderId = id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showShareModal && shareTarget}
|
||||
|
||||
<ShareModal docId={shareTarget.id} onClose={() => showShareModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showDeleteModal && deleteTarget}
|
||||
<DeleteModal {deleteTarget} {confirmDelete} onClose={() => showDeleteModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showInfoModal && selectedInfo}
|
||||
<InfoModal {selectedInfo} onClose={() => showInfoModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showRenameModal}
|
||||
<RenameModal initialTitle={renameTitle} {handleRename} onClose={() => showRenameModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showCreateModal}
|
||||
<CreateDocModal {createDoc} onClose={() => showCreateModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showCreateFolderModal}
|
||||
<CreateFolderModal {createFolder} onClose={() => showCreateFolderModal = false} />
|
||||
{/if}
|
||||
|
||||
|
||||
{#if documents.length > 0 || files.length > 0}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{#each documents as doc}
|
||||
<DocCard
|
||||
{doc}
|
||||
{activeMenu}
|
||||
setActiveMenu={(id) => activeMenu = id}
|
||||
{openInfo}
|
||||
{openRename}
|
||||
{shareItem}
|
||||
{deleteDoc}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#each files as file}
|
||||
<FileCard {file} {deleteFile} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if documents.length === 0 && folders.length === 0 && files.length === 0}
|
||||
<div class="flex-grow flex items-center justify-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">This folder is empty.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Editor from '$lib/components/Editor.svelte';
|
||||
import Preview from '$lib/components/Preview.svelte';
|
||||
import Toolbar from '$lib/components/Toolbar.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { text, initYjs, cleanupYjs } from '$lib/ts/yjs-setup';
|
||||
import { compileTypst } from '$lib/ts/typst-api';
|
||||
import type { Diagnostic } from '$lib/ts/typst-api';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let svgs = $state<string[]>([]);
|
||||
let errors = $state<Diagnostic[]>([]);
|
||||
let timeoutId: number | undefined;
|
||||
let initialized = $state(false);
|
||||
let documentTitle = $state('Untitled Document');
|
||||
|
||||
function triggerCompile() {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
const docId = $page.params.id;
|
||||
compileTypst(content, docId)
|
||||
.then((res) => {
|
||||
if (res.svgs) {
|
||||
svgs = res.svgs;
|
||||
errors = [];
|
||||
} else if (res.errors) {
|
||||
errors = res.errors;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Compilation fetch failed', e);
|
||||
errors = [{ message: 'Network or Server Error compiling document.', severity: 'error' }];
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const docId = $page.params.id;
|
||||
if (!docId) return;
|
||||
|
||||
|
||||
fetch(`/api/docs/${docId}`)
|
||||
.then(res => res.json())
|
||||
.then(doc => {
|
||||
if (doc && doc.title) {
|
||||
documentTitle = doc.title;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to fetch document title:", err));
|
||||
|
||||
initYjs(docId);
|
||||
initialized = true;
|
||||
|
||||
text?.observe(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(triggerCompile, 500);
|
||||
});
|
||||
|
||||
triggerCompile();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
cleanupYjs();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{documentTitle} - TypstDrive</title>
|
||||
<meta name="description" content={`Editing ${documentTitle} in TypstDrive.`} />
|
||||
<meta property="og:title" content={`${documentTitle} - TypstDrive`} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full relative">
|
||||
<Toolbar title={documentTitle} docId={$page.params.id} />
|
||||
|
||||
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative">
|
||||
|
||||
<div class="w-full md:w-1/2 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)] border-r border-gray-200 dark:border-white/10">
|
||||
{#if initialized}
|
||||
<Editor />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full md:w-1/2 relative bg-white/50 dark:bg-black/20 min-h-[50%] md:min-h-0 flex flex-col">
|
||||
<Preview {svgs} />
|
||||
<ErrorBanner {errors} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let errorMsg = $state('');
|
||||
|
||||
async function login(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
errorMsg = text || 'Login failed';
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
userStore.set(user);
|
||||
goto('/dashboard');
|
||||
} catch (e: any) {
|
||||
errorMsg = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($userStore) goto('/dashboard');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - TypstDrive</title>
|
||||
<meta name="description" content="Sign in to TypstDrive." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden bg-transparent">
|
||||
|
||||
<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"></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"></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"></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="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">
|
||||
<Icon icon="mdi:script-text" class="text-3xl" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">
|
||||
Welcome back
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 font-medium">
|
||||
Sign in to your TypstDrive workspace
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" onsubmit={login}>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Username</label>
|
||||
<div class="relative">
|
||||
<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" />
|
||||
</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="Enter your username">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
||||
<div class="relative">
|
||||
<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" />
|
||||
</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="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||
<span class="font-medium">{errorMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
Sign In
|
||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-center pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<span class="text-gray-500 dark:text-gray-400">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">
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let errorMsg = $state('');
|
||||
|
||||
async function register(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
errorMsg = text || 'Registration failed';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (loginRes.ok) {
|
||||
const user = await loginRes.json();
|
||||
userStore.set(user);
|
||||
goto('/dashboard');
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMsg = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($userStore) goto('/dashboard');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Register - TypstDrive</title>
|
||||
<meta name="description" content="Create a new TypstDrive account." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden bg-transparent">
|
||||
|
||||
<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"></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"></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"></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="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">
|
||||
<Icon icon="mdi:account-plus" class="text-3xl" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">
|
||||
Create an account
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 font-medium">
|
||||
Join TypstDrive to start collaborating
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" onsubmit={register}>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Username</label>
|
||||
<div class="relative">
|
||||
<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" />
|
||||
</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">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
||||
<div class="relative">
|
||||
<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" />
|
||||
</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="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||
<span class="font-medium">{errorMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
Register
|
||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-center pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<span class="text-gray-500 dark:text-gray-400">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">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||
import { themes } from '$lib/ts/themes';
|
||||
import Icon from '@iconify/svelte';
|
||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||
|
||||
let username = $state('');
|
||||
let isSaving = $state(false);
|
||||
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let isSavingPassword = $state(false);
|
||||
let passwordError = $state('');
|
||||
let passwordSuccess = $state(false);
|
||||
|
||||
let usernameError = $state('');
|
||||
let usernameSuccess = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (!$userStore) {
|
||||
goto('/login');
|
||||
} else {
|
||||
username = $userStore.username;
|
||||
}
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
userStore.set(null);
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!username || username === $userStore?.username) return;
|
||||
|
||||
isSaving = true;
|
||||
usernameError = '';
|
||||
usernameSuccess = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
usernameError = text || "Failed to update profile";
|
||||
} else {
|
||||
const updatedUser = await res.json();
|
||||
userStore.set(updatedUser);
|
||||
usernameSuccess = true;
|
||||
}
|
||||
} catch (e) {
|
||||
usernameError = "Network error occurred.";
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
passwordError = '';
|
||||
passwordSuccess = false;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
passwordError = "New passwords don't match.";
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingPassword = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
passwordError = text || "Failed to change password";
|
||||
} else {
|
||||
passwordSuccess = true;
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
}
|
||||
} catch (e) {
|
||||
passwordError = "Network error occurred.";
|
||||
}
|
||||
|
||||
isSavingPassword = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - TypstDrive</title>
|
||||
<meta name="description" content="Manage your TypstDrive settings." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-screen overflow-y-auto bg-transparent flex flex-col">
|
||||
<nav class="bg-white/80 dark:bg-black/20 backdrop-blur-md shadow-sm border-b border-gray-200 dark:border-white/10 px-6 py-4 flex justify-between items-center sticky top-0 z-10 transition-colors duration-200 flex-shrink-0">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Icon icon="mdi:cog" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
||||
Settings
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<Icon icon="mdi:arrow-left" class="text-lg" />
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-3xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 flex-grow pb-32 mb-16 space-y-8">
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:account-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Account Settings
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10 flex flex-col gap-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-16 w-16 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">
|
||||
{$userStore?.username?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{$userStore?.username}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your profile and preferences.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<h3 class="text-md font-bold text-gray-900 dark:text-white">Profile</h3>
|
||||
|
||||
{#if usernameError}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm">
|
||||
{usernameError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if usernameSuccess}
|
||||
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm">
|
||||
Username successfully updated.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="username-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 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" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start mt-2">
|
||||
<button onclick={saveProfile} disabled={isSaving || username === $userStore?.username} 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 shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{#if isSaving}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Icon icon="mdi:content-save" class="text-lg" />
|
||||
Save Profile
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<h3 class="text-md font-bold text-gray-900 dark:text-white">Change Password</h3>
|
||||
|
||||
{#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">
|
||||
{passwordError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
Password successfully changed.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 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" />
|
||||
</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>
|
||||
<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" />
|
||||
</div>
|
||||
<div class="flex justify-start mt-2">
|
||||
<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 shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{#if isSavingPassword}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Updating...
|
||||
{:else}
|
||||
<Icon icon="mdi:lock-reset" class="text-lg" />
|
||||
Update Password
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button onclick={saveProfile} disabled={isSaving || username === $userStore?.username} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{#if isSaving}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Icon icon="mdi:content-save" class="text-lg" />
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
<button onclick={logout} class="bg-red-50 hover:bg-red-100 text-red-600 dark:bg-red-900/20 dark:hover:bg-red-900/40 dark:text-red-400 px-5 py-2.5 rounded-lg shadow-sm text-sm font-semibold transition-colors flex items-center gap-2">
|
||||
<Icon icon="mdi:logout" class="text-lg" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:palette-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Theme Settings
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Customize the appearance of your editor and dashboard. These settings are saved to your browser.</p>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:harddisk" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Storage Tracking
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10">
|
||||
<div class="mb-2 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-bold text-gray-900 dark:text-white">45 MB</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm mt-6">
|
||||
<div class="bg-white dark:bg-black/40 p-3 rounded-lg border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Documents</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">12 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-black/40 p-3 rounded-lg border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-purple-500"></div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Images & Assets</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">33 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,26 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { relative, sep } from 'node:path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
runes: ({ filename }) => {
|
||||
const relativePath = relative(import.meta.dirname, filename);
|
||||
const pathSegments = relativePath.toLowerCase().split(sep);
|
||||
const isExternalLibrary = pathSegments.includes('node_modules');
|
||||
|
||||
return isExternalLibrary ? undefined : true;
|
||||
}
|
||||
},
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: 'index.html' // Enable SPA mode
|
||||
}),
|
||||
prerender: {
|
||||
entries: ['*'],
|
||||
handleUnseenRoutes: 'ignore'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
Submodule
+1
Submodule typst added at d6848a802e
@@ -0,0 +1,24 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import topLevelAwait from 'vite-plugin-top-level-await';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), wasm(), topLevelAwait()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:3000',
|
||||
'/yjs': {
|
||||
target: 'ws://127.0.0.1:3000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['codemirror-lang-typst']
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user