Add cloud browsing, autosave, and editor improvements
This commit is contained in:
@@ -34,7 +34,7 @@ pub fn assets_dir(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn families_in(data: &[u8]) -> Vec<String> {
|
pub fn families_in(data: &[u8]) -> Vec<String> {
|
||||||
let mut families = BTreeSet::new();
|
let mut families = BTreeSet::new();
|
||||||
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) {
|
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) {
|
||||||
families.insert(font.info().family.clone());
|
families.insert(font.info().family.clone());
|
||||||
|
|||||||
+67
-1
@@ -1,4 +1,5 @@
|
|||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
@@ -9,7 +10,15 @@ pub struct Store {
|
|||||||
connection: Mutex<Connection>,
|
connection: Mutex<Connection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHEMA: [&str; 4] = [
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct DocumentLink {
|
||||||
|
pub document_id: String,
|
||||||
|
pub base_hash: String,
|
||||||
|
pub role: String,
|
||||||
|
pub base_content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA: [&str; 5] = [
|
||||||
"CREATE TABLE IF NOT EXISTS settings (
|
"CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -27,6 +36,13 @@ const SCHEMA: [&str; 4] = [
|
|||||||
content BLOB,
|
content BLOB,
|
||||||
PRIMARY KEY (project_path, file_path)
|
PRIMARY KEY (project_path, file_path)
|
||||||
)",
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS document_links (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
document_id TEXT NOT NULL,
|
||||||
|
base_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
base_content TEXT
|
||||||
|
)",
|
||||||
"CREATE TABLE IF NOT EXISTS thumbnails (
|
"CREATE TABLE IF NOT EXISTS thumbnails (
|
||||||
path TEXT PRIMARY KEY,
|
path TEXT PRIMARY KEY,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
@@ -257,6 +273,56 @@ impl Store {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn save_document_link(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
document_id: &str,
|
||||||
|
base_hash: &str,
|
||||||
|
role: &str,
|
||||||
|
base_content: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.with(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO document_links (path, document_id, base_hash, role, base_content)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
|
document_id = excluded.document_id,
|
||||||
|
base_hash = excluded.base_hash,
|
||||||
|
role = excluded.role,
|
||||||
|
base_content = excluded.base_content",
|
||||||
|
params![path, document_id, base_hash, role, base_content],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn document_link(&self, path: &str) -> Result<Option<DocumentLink>, String> {
|
||||||
|
self.with(|connection| {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT document_id, base_hash, role, base_content
|
||||||
|
FROM document_links WHERE path = ?1",
|
||||||
|
params![path],
|
||||||
|
|row| {
|
||||||
|
Ok(DocumentLink {
|
||||||
|
document_id: row.get(0)?,
|
||||||
|
base_hash: row.get(1)?,
|
||||||
|
role: row.get(2)?,
|
||||||
|
base_content: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forget_document_link(&self, path: &str) -> Result<(), String> {
|
||||||
|
self.with(|connection| {
|
||||||
|
connection.execute("DELETE FROM document_links WHERE path = ?1", params![path])?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn thumbnail(&self, path: &str, modified: i64) -> Result<Option<(String, String)>, String> {
|
pub fn thumbnail(&self, path: &str, modified: i64) -> Result<Option<(String, String)>, String> {
|
||||||
self.with(|connection| {
|
self.with(|connection| {
|
||||||
connection
|
connection
|
||||||
|
|||||||
+235
-1
@@ -57,6 +57,26 @@ fn load_project(
|
|||||||
Ok((dir, store.meta(project)?))
|
Ok((dir, store.meta(project)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AppInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub typst_version: String,
|
||||||
|
pub authors: String,
|
||||||
|
pub license: String,
|
||||||
|
pub tauri_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn app_info() -> AppInfo {
|
||||||
|
AppInfo {
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
typst_version: "0.14.2".to_string(),
|
||||||
|
authors: "SirBlobby".to_string(),
|
||||||
|
license: "Apache-2.0".to_string(),
|
||||||
|
tauri_version: tauri::VERSION.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_settings(app: AppHandle, store: State<'_, Store>) -> Result<Settings, String> {
|
fn get_settings(app: AppHandle, store: State<'_, Store>) -> Result<Settings, String> {
|
||||||
load_settings(&app, &store)
|
load_settings(&app, &store)
|
||||||
@@ -68,6 +88,8 @@ fn update_settings(
|
|||||||
store: State<'_, Store>,
|
store: State<'_, Store>,
|
||||||
workspace_root: Option<String>,
|
workspace_root: Option<String>,
|
||||||
server_url: Option<String>,
|
server_url: Option<String>,
|
||||||
|
autosave_seconds: Option<u32>,
|
||||||
|
sync_minutes: Option<u32>,
|
||||||
) -> Result<Settings, String> {
|
) -> Result<Settings, String> {
|
||||||
let mut settings = load_settings(&app, &store)?;
|
let mut settings = load_settings(&app, &store)?;
|
||||||
if let Some(root) = workspace_root {
|
if let Some(root) = workspace_root {
|
||||||
@@ -79,6 +101,12 @@ fn update_settings(
|
|||||||
if let Some(url) = server_url {
|
if let Some(url) = server_url {
|
||||||
settings.server_url = url.trim_end_matches('/').to_string();
|
settings.server_url = url.trim_end_matches('/').to_string();
|
||||||
}
|
}
|
||||||
|
if let Some(seconds) = autosave_seconds {
|
||||||
|
settings.autosave_seconds = seconds;
|
||||||
|
}
|
||||||
|
if let Some(minutes) = sync_minutes {
|
||||||
|
settings.sync_minutes = minutes;
|
||||||
|
}
|
||||||
save_settings(&store, &settings)?;
|
save_settings(&store, &settings)?;
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
@@ -331,6 +359,7 @@ fn compile_target(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
store: State<'_, Store>,
|
store: State<'_, Store>,
|
||||||
path: String,
|
path: String,
|
||||||
|
entrypoint: Option<String>,
|
||||||
overrides: Option<std::collections::HashMap<String, String>>,
|
overrides: Option<std::collections::HashMap<String, String>>,
|
||||||
) -> Result<CompileResult, CompileFailure> {
|
) -> Result<CompileResult, CompileFailure> {
|
||||||
let target = resolve_target(&app, &store, &path).map_err(failure)?;
|
let target = resolve_target(&app, &store, &path).map_err(failure)?;
|
||||||
@@ -340,7 +369,12 @@ fn compile_target(
|
|||||||
files.insert(file, content.into_bytes());
|
files.insert(file, content.into_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
compiler::compile_to_svg(target.entrypoint, files)
|
let entrypoint = match entrypoint {
|
||||||
|
Some(file) if files.contains_key(&file) => file,
|
||||||
|
_ => target.entrypoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
compiler::compile_to_svg(entrypoint, files)
|
||||||
.map_err(|diagnostics| CompileFailure { diagnostics })
|
.map_err(|diagnostics| CompileFailure { diagnostics })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +431,87 @@ fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, St
|
|||||||
assets::list_assets(&app, &store)
|
assets::list_assets(&app, &store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Resource {
|
||||||
|
pub name: String,
|
||||||
|
pub reference: String,
|
||||||
|
pub path: String,
|
||||||
|
pub scope: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub font_families: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_kind(name: &str) -> String {
|
||||||
|
if assets::is_font(name) {
|
||||||
|
"font"
|
||||||
|
} else if assets::is_image(name) {
|
||||||
|
"image"
|
||||||
|
} else {
|
||||||
|
"file"
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_resources(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<Vec<Resource>, String> {
|
||||||
|
let mut resources = Vec::new();
|
||||||
|
|
||||||
|
for asset in assets::list_assets(&app, &store)? {
|
||||||
|
resources.push(Resource {
|
||||||
|
reference: asset.name.clone(),
|
||||||
|
path: format!("{}/{}", assets::ASSETS_DIR, asset.name),
|
||||||
|
scope: "shared".to_string(),
|
||||||
|
kind: asset.kind,
|
||||||
|
size: asset.size,
|
||||||
|
font_families: asset.font_families,
|
||||||
|
name: asset.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = resolve_target(&app, &store, &path)?;
|
||||||
|
let prefix = if target.standalone {
|
||||||
|
path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("")
|
||||||
|
} else {
|
||||||
|
path.as_str()
|
||||||
|
};
|
||||||
|
|
||||||
|
for file in list_files(&target.root)? {
|
||||||
|
if file.path.to_lowercase().ends_with(".typ") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full = target.root.join(&file.path);
|
||||||
|
let font_families = if assets::is_font(&file.name) {
|
||||||
|
std::fs::read(&full)
|
||||||
|
.map(|data| assets::families_in(&data))
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
resources.push(Resource {
|
||||||
|
name: file.name,
|
||||||
|
reference: file.path.clone(),
|
||||||
|
path: if prefix.is_empty() {
|
||||||
|
file.path.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", prefix, file.path)
|
||||||
|
},
|
||||||
|
scope: "project".to_string(),
|
||||||
|
kind: resource_kind(&file.path),
|
||||||
|
size: file.size,
|
||||||
|
font_families,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resources)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn list_font_families(app: AppHandle, store: State<'_, Store>, path: Option<String>) -> Result<Vec<String>, String> {
|
fn list_font_families(app: AppHandle, store: State<'_, Store>, path: Option<String>) -> Result<Vec<String>, String> {
|
||||||
let files = match path {
|
let files = match path {
|
||||||
@@ -524,6 +639,115 @@ fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Spac
|
|||||||
sync::list_spaces(&server_url, &token)
|
sync::list_spaces(&server_url, &token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_list_folders(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
) -> Result<Vec<sync::CloudFolder>, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::list_folders(&server_url, &token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_list_documents(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
) -> Result<Vec<sync::CloudDocument>, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::list_documents(&server_url, &token, folder_id.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_list_shared(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
) -> Result<sync::SharedItems, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::list_shared(&server_url, &token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads a cloud document into the workspace and remembers where it came
|
||||||
|
/// from so it can be synced back.
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_download_document(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
document_id: String,
|
||||||
|
parent: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
let document = sync::pull_document(&server_url, &token, &document_id)?;
|
||||||
|
|
||||||
|
let mut name = document.title.replace('/', "-").trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
name = "document".to_string();
|
||||||
|
}
|
||||||
|
if !name.to_lowercase().ends_with(".typ") {
|
||||||
|
name.push_str(".typ");
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = join_path(&parent, &name);
|
||||||
|
let full = workspace_path(&app, &store, &path)?;
|
||||||
|
if let Some(dir) = full.parent() {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
std::fs::write(&full, &document.content).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
store.save_document_link(
|
||||||
|
&path,
|
||||||
|
&document_id,
|
||||||
|
&document.hash,
|
||||||
|
&document.role,
|
||||||
|
&document.content,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_sync_document(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<SyncReport, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::sync_document(&server_url, &token, &app, &store, &path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_resolve_document(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
path: String,
|
||||||
|
content: String,
|
||||||
|
server_hash: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::resolve_document_conflict(
|
||||||
|
&server_url,
|
||||||
|
&token,
|
||||||
|
&app,
|
||||||
|
&store,
|
||||||
|
&path,
|
||||||
|
&content,
|
||||||
|
&server_hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), String> {
|
||||||
|
store.forget_document_link(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_document_link(
|
||||||
|
store: State<'_, Store>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<Option<db::DocumentLink>, String> {
|
||||||
|
store.document_link(&path)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
|
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
|
||||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
@@ -667,6 +891,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
app_info,
|
||||||
get_settings,
|
get_settings,
|
||||||
update_settings,
|
update_settings,
|
||||||
browse_workspace,
|
browse_workspace,
|
||||||
@@ -686,6 +911,7 @@ pub fn run() {
|
|||||||
read_image,
|
read_image,
|
||||||
clear_thumbnails,
|
clear_thumbnails,
|
||||||
list_assets,
|
list_assets,
|
||||||
|
list_resources,
|
||||||
list_font_families,
|
list_font_families,
|
||||||
import_assets,
|
import_assets,
|
||||||
delete_asset,
|
delete_asset,
|
||||||
@@ -699,6 +925,14 @@ pub fn run() {
|
|||||||
cloud_logout,
|
cloud_logout,
|
||||||
cloud_account,
|
cloud_account,
|
||||||
cloud_list_spaces,
|
cloud_list_spaces,
|
||||||
|
cloud_list_folders,
|
||||||
|
cloud_list_documents,
|
||||||
|
cloud_list_shared,
|
||||||
|
cloud_download_document,
|
||||||
|
cloud_sync_document,
|
||||||
|
cloud_resolve_document,
|
||||||
|
cloud_document_link,
|
||||||
|
cloud_unlink_document,
|
||||||
cloud_create_space,
|
cloud_create_space,
|
||||||
cloud_delete_space,
|
cloud_delete_space,
|
||||||
cloud_clone_space,
|
cloud_clone_space,
|
||||||
|
|||||||
@@ -566,3 +566,285 @@ pub fn resolve_conflict(
|
|||||||
.insert(path.to_string(), server_hash.to_string());
|
.insert(path.to_string(), server_hash.to_string());
|
||||||
store.save_meta(project, meta)
|
store.save_meta(project, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub struct CloudFolder {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub struct CloudDocument {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub folder_id: Option<String>,
|
||||||
|
pub role: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct SharedItems {
|
||||||
|
pub documents: Vec<CloudDocument>,
|
||||||
|
pub spaces: Vec<SpaceSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_folders(server_url: &str, token: &str) -> Result<Vec<CloudFolder>, String> {
|
||||||
|
agent()
|
||||||
|
.get(&endpoint(server_url, "/folders"))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.call()
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<Vec<CloudFolder>>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_documents(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
) -> Result<Vec<CloudDocument>, String> {
|
||||||
|
let mut request = agent()
|
||||||
|
.get(&endpoint(server_url, "/documents"))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token));
|
||||||
|
|
||||||
|
if let Some(folder) = folder_id {
|
||||||
|
request = request.query("folder_id", folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
request
|
||||||
|
.call()
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<Vec<CloudDocument>>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_shared(server_url: &str, token: &str) -> Result<SharedItems, String> {
|
||||||
|
agent()
|
||||||
|
.get(&endpoint(server_url, "/shared"))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.call()
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<SharedItems>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct DocumentContent {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub role: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_document(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
document_id: &str,
|
||||||
|
) -> Result<DocumentContent, String> {
|
||||||
|
agent()
|
||||||
|
.get(&endpoint(server_url, &format!("/documents/{}", document_id)))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.call()
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<DocumentContent>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs one downloaded cloud document. The merge base is the copy stored when
|
||||||
|
/// the document was last exchanged with the server.
|
||||||
|
pub fn sync_document(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
store: &Store,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<SyncReport, String> {
|
||||||
|
let link = store
|
||||||
|
.document_link(path)?
|
||||||
|
.ok_or("This document is not linked to the cloud")?;
|
||||||
|
|
||||||
|
let full = crate::workspace::workspace_path(app, store, path)?;
|
||||||
|
let local = std::fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||||
|
let remote = pull_document(server_url, token, &link.document_id)?;
|
||||||
|
|
||||||
|
let mut report = SyncReport::default();
|
||||||
|
|
||||||
|
let local_hash = content_hash(local.as_bytes());
|
||||||
|
let editable = remote.role == "owner" || remote.role == "editor";
|
||||||
|
|
||||||
|
if local_hash == remote.hash {
|
||||||
|
store.save_document_link(path, &link.document_id, &remote.hash, &remote.role, &local)?;
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
if local_hash == link.base_hash {
|
||||||
|
std::fs::write(&full, &remote.content).map_err(|e| e.to_string())?;
|
||||||
|
store.save_document_link(
|
||||||
|
path,
|
||||||
|
&link.document_id,
|
||||||
|
&remote.hash,
|
||||||
|
&remote.role,
|
||||||
|
&remote.content,
|
||||||
|
)?;
|
||||||
|
report.pulled.push(path.to_string());
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
if remote.hash == link.base_hash {
|
||||||
|
if !editable {
|
||||||
|
return Err("You only have view access to this document".to_string());
|
||||||
|
}
|
||||||
|
match push_document(
|
||||||
|
server_url,
|
||||||
|
token,
|
||||||
|
&link.document_id,
|
||||||
|
&local,
|
||||||
|
Some(&link.base_hash),
|
||||||
|
)? {
|
||||||
|
PushResult::Applied => {
|
||||||
|
store.save_document_link(
|
||||||
|
path,
|
||||||
|
&link.document_id,
|
||||||
|
&local_hash,
|
||||||
|
&remote.role,
|
||||||
|
&local,
|
||||||
|
)?;
|
||||||
|
report.pushed.push(path.to_string());
|
||||||
|
}
|
||||||
|
PushResult::Conflict {
|
||||||
|
server_hash,
|
||||||
|
server_text,
|
||||||
|
} => {
|
||||||
|
report.conflicts.push(Conflict {
|
||||||
|
path: path.to_string(),
|
||||||
|
local_text: local,
|
||||||
|
remote_text: server_text.clone(),
|
||||||
|
merged_text: server_text,
|
||||||
|
server_hash,
|
||||||
|
auto_merged: false,
|
||||||
|
binary: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
match diffy::merge(&link.base_content, &local, &remote.content) {
|
||||||
|
Ok(merged) => {
|
||||||
|
std::fs::write(&full, &merged).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if editable {
|
||||||
|
match push_document(
|
||||||
|
server_url,
|
||||||
|
token,
|
||||||
|
&link.document_id,
|
||||||
|
&merged,
|
||||||
|
Some(&remote.hash),
|
||||||
|
)? {
|
||||||
|
PushResult::Applied => {
|
||||||
|
store.save_document_link(
|
||||||
|
path,
|
||||||
|
&link.document_id,
|
||||||
|
&content_hash(merged.as_bytes()),
|
||||||
|
&remote.role,
|
||||||
|
&merged,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
PushResult::Conflict {
|
||||||
|
server_hash,
|
||||||
|
server_text,
|
||||||
|
} => {
|
||||||
|
report.conflicts.push(Conflict {
|
||||||
|
path: path.to_string(),
|
||||||
|
local_text: merged.clone(),
|
||||||
|
remote_text: server_text.clone(),
|
||||||
|
merged_text: server_text,
|
||||||
|
server_hash,
|
||||||
|
auto_merged: false,
|
||||||
|
binary: false,
|
||||||
|
});
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report.merged.push(path.to_string());
|
||||||
|
}
|
||||||
|
Err(conflicted) => {
|
||||||
|
report.conflicts.push(Conflict {
|
||||||
|
path: path.to_string(),
|
||||||
|
local_text: local,
|
||||||
|
remote_text: remote.content,
|
||||||
|
merged_text: conflicted,
|
||||||
|
server_hash: remote.hash,
|
||||||
|
auto_merged: false,
|
||||||
|
binary: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a resolved cloud document and uploads it.
|
||||||
|
pub fn resolve_document_conflict(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
store: &Store,
|
||||||
|
path: &str,
|
||||||
|
content: &str,
|
||||||
|
server_hash: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let link = store
|
||||||
|
.document_link(path)?
|
||||||
|
.ok_or("This document is not linked to the cloud")?;
|
||||||
|
|
||||||
|
let full = crate::workspace::workspace_path(app, store, path)?;
|
||||||
|
std::fs::write(&full, content).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match push_document(server_url, token, &link.document_id, content, Some(server_hash))? {
|
||||||
|
PushResult::Applied => store.save_document_link(
|
||||||
|
path,
|
||||||
|
&link.document_id,
|
||||||
|
&content_hash(content.as_bytes()),
|
||||||
|
&link.role,
|
||||||
|
content,
|
||||||
|
),
|
||||||
|
PushResult::Conflict { .. } => {
|
||||||
|
Err("The document changed again in the cloud. Sync and merge once more.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_document(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
document_id: &str,
|
||||||
|
content: &str,
|
||||||
|
base_hash: Option<&str>,
|
||||||
|
) -> Result<PushResult, String> {
|
||||||
|
let response = agent()
|
||||||
|
.put(&endpoint(server_url, &format!("/documents/{}", document_id)))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({
|
||||||
|
"content": content,
|
||||||
|
"base_hash": base_hash,
|
||||||
|
}));
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(_) => Ok(PushResult::Applied),
|
||||||
|
Err(ureq::Error::Status(409, body)) => {
|
||||||
|
let conflict = body
|
||||||
|
.into_json::<ConflictBody>()
|
||||||
|
.map_err(|e| format!("Malformed conflict response: {}", e))?;
|
||||||
|
Ok(PushResult::Conflict {
|
||||||
|
server_hash: conflict.server_hash,
|
||||||
|
server_text: conflict.server_content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(other) => Err(describe(other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ pub struct Settings {
|
|||||||
pub account_email: Option<String>,
|
pub account_email: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub account_username: Option<String>,
|
pub account_username: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub autosave_seconds: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_minutes: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
@@ -49,6 +53,8 @@ impl Settings {
|
|||||||
device_token: None,
|
device_token: None,
|
||||||
account_email: None,
|
account_email: None,
|
||||||
account_username: None,
|
account_username: None,
|
||||||
|
autosave_seconds: 5,
|
||||||
|
sync_minutes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import Modal from "./Modal.svelte";
|
import Modal from "./Modal.svelte";
|
||||||
import * as api from "$lib/ts/api";
|
import * as api from "$lib/ts/api";
|
||||||
import type { Asset } from "$lib/ts/api";
|
import type { Resource } from "$lib/ts/api";
|
||||||
import { pickFiles } from "$lib/ts/import";
|
import { pickFiles } from "$lib/ts/import";
|
||||||
|
import { app } from "$lib/ts/state.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
oninsert?: (snippet: string) => void;
|
oninsert?: (snippet: string) => void;
|
||||||
@@ -13,14 +14,33 @@
|
|||||||
|
|
||||||
let { oninsert, onchanged, onclose }: Props = $props();
|
let { oninsert, onchanged, onclose }: Props = $props();
|
||||||
|
|
||||||
let assets = $state<Asset[]>([]);
|
let resources = $state<Resource[]>([]);
|
||||||
|
let previews = $state<Record<string, string>>({});
|
||||||
|
let query = $state("");
|
||||||
|
let scope = $state<"all" | "project" | "shared">("all");
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let copied = $state<string | null>(null);
|
let copied = $state<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = $derived(
|
||||||
|
resources.filter((resource) => {
|
||||||
|
if (scope !== "all" && resource.scope !== scope) return false;
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (!needle) return true;
|
||||||
|
return (
|
||||||
|
resource.name.toLowerCase().includes(needle) ||
|
||||||
|
resource.reference.toLowerCase().includes(needle) ||
|
||||||
|
resource.font_families.some((family) =>
|
||||||
|
family.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
if (!app.target) return;
|
||||||
try {
|
try {
|
||||||
assets = await api.listAssets();
|
resources = await api.listResources(app.target.path);
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
error = api.errorMessage(caught);
|
error = api.errorMessage(caught);
|
||||||
}
|
}
|
||||||
@@ -30,14 +50,42 @@
|
|||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importFiles() {
|
$effect(() => {
|
||||||
|
const images = filtered.filter((resource) => resource.kind === "image");
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const resource of images) {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (previews[resource.path]) continue;
|
||||||
|
try {
|
||||||
|
const result = await api.thumbnail(resource.path);
|
||||||
|
if (!cancelled && result.kind === "image") {
|
||||||
|
previews[resource.path] = result.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importInto(destination: "project" | "shared") {
|
||||||
const sources = await pickFiles("assets");
|
const sources = await pickFiles("assets");
|
||||||
if (sources.length === 0) return;
|
if (sources.length === 0) return;
|
||||||
|
|
||||||
busy = true;
|
busy = true;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
await api.importAssets(sources);
|
if (destination === "shared") {
|
||||||
|
await api.importAssets(sources);
|
||||||
|
} else if (app.target) {
|
||||||
|
await api.importIntoTarget(app.target.path, sources);
|
||||||
|
}
|
||||||
await refresh();
|
await refresh();
|
||||||
onchanged?.();
|
onchanged?.();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
@@ -47,9 +95,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(asset: Asset) {
|
async function remove(resource: Resource) {
|
||||||
try {
|
try {
|
||||||
await api.deleteAsset(asset.name);
|
if (resource.scope === "shared") {
|
||||||
|
await api.deleteAsset(resource.name);
|
||||||
|
} else {
|
||||||
|
await api.deleteEntry(resource.path);
|
||||||
|
}
|
||||||
|
delete previews[resource.path];
|
||||||
await refresh();
|
await refresh();
|
||||||
onchanged?.();
|
onchanged?.();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
@@ -57,17 +110,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function insert(asset: Asset) {
|
function insert(resource: Resource) {
|
||||||
if (asset.kind === "image") {
|
if (resource.kind === "image") {
|
||||||
oninsert?.(`#image("${asset.name}")`);
|
oninsert?.(`#image("${resource.reference}")`);
|
||||||
} else if (asset.font_families.length > 0) {
|
} else if (resource.font_families.length > 0) {
|
||||||
oninsert?.(`#set text(font: "${asset.font_families[0]}")`);
|
oninsert?.(`#set text(font: "${resource.font_families[0]}")`);
|
||||||
|
} else {
|
||||||
|
oninsert?.(`"${resource.reference}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyFamily(family: string) {
|
async function copyReference(value: string) {
|
||||||
await navigator.clipboard.writeText(family);
|
await navigator.clipboard.writeText(value);
|
||||||
copied = family;
|
copied = value;
|
||||||
setTimeout(() => (copied = null), 1200);
|
setTimeout(() => (copied = null), 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,80 +132,121 @@
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconFor: Record<Asset["kind"], string> = {
|
const iconFor: Record<string, string> = {
|
||||||
image: "ph:image",
|
image: "ph:image",
|
||||||
font: "ph:text-aa",
|
font: "ph:text-aa",
|
||||||
file: "ph:file",
|
file: "ph:file",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal title="Images and fonts" icon="ph:images" width="max-w-2xl" {onclose}>
|
<Modal title="Assets" icon="ph:images" width="max-w-3xl" {onclose}>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
<div class="flex items-center gap-2">
|
||||||
Files imported here are available to every project. Reference an image by
|
<div
|
||||||
its file name, and a font by its family name.
|
class="flex flex-1 items-center gap-2 rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 focus-within:border-[var(--color-accent)]"
|
||||||
</p>
|
>
|
||||||
|
<Icon icon="ph:magnifying-glass" class="text-[var(--color-ink-muted)]" />
|
||||||
|
<input
|
||||||
|
class="min-w-0 flex-1 bg-transparent text-sm focus:outline-none"
|
||||||
|
placeholder="Search images, fonts, and files"
|
||||||
|
bind:value={query}
|
||||||
|
/>
|
||||||
|
{#if query}
|
||||||
|
<button
|
||||||
|
class="text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||||
|
onclick={() => (query = "")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:x" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex rounded-md bg-[var(--color-surface-sunken)] p-0.5 text-xs">
|
||||||
|
{#each [["all", "All"], ["project", "This project"], ["shared", "Shared"]] as [value, label]}
|
||||||
|
<button
|
||||||
|
class="rounded px-2.5 py-1.5 transition
|
||||||
|
{scope === value
|
||||||
|
? 'bg-[var(--color-surface)] font-medium shadow-sm'
|
||||||
|
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
||||||
|
onclick={() => (scope = value as "all" | "project" | "shared")}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-xs text-[var(--color-danger)]">{error}</p>
|
<p class="text-xs text-[var(--color-danger)]">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if assets.length === 0}
|
{#if filtered.length === 0}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-[var(--color-line)] px-4 py-10 text-center"
|
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-[var(--color-line)] px-4 py-10 text-center"
|
||||||
>
|
>
|
||||||
<Icon icon="ph:image-square" class="text-4xl text-[var(--color-ink-muted)]" />
|
<Icon icon="ph:image-square" class="text-4xl text-[var(--color-ink-muted)]" />
|
||||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||||
No images or fonts imported yet.
|
{query ? "Nothing matches that search." : "No images or fonts yet."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-1">
|
<div
|
||||||
{#each assets as asset (asset.name)}
|
class="scroll-thin grid max-h-96 grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{#each filtered as resource (resource.path)}
|
||||||
<div
|
<div
|
||||||
class="group flex items-center gap-3 rounded-md border border-[var(--color-line)] px-3 py-2"
|
class="group relative flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] transition hover:border-[var(--color-accent)]"
|
||||||
>
|
>
|
||||||
<Icon
|
<button
|
||||||
icon={iconFor[asset.kind]}
|
class="flex h-20 items-center justify-center overflow-hidden bg-[var(--color-surface-muted)]"
|
||||||
class="text-lg text-[var(--color-accent)]"
|
onclick={() => insert(resource)}
|
||||||
/>
|
title="Insert into document"
|
||||||
|
>
|
||||||
<div class="min-w-0 flex-1">
|
{#if previews[resource.path]}
|
||||||
<p class="truncate text-xs font-medium">{asset.name}</p>
|
<img
|
||||||
{#if asset.font_families.length > 0}
|
src={previews[resource.path]}
|
||||||
<div class="mt-0.5 flex flex-wrap gap-1">
|
alt={resource.name}
|
||||||
{#each asset.font_families as family}
|
class="h-full w-full object-contain"
|
||||||
<button
|
/>
|
||||||
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-px text-[10px] text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
|
||||||
onclick={() => copyFamily(family)}
|
|
||||||
title="Copy family name"
|
|
||||||
>
|
|
||||||
{copied === family ? "Copied" : family}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-[10px] text-[var(--color-ink-muted)]">
|
<Icon
|
||||||
{formatSize(asset.size)}
|
icon={iconFor[resource.kind] ?? "ph:file"}
|
||||||
</p>
|
class="text-2xl text-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0.5 px-2 py-1.5">
|
||||||
|
<span class="truncate text-[11px] font-medium" title={resource.reference}>
|
||||||
|
{resource.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if resource.font_families.length > 0}
|
||||||
|
<button
|
||||||
|
class="truncate text-left text-[10px] text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||||
|
onclick={() => copyReference(resource.font_families[0])}
|
||||||
|
title="Copy family name"
|
||||||
|
>
|
||||||
|
{copied === resource.font_families[0]
|
||||||
|
? "Copied"
|
||||||
|
: resource.font_families[0]}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||||
|
{resource.scope === "shared" ? "Shared" : "Project"} · {formatSize(
|
||||||
|
resource.size,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if oninsert && (asset.kind === "image" || asset.font_families.length > 0)}
|
|
||||||
<button
|
|
||||||
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-muted)]"
|
|
||||||
onclick={() => insert(asset)}
|
|
||||||
>
|
|
||||||
Insert
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-danger)]"
|
class="absolute right-1 top-1 rounded bg-[var(--color-surface)]/90 p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:text-[var(--color-danger)]"
|
||||||
onclick={() => remove(asset)}
|
onclick={() => remove(resource)}
|
||||||
aria-label="Delete"
|
aria-label="Delete"
|
||||||
>
|
>
|
||||||
<Icon icon="ph:trash" />
|
<Icon icon="ph:trash" class="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -166,12 +262,20 @@
|
|||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
class="flex items-center gap-1.5 rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs transition hover:bg-[var(--color-surface-muted)] disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={importFiles}
|
onclick={() => importInto("shared")}
|
||||||
>
|
>
|
||||||
<Icon icon="ph:upload-simple" />
|
<Icon icon="ph:upload-simple" />
|
||||||
{busy ? "Importing..." : "Import files"}
|
Add to shared
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => importInto("project")}
|
||||||
|
>
|
||||||
|
<Icon icon="ph:upload-simple" />
|
||||||
|
{busy ? "Importing..." : "Add to project"}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
app,
|
app,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
browseTo,
|
browseTo,
|
||||||
|
openCloudFolder,
|
||||||
openTarget,
|
openTarget,
|
||||||
|
refreshCloud,
|
||||||
} from "$lib/ts/state.svelte";
|
} from "$lib/ts/state.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,11 +16,11 @@
|
|||||||
onnewproject: () => void;
|
onnewproject: () => void;
|
||||||
onnewdocument: () => void;
|
onnewdocument: () => void;
|
||||||
onupload: () => void;
|
onupload: () => void;
|
||||||
onassets: () => void;
|
|
||||||
onrename: (entry: BrowseEntry) => void;
|
onrename: (entry: BrowseEntry) => void;
|
||||||
ondelete: (entry: BrowseEntry) => void;
|
ondelete: (entry: BrowseEntry) => void;
|
||||||
onlink: (entry: BrowseEntry) => void;
|
onlink: (entry: BrowseEntry) => void;
|
||||||
onviewimage: (paths: string[], index: number) => void;
|
onviewimage: (paths: string[], index: number) => void;
|
||||||
|
ondownloaddocument: (documentId: string, title: string) => void;
|
||||||
onclonespace: (spaceId: string, name: string) => void;
|
onclonespace: (spaceId: string, name: string) => void;
|
||||||
ondeletespace: (spaceId: string) => void;
|
ondeletespace: (spaceId: string) => void;
|
||||||
onnewspace: () => void;
|
onnewspace: () => void;
|
||||||
@@ -30,11 +32,11 @@
|
|||||||
onnewproject,
|
onnewproject,
|
||||||
onnewdocument,
|
onnewdocument,
|
||||||
onupload,
|
onupload,
|
||||||
onassets,
|
|
||||||
onrename,
|
onrename,
|
||||||
ondelete,
|
ondelete,
|
||||||
onlink,
|
onlink,
|
||||||
onviewimage,
|
onviewimage,
|
||||||
|
ondownloaddocument,
|
||||||
onclonespace,
|
onclonespace,
|
||||||
ondeletespace,
|
ondeletespace,
|
||||||
onnewspace,
|
onnewspace,
|
||||||
@@ -45,6 +47,12 @@
|
|||||||
|
|
||||||
const trail = $derived(breadcrumbs());
|
const trail = $derived(breadcrumbs());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (app.scope === "cloud" && app.account) {
|
||||||
|
refreshCloud();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const containers = $derived(
|
const containers = $derived(
|
||||||
app.entries.filter(
|
app.entries.filter(
|
||||||
(entry) => entry.kind === "folder" || entry.kind === "project",
|
(entry) => entry.kind === "folder" || entry.kind === "project",
|
||||||
@@ -221,13 +229,6 @@
|
|||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
{#if app.scope === "local"}
|
{#if app.scope === "local"}
|
||||||
<button
|
|
||||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
|
||||||
onclick={onassets}
|
|
||||||
>
|
|
||||||
<Icon icon="ph:images" />
|
|
||||||
Assets
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||||
onclick={onupload}
|
onclick={onupload}
|
||||||
@@ -454,14 +455,105 @@
|
|||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if app.spaces.length === 0}
|
|
||||||
<div
|
|
||||||
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
|
|
||||||
>
|
|
||||||
<Icon icon="ph:cloud" class="text-5xl" />
|
|
||||||
<p class="text-sm">No cloud spaces yet.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="mb-3 flex items-center gap-1 text-xs">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
|
||||||
|
{app.cloudFolder === null ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
|
||||||
|
onclick={() => openCloudFolder(null)}
|
||||||
|
>
|
||||||
|
<Icon icon="ph:cloud" />
|
||||||
|
My Drive
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
|
||||||
|
{app.cloudFolder === 'shared'
|
||||||
|
? 'font-medium'
|
||||||
|
: 'text-[var(--color-ink-muted)]'}"
|
||||||
|
onclick={() => openCloudFolder("shared")}
|
||||||
|
>
|
||||||
|
<Icon icon="ph:users-three" />
|
||||||
|
Shared with me
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if app.cloudLoading}
|
||||||
|
<Icon
|
||||||
|
icon="ph:circle-notch"
|
||||||
|
class="animate-spin text-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if app.cloudFolders.length > 0}
|
||||||
|
<div
|
||||||
|
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2"
|
||||||
|
>
|
||||||
|
{#each app.cloudFolders as folder (folder.id)}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
|
||||||
|
onclick={() => openCloudFolder(folder.id)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="ph:folder-fill"
|
||||||
|
class="shrink-0 text-2xl text-[var(--color-ink-muted)]"
|
||||||
|
/>
|
||||||
|
<span class="truncate text-xs font-medium">{folder.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if app.cloudDocuments.length > 0}
|
||||||
|
<h2
|
||||||
|
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||||
|
>
|
||||||
|
Documents
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
|
||||||
|
>
|
||||||
|
{#each app.cloudDocuments as document (document.id)}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="ph:file-text"
|
||||||
|
class="text-xl text-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
<span class="truncate text-xs font-medium" title={document.title}>
|
||||||
|
{document.title}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||||
|
{document.role} · {formatDate(document.updated_at)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="mt-1 flex items-center justify-center gap-1 rounded border border-[var(--color-line)] px-2 py-1 text-[10px] hover:bg-[var(--color-surface-muted)]"
|
||||||
|
onclick={() => ondownloaddocument(document.id, document.title)}
|
||||||
|
>
|
||||||
|
<Icon icon="ph:download-simple" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if app.spaces.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:cloud" class="text-5xl" />
|
||||||
|
<p class="text-sm">Nothing here yet.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if app.spaces.length > 0}
|
||||||
|
<h2
|
||||||
|
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||||
|
>
|
||||||
|
Spaces
|
||||||
|
</h2>
|
||||||
|
{/if}
|
||||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
|
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
|
||||||
{#each app.spaces as space (space.id)}
|
{#each app.spaces as space (space.id)}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
import * as api from "$lib/ts/api";
|
||||||
|
import type { AppInfo } from "$lib/ts/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onclose }: Props = $props();
|
||||||
|
|
||||||
|
let info = $state<AppInfo | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api
|
||||||
|
.appInfo()
|
||||||
|
.then((result) => (info = result))
|
||||||
|
.catch(() => (info = null));
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{
|
||||||
|
label: "Typst Documentation",
|
||||||
|
url: "https://typst.app/docs/",
|
||||||
|
icon: "ph:book-open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Typst Universe",
|
||||||
|
url: "https://typst.app/universe/",
|
||||||
|
icon: "ph:planet",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title="About Typst Desktop" icon="ph:info" {onclose}>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Icon icon="ph:file-code" class="text-3xl text-[var(--color-accent)]" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold">Typst Desktop</p>
|
||||||
|
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||||
|
A local editor for Typst documents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if info}
|
||||||
|
<dl class="flex flex-col gap-1 text-xs">
|
||||||
|
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
|
||||||
|
<dt class="text-[var(--color-ink-muted)]">Version</dt>
|
||||||
|
<dd class="font-medium">{info.version}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
|
||||||
|
<dt class="text-[var(--color-ink-muted)]">Typst</dt>
|
||||||
|
<dd class="font-medium">{info.typst_version}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
|
||||||
|
<dt class="text-[var(--color-ink-muted)]">Tauri</dt>
|
||||||
|
<dd class="font-medium">{info.tauri_version}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
|
||||||
|
<dt class="text-[var(--color-ink-muted)]">Author</dt>
|
||||||
|
<dd class="font-medium">{info.authors}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1.5">
|
||||||
|
<dt class="text-[var(--color-ink-muted)]">License</dt>
|
||||||
|
<dd class="font-medium">{info.license}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
{#each links as link}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 rounded-md border border-[var(--color-line)] px-3 py-2 text-xs transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-muted)]"
|
||||||
|
onclick={() => openUrl(link.url)}
|
||||||
|
>
|
||||||
|
<Icon icon={link.icon} class="text-base text-[var(--color-accent)]" />
|
||||||
|
<span class="flex-1 text-left">{link.label}</span>
|
||||||
|
<Icon
|
||||||
|
icon="ph:arrow-square-out"
|
||||||
|
class="text-[var(--color-ink-muted)]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3 text-[11px] leading-relaxed text-[var(--color-ink-muted)]"
|
||||||
|
>
|
||||||
|
Typst Desktop is a community application and is not affiliated with,
|
||||||
|
endorsed by, or supported by the official Typst project. The links above
|
||||||
|
open the official Typst website in your browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||||
|
onclick={onclose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
@@ -3,7 +3,14 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import Modal from "./Modal.svelte";
|
import Modal from "./Modal.svelte";
|
||||||
import * as api from "$lib/ts/api";
|
import * as api from "$lib/ts/api";
|
||||||
import { app, applyTheme, refreshEntries, setError } from "$lib/ts/state.svelte";
|
import { untrack } from "svelte";
|
||||||
|
import {
|
||||||
|
app,
|
||||||
|
applyTheme,
|
||||||
|
refreshEntries,
|
||||||
|
restartAutoSync,
|
||||||
|
setError,
|
||||||
|
} from "$lib/ts/state.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
@@ -12,10 +19,26 @@
|
|||||||
|
|
||||||
let { onclose, onsignin }: Props = $props();
|
let { onclose, onsignin }: Props = $props();
|
||||||
|
|
||||||
let workspaceRoot = $state(app.settings?.workspace_root ?? "");
|
let workspaceRoot = $state(untrack(() => app.settings?.workspace_root ?? ""));
|
||||||
let serverUrl = $state(app.settings?.server_url ?? "");
|
let serverUrl = $state(untrack(() => app.settings?.server_url ?? ""));
|
||||||
|
let autosaveSeconds = $state(untrack(() => app.settings?.autosave_seconds ?? 0));
|
||||||
|
let syncMinutes = $state(untrack(() => app.settings?.sync_minutes ?? 0));
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
|
const autosaveOptions = [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 5, label: "5 seconds" },
|
||||||
|
{ value: 10, label: "10 seconds" },
|
||||||
|
{ value: 15, label: "15 seconds" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const syncOptions = [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 1, label: "1 minute" },
|
||||||
|
{ value: 2, label: "2 minutes" },
|
||||||
|
{ value: 5, label: "5 minutes" },
|
||||||
|
];
|
||||||
|
|
||||||
async function browse() {
|
async function browse() {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
if (typeof selected === "string") {
|
if (typeof selected === "string") {
|
||||||
@@ -29,7 +52,10 @@
|
|||||||
app.settings = await api.updateSettings({
|
app.settings = await api.updateSettings({
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
|
autosaveSeconds,
|
||||||
|
syncMinutes,
|
||||||
});
|
});
|
||||||
|
restartAutoSync();
|
||||||
await refreshEntries();
|
await refreshEntries();
|
||||||
onclose();
|
onclose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -81,6 +107,37 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="font-medium text-[var(--color-ink-muted)]">Autosave</span>
|
||||||
|
<select
|
||||||
|
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
bind:value={autosaveSeconds}
|
||||||
|
>
|
||||||
|
{#each autosaveOptions as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span class="text-[var(--color-ink-muted)]">
|
||||||
|
Saves the file being edited after you stop typing.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="font-medium text-[var(--color-ink-muted)]">Automatic sync</span>
|
||||||
|
<select
|
||||||
|
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
bind:value={syncMinutes}
|
||||||
|
>
|
||||||
|
{#each syncOptions as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span class="text-[var(--color-ink-muted)]">
|
||||||
|
Pulls and pushes cloud-linked projects on a timer. Conflicts pause
|
||||||
|
syncing until they are resolved.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3"
|
class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3"
|
||||||
>
|
>
|
||||||
|
|||||||
+83
-1
@@ -6,6 +6,8 @@ export interface Settings {
|
|||||||
device_token: string | null;
|
device_token: string | null;
|
||||||
account_email: string | null;
|
account_email: string | null;
|
||||||
account_username: string | null;
|
account_username: string | null;
|
||||||
|
autosave_seconds: number;
|
||||||
|
sync_minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
@@ -143,8 +145,9 @@ export const setTargetEntrypoint = (path: string, entrypoint: string) =>
|
|||||||
|
|
||||||
export const compileTarget = (
|
export const compileTarget = (
|
||||||
path: string,
|
path: string,
|
||||||
|
entrypoint?: string,
|
||||||
overrides?: Record<string, string>,
|
overrides?: Record<string, string>,
|
||||||
) => invoke<CompileResult>("compile_target", { path, overrides });
|
) => invoke<CompileResult>("compile_target", { path, entrypoint, overrides });
|
||||||
|
|
||||||
export const exportTarget = (
|
export const exportTarget = (
|
||||||
path: string,
|
path: string,
|
||||||
@@ -161,6 +164,19 @@ export interface Asset {
|
|||||||
|
|
||||||
export const listAssets = () => invoke<Asset[]>("list_assets");
|
export const listAssets = () => invoke<Asset[]>("list_assets");
|
||||||
|
|
||||||
|
export interface Resource {
|
||||||
|
name: string;
|
||||||
|
reference: string;
|
||||||
|
path: string;
|
||||||
|
scope: "shared" | "project";
|
||||||
|
kind: "font" | "image" | "file";
|
||||||
|
size: number;
|
||||||
|
font_families: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listResources = (path: string) =>
|
||||||
|
invoke<Resource[]>("list_resources", { path });
|
||||||
|
|
||||||
export interface Thumbnail {
|
export interface Thumbnail {
|
||||||
kind: "svg" | "image";
|
kind: "svg" | "image";
|
||||||
data: string;
|
data: string;
|
||||||
@@ -204,11 +220,23 @@ export const importIntoTarget = (path: string, sources: string[]) =>
|
|||||||
export const importIntoFolder = (parent: string, sources: string[]) =>
|
export const importIntoFolder = (parent: string, sources: string[]) =>
|
||||||
invoke<string[]>("import_into_folder", { parent, sources });
|
invoke<string[]>("import_into_folder", { parent, sources });
|
||||||
|
|
||||||
|
export interface AppInfo {
|
||||||
|
version: string;
|
||||||
|
typst_version: string;
|
||||||
|
authors: string;
|
||||||
|
license: string;
|
||||||
|
tauri_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appInfo = () => invoke<AppInfo>("app_info");
|
||||||
|
|
||||||
export const getSettings = () => invoke<Settings>("get_settings");
|
export const getSettings = () => invoke<Settings>("get_settings");
|
||||||
|
|
||||||
export const updateSettings = (changes: {
|
export const updateSettings = (changes: {
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
|
autosaveSeconds?: number;
|
||||||
|
syncMinutes?: number;
|
||||||
}) => invoke<Settings>("update_settings", changes);
|
}) => invoke<Settings>("update_settings", changes);
|
||||||
|
|
||||||
export const cloudLogin = (
|
export const cloudLogin = (
|
||||||
@@ -224,6 +252,60 @@ export const cloudAccount = () => invoke<Account | null>("cloud_account");
|
|||||||
export const cloudListSpaces = () =>
|
export const cloudListSpaces = () =>
|
||||||
invoke<SpaceSummary[]>("cloud_list_spaces");
|
invoke<SpaceSummary[]>("cloud_list_spaces");
|
||||||
|
|
||||||
|
export interface CloudFolder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDocument {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
folder_id: string | null;
|
||||||
|
role: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedItems {
|
||||||
|
documents: CloudDocument[];
|
||||||
|
spaces: SpaceSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentLink {
|
||||||
|
document_id: string;
|
||||||
|
base_hash: string;
|
||||||
|
role: string;
|
||||||
|
base_content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudListFolders = () =>
|
||||||
|
invoke<CloudFolder[]>("cloud_list_folders");
|
||||||
|
|
||||||
|
export const cloudListDocuments = (folderId?: string | null) =>
|
||||||
|
invoke<CloudDocument[]>("cloud_list_documents", {
|
||||||
|
folderId: folderId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cloudListShared = () => invoke<SharedItems>("cloud_list_shared");
|
||||||
|
|
||||||
|
export const cloudDownloadDocument = (documentId: string, parent: string) =>
|
||||||
|
invoke<string>("cloud_download_document", { documentId, parent });
|
||||||
|
|
||||||
|
export const cloudSyncDocument = (path: string) =>
|
||||||
|
invoke<SyncReport>("cloud_sync_document", { path });
|
||||||
|
|
||||||
|
export const cloudResolveDocument = (
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
serverHash: string,
|
||||||
|
) => invoke<void>("cloud_resolve_document", { path, content, serverHash });
|
||||||
|
|
||||||
|
export const cloudDocumentLink = (path: string) =>
|
||||||
|
invoke<DocumentLink | null>("cloud_document_link", { path });
|
||||||
|
|
||||||
|
export const cloudUnlinkDocument = (path: string) =>
|
||||||
|
invoke<void>("cloud_unlink_document", { path });
|
||||||
|
|
||||||
export const cloudCreateSpace = (name: string) =>
|
export const cloudCreateSpace = (name: string) =>
|
||||||
invoke<SpaceSummary>("cloud_create_space", { name });
|
invoke<SpaceSummary>("cloud_create_space", { name });
|
||||||
|
|
||||||
|
|||||||
+131
-5
@@ -2,9 +2,12 @@ import * as api from "./api";
|
|||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
BrowseEntry,
|
BrowseEntry,
|
||||||
|
CloudDocument,
|
||||||
|
CloudFolder,
|
||||||
CompileResult,
|
CompileResult,
|
||||||
Conflict,
|
Conflict,
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
|
DocumentLink,
|
||||||
Settings,
|
Settings,
|
||||||
SpaceSummary,
|
SpaceSummary,
|
||||||
TargetInfo,
|
TargetInfo,
|
||||||
@@ -23,6 +26,11 @@ interface AppState {
|
|||||||
currentDir: string;
|
currentDir: string;
|
||||||
entries: BrowseEntry[];
|
entries: BrowseEntry[];
|
||||||
spaces: SpaceSummary[];
|
spaces: SpaceSummary[];
|
||||||
|
cloudFolder: string | null | "shared";
|
||||||
|
cloudFolders: CloudFolder[];
|
||||||
|
cloudDocuments: CloudDocument[];
|
||||||
|
cloudLoading: boolean;
|
||||||
|
documentLink: DocumentLink | null;
|
||||||
|
|
||||||
target: TargetInfo | null;
|
target: TargetInfo | null;
|
||||||
activePath: string | null;
|
activePath: string | null;
|
||||||
@@ -49,6 +57,11 @@ export const app = $state<AppState>({
|
|||||||
currentDir: "",
|
currentDir: "",
|
||||||
entries: [],
|
entries: [],
|
||||||
spaces: [],
|
spaces: [],
|
||||||
|
cloudFolder: null,
|
||||||
|
cloudFolders: [],
|
||||||
|
cloudDocuments: [],
|
||||||
|
cloudLoading: false,
|
||||||
|
documentLink: null,
|
||||||
|
|
||||||
target: null,
|
target: null,
|
||||||
activePath: null,
|
activePath: null,
|
||||||
@@ -102,6 +115,7 @@ export async function bootstrap() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
app.settings = await api.getSettings();
|
app.settings = await api.getSettings();
|
||||||
|
restartAutoSync();
|
||||||
await browseTo("");
|
await browseTo("");
|
||||||
await refreshAccount();
|
await refreshAccount();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -144,6 +158,53 @@ export async function refreshSpaces() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshCloud() {
|
||||||
|
if (!app.account) return;
|
||||||
|
|
||||||
|
app.cloudLoading = true;
|
||||||
|
try {
|
||||||
|
if (app.cloudFolder === "shared") {
|
||||||
|
const shared = await api.cloudListShared();
|
||||||
|
app.cloudDocuments = shared.documents;
|
||||||
|
app.spaces = shared.spaces;
|
||||||
|
app.cloudFolders = [];
|
||||||
|
} else {
|
||||||
|
const [folders, documents, spaces] = await Promise.all([
|
||||||
|
api.cloudListFolders(),
|
||||||
|
api.cloudListDocuments(app.cloudFolder),
|
||||||
|
api.cloudListSpaces(),
|
||||||
|
]);
|
||||||
|
app.cloudFolders = folders.filter(
|
||||||
|
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
|
||||||
|
);
|
||||||
|
app.cloudDocuments = documents;
|
||||||
|
app.spaces = spaces;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error);
|
||||||
|
} finally {
|
||||||
|
app.cloudLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCloudFolder(id: string | null | "shared") {
|
||||||
|
app.cloudFolder = id;
|
||||||
|
await refreshCloud();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadDocument(documentId: string, title: string) {
|
||||||
|
try {
|
||||||
|
const path = await api.cloudDownloadDocument(documentId, "");
|
||||||
|
app.scope = "local";
|
||||||
|
await browseTo("");
|
||||||
|
setStatus(`Downloaded '${title}' to this device`);
|
||||||
|
return path;
|
||||||
|
} catch (error) {
|
||||||
|
setError(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function openTarget(path: string) {
|
export async function openTarget(path: string) {
|
||||||
try {
|
try {
|
||||||
const target = await api.targetInfo(path);
|
const target = await api.targetInfo(path);
|
||||||
@@ -157,6 +218,10 @@ export async function openTarget(path: string) {
|
|||||||
app.lspStatus = "off";
|
app.lspStatus = "off";
|
||||||
clearMessages();
|
clearMessages();
|
||||||
|
|
||||||
|
app.documentLink = target.standalone
|
||||||
|
? await api.cloudDocumentLink(path).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const preferred =
|
const preferred =
|
||||||
target.files.find((file) => file.path === target.entrypoint) ??
|
target.files.find((file) => file.path === target.entrypoint) ??
|
||||||
target.files.find((file) => file.path.endsWith(".typ")) ??
|
target.files.find((file) => file.path.endsWith(".typ")) ??
|
||||||
@@ -170,6 +235,7 @@ export async function openTarget(path: string) {
|
|||||||
|
|
||||||
export async function closeTarget() {
|
export async function closeTarget() {
|
||||||
cancelScheduledCompile();
|
cancelScheduledCompile();
|
||||||
|
cancelAutosave();
|
||||||
if (app.dirty) await saveActiveFile();
|
if (app.dirty) await saveActiveFile();
|
||||||
app.view = "files";
|
app.view = "files";
|
||||||
app.target = null;
|
app.target = null;
|
||||||
@@ -194,6 +260,7 @@ export async function openFile(file: string) {
|
|||||||
if (!app.target) return;
|
if (!app.target) return;
|
||||||
|
|
||||||
cancelScheduledCompile();
|
cancelScheduledCompile();
|
||||||
|
cancelAutosave();
|
||||||
|
|
||||||
if (app.dirty && app.activePath) await saveActiveFile();
|
if (app.dirty && app.activePath) await saveActiveFile();
|
||||||
|
|
||||||
@@ -242,7 +309,15 @@ export async function compile() {
|
|||||||
app.compiling = true;
|
app.compiling = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.compileTarget(app.target.path, liveOverrides());
|
const previewFile =
|
||||||
|
app.activePath && app.activePath.toLowerCase().endsWith(".typ")
|
||||||
|
? app.activePath
|
||||||
|
: undefined;
|
||||||
|
const result = await api.compileTarget(
|
||||||
|
app.target.path,
|
||||||
|
previewFile,
|
||||||
|
liveOverrides(),
|
||||||
|
);
|
||||||
app.compiled = result;
|
app.compiled = result;
|
||||||
app.diagnostics = result.diagnostics;
|
app.diagnostics = result.diagnostics;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -280,8 +355,57 @@ export function cancelScheduledCompile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function scheduleAutosave() {
|
||||||
|
const seconds = app.settings?.autosave_seconds ?? 0;
|
||||||
|
if (autosaveTimer) clearTimeout(autosaveTimer);
|
||||||
|
if (seconds <= 0) return;
|
||||||
|
|
||||||
|
autosaveTimer = setTimeout(() => {
|
||||||
|
autosaveTimer = null;
|
||||||
|
if (app.dirty) saveActiveFile();
|
||||||
|
}, seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAutosave() {
|
||||||
|
if (autosaveTimer) {
|
||||||
|
clearTimeout(autosaveTimer);
|
||||||
|
autosaveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
export function restartAutoSync() {
|
||||||
|
if (syncTimer) {
|
||||||
|
clearInterval(syncTimer);
|
||||||
|
syncTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = app.settings?.sync_minutes ?? 0;
|
||||||
|
if (minutes <= 0) return;
|
||||||
|
|
||||||
|
syncTimer = setInterval(() => {
|
||||||
|
autoSync();
|
||||||
|
}, minutes * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoSync() {
|
||||||
|
if (!app.account || app.syncing) return;
|
||||||
|
if (app.conflicts.length > 0) return;
|
||||||
|
|
||||||
|
const linked = app.target?.space_id || app.documentLink;
|
||||||
|
const project = linked ? app.target?.path : null;
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
if (app.dirty) await saveActiveFile();
|
||||||
|
await runSync("sync", project, true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveAndCompile() {
|
export async function saveAndCompile() {
|
||||||
cancelScheduledCompile();
|
cancelScheduledCompile();
|
||||||
|
cancelAutosave();
|
||||||
await saveActiveFile();
|
await saveActiveFile();
|
||||||
await compile();
|
await compile();
|
||||||
}
|
}
|
||||||
@@ -289,15 +413,17 @@ export async function saveAndCompile() {
|
|||||||
export async function runSync(
|
export async function runSync(
|
||||||
action: "sync" | "push" | "pull",
|
action: "sync" | "push" | "pull",
|
||||||
project = app.target?.path,
|
project = app.target?.path,
|
||||||
|
quiet = false,
|
||||||
) {
|
) {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
|
||||||
app.syncing = true;
|
app.syncing = true;
|
||||||
clearMessages();
|
if (!quiet) clearMessages();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const report =
|
const report = app.documentLink
|
||||||
action === "push"
|
? await api.cloudSyncDocument(project)
|
||||||
|
: action === "push"
|
||||||
? await api.cloudPush(project)
|
? await api.cloudPush(project)
|
||||||
: action === "pull"
|
: action === "pull"
|
||||||
? await api.cloudPull(project)
|
? await api.cloudPull(project)
|
||||||
@@ -307,7 +433,7 @@ export async function runSync(
|
|||||||
|
|
||||||
if (report.conflicts.length > 0) {
|
if (report.conflicts.length > 0) {
|
||||||
setError(`${report.conflicts.length} file(s) need conflict resolution`);
|
setError(`${report.conflicts.length} file(s) need conflict resolution`);
|
||||||
} else {
|
} else if (!quiet) {
|
||||||
setStatus(summarize(report));
|
setStatus(summarize(report));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-11
@@ -16,6 +16,7 @@
|
|||||||
import AssetsModal from "$lib/components/AssetsModal.svelte";
|
import AssetsModal from "$lib/components/AssetsModal.svelte";
|
||||||
import WindowControls from "$lib/components/WindowControls.svelte";
|
import WindowControls from "$lib/components/WindowControls.svelte";
|
||||||
import ImageViewer from "$lib/components/ImageViewer.svelte";
|
import ImageViewer from "$lib/components/ImageViewer.svelte";
|
||||||
|
import InfoModal from "$lib/components/InfoModal.svelte";
|
||||||
import EditorToolbar from "$lib/components/EditorToolbar.svelte";
|
import EditorToolbar from "$lib/components/EditorToolbar.svelte";
|
||||||
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
clearMessages,
|
clearMessages,
|
||||||
closeTarget,
|
closeTarget,
|
||||||
compile,
|
compile,
|
||||||
|
downloadDocument,
|
||||||
openFile,
|
openFile,
|
||||||
openTarget,
|
openTarget,
|
||||||
refreshAccount,
|
refreshAccount,
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
refreshTarget,
|
refreshTarget,
|
||||||
runSync,
|
runSync,
|
||||||
saveAndCompile,
|
saveAndCompile,
|
||||||
|
scheduleAutosave,
|
||||||
scheduleCompile,
|
scheduleCompile,
|
||||||
setError,
|
setError,
|
||||||
setStatus,
|
setStatus,
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
| { kind: "delete-file"; path: string }
|
| { kind: "delete-file"; path: string }
|
||||||
| { kind: "login" }
|
| { kind: "login" }
|
||||||
| { kind: "settings" }
|
| { kind: "settings" }
|
||||||
|
| { kind: "info" }
|
||||||
| { kind: "assets" }
|
| { kind: "assets" }
|
||||||
| { kind: "page-settings" }
|
| { kind: "page-settings" }
|
||||||
| { kind: "conflicts" };
|
| { kind: "conflicts" };
|
||||||
@@ -231,6 +235,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveDocumentConflicts = (resolutions: api.Resolution[]) =>
|
||||||
|
guard(async () => {
|
||||||
|
for (const resolution of resolutions) {
|
||||||
|
await api.cloudResolveDocument(
|
||||||
|
app.target!.path,
|
||||||
|
resolution.content,
|
||||||
|
resolution.server_hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.conflicts = [];
|
||||||
|
if (app.activePath) await openFile(app.activePath);
|
||||||
|
setStatus("Conflicts resolved and uploaded");
|
||||||
|
});
|
||||||
|
|
||||||
const resolveConflicts = (resolutions: api.Resolution[]) =>
|
const resolveConflicts = (resolutions: api.Resolution[]) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
const report = await api.cloudResolveConflicts(
|
const report = await api.cloudResolveConflicts(
|
||||||
@@ -365,14 +383,6 @@
|
|||||||
{lspLabel[app.lspStatus]}
|
{lspLabel[app.lspStatus]}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
|
||||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
|
||||||
onclick={() => (dialog = { kind: "assets" })}
|
|
||||||
>
|
|
||||||
<Icon icon="ph:images" />
|
|
||||||
Assets
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||||
onclick={saveAndCompile}
|
onclick={saveAndCompile}
|
||||||
@@ -402,7 +412,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if app.target?.space_id}
|
{#if app.target?.space_id || app.documentLink}
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||||
disabled={app.syncing}
|
disabled={app.syncing}
|
||||||
@@ -425,6 +435,14 @@
|
|||||||
<Icon icon="ph:gear-six" />
|
<Icon icon="ph:gear-six" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||||
|
onclick={() => (dialog = { kind: "info" })}
|
||||||
|
aria-label="About"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:info" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="ml-1 h-5 w-px bg-[var(--color-line)]"></div>
|
<div class="ml-1 h-5 w-px bg-[var(--color-line)]"></div>
|
||||||
|
|
||||||
<WindowControls />
|
<WindowControls />
|
||||||
@@ -461,11 +479,12 @@
|
|||||||
onnewfolder={() => (dialog = { kind: "new-folder" })}
|
onnewfolder={() => (dialog = { kind: "new-folder" })}
|
||||||
onnewdocument={() => (dialog = { kind: "new-document" })}
|
onnewdocument={() => (dialog = { kind: "new-document" })}
|
||||||
onupload={importFiles}
|
onupload={importFiles}
|
||||||
onassets={() => (dialog = { kind: "assets" })}
|
|
||||||
onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
|
onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
|
||||||
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
|
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
|
||||||
onlink={(entry) => (dialog = { kind: "link-entry", entry })}
|
onlink={(entry) => (dialog = { kind: "link-entry", entry })}
|
||||||
onviewimage={(paths, index) => (imageViewer = { paths, index })}
|
onviewimage={(paths, index) => (imageViewer = { paths, index })}
|
||||||
|
ondownloaddocument={(documentId, title) =>
|
||||||
|
downloadDocument(documentId, title)}
|
||||||
onnewspace={() => (dialog = { kind: "new-space" })}
|
onnewspace={() => (dialog = { kind: "new-space" })}
|
||||||
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
|
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
|
||||||
ondeletespace={(id) => (dialog = { kind: "delete-space", id })}
|
ondeletespace={(id) => (dialog = { kind: "delete-space", id })}
|
||||||
@@ -553,6 +572,7 @@
|
|||||||
app.editorContent = value;
|
app.editorContent = value;
|
||||||
app.dirty = true;
|
app.dirty = true;
|
||||||
scheduleCompile();
|
scheduleCompile();
|
||||||
|
scheduleAutosave();
|
||||||
}}
|
}}
|
||||||
onsave={saveAndCompile}
|
onsave={saveAndCompile}
|
||||||
onlspstatus={(status) => (app.lspStatus = status)}
|
onlspstatus={(status) => (app.lspStatus = status)}
|
||||||
@@ -746,6 +766,8 @@
|
|||||||
/>
|
/>
|
||||||
{:else if dialog.kind === "settings"}
|
{:else if dialog.kind === "settings"}
|
||||||
<SettingsModal onclose={close} onsignin={() => (dialog = { kind: "login" })} />
|
<SettingsModal onclose={close} onsignin={() => (dialog = { kind: "login" })} />
|
||||||
|
{:else if dialog.kind === "info"}
|
||||||
|
<InfoModal onclose={close} />
|
||||||
{:else if dialog.kind === "assets"}
|
{:else if dialog.kind === "assets"}
|
||||||
<AssetsModal
|
<AssetsModal
|
||||||
oninsert={app.view === "editor" && editorView
|
oninsert={app.view === "editor" && editorView
|
||||||
@@ -762,7 +784,7 @@
|
|||||||
{:else if dialog.kind === "conflicts"}
|
{:else if dialog.kind === "conflicts"}
|
||||||
<ConflictModal
|
<ConflictModal
|
||||||
conflicts={app.conflicts}
|
conflicts={app.conflicts}
|
||||||
onresolve={resolveConflicts}
|
onresolve={app.documentLink ? resolveDocumentConflicts : resolveConflicts}
|
||||||
onclose={close}
|
onclose={close}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user