Cloud folder organization, file uploads, appearance themes, title bar rework, faster autosync
This commit is contained in:
+162
-10
@@ -89,7 +89,7 @@ fn update_settings(
|
|||||||
workspace_root: Option<String>,
|
workspace_root: Option<String>,
|
||||||
server_url: Option<String>,
|
server_url: Option<String>,
|
||||||
autosave_seconds: Option<u32>,
|
autosave_seconds: Option<u32>,
|
||||||
sync_minutes: Option<u32>,
|
sync_seconds: 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 {
|
||||||
@@ -104,8 +104,8 @@ fn update_settings(
|
|||||||
if let Some(seconds) = autosave_seconds {
|
if let Some(seconds) = autosave_seconds {
|
||||||
settings.autosave_seconds = seconds;
|
settings.autosave_seconds = seconds;
|
||||||
}
|
}
|
||||||
if let Some(minutes) = sync_minutes {
|
if let Some(seconds) = sync_seconds {
|
||||||
settings.sync_minutes = minutes;
|
settings.sync_seconds = seconds;
|
||||||
}
|
}
|
||||||
save_settings(&store, &settings)?;
|
save_settings(&store, &settings)?;
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
@@ -745,6 +745,71 @@ fn cloud_list_folders(
|
|||||||
sync::list_folders(&server_url, &token)
|
sync::list_folders(&server_url, &token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_create_folder(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
name: String,
|
||||||
|
parent_id: Option<String>,
|
||||||
|
) -> Result<sync::CloudFolder, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::create_folder(&server_url, &token, &name, parent_id.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_rename_folder(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
folder_id: String,
|
||||||
|
name: String,
|
||||||
|
) -> Result<sync::CloudFolder, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::rename_folder(&server_url, &token, &folder_id, &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_move_folder(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
folder_id: String,
|
||||||
|
parent_id: Option<String>,
|
||||||
|
) -> Result<sync::CloudFolder, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::move_folder(&server_url, &token, &folder_id, parent_id.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_delete_folder(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
folder_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::delete_folder(&server_url, &token, &folder_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_move_project(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
cloud_project_id: String,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
) -> Result<ProjectSummary, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::move_cloud_project(&server_url, &token, &cloud_project_id, folder_id.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_move_document(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
document_id: String,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
) -> Result<sync::CloudDocument, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::move_document(&server_url, &token, &document_id, folder_id.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn cloud_list_documents(
|
fn cloud_list_documents(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -793,6 +858,73 @@ fn cloud_delete_file(app: AppHandle, store: State<'_, Store>, file_id: String) -
|
|||||||
sync::delete_account_file(&server_url, &token, &file_id)
|
sync::delete_account_file(&server_url, &token, &file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn guess_mime_type(name: &str) -> &'static str {
|
||||||
|
let extension = std::path::Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
match extension.as_str() {
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"ttf" => "font/ttf",
|
||||||
|
"otf" => "font/otf",
|
||||||
|
"ttc" | "otc" => "font/collection",
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_upload_file(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
path: String,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
) -> Result<sync::CloudFile, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
let name = std::path::Path::new(&path)
|
||||||
|
.file_name()
|
||||||
|
.map(|value| value.to_string_lossy().to_string())
|
||||||
|
.ok_or_else(|| format!("'{}' has no file name", path))?;
|
||||||
|
let data = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||||
|
let mime_type = guess_mime_type(&name);
|
||||||
|
sync::upload_account_file(
|
||||||
|
&server_url,
|
||||||
|
&token,
|
||||||
|
&name,
|
||||||
|
mime_type,
|
||||||
|
&data,
|
||||||
|
folder_id.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_rename_file(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
file_id: String,
|
||||||
|
name: String,
|
||||||
|
) -> Result<sync::CloudFile, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::rename_account_file(&server_url, &token, &file_id, name.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_move_file(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
file_id: String,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
) -> Result<sync::CloudFile, String> {
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
sync::move_account_file(&server_url, &token, &file_id, folder_id.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn cloud_delete_document(
|
fn cloud_delete_document(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -905,7 +1037,7 @@ fn cloud_create_document(
|
|||||||
let full = workspace_path(&app, &store, &path)?;
|
let full = workspace_path(&app, &store, &path)?;
|
||||||
let content = std::fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
let content = std::fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let document = sync::create_document(&server_url, &token, title.trim(), &content)?;
|
let document = sync::create_document(&server_url, &token, title.trim(), &content, None)?;
|
||||||
|
|
||||||
store.save_document_link(
|
store.save_document_link(
|
||||||
&path,
|
&path,
|
||||||
@@ -995,13 +1127,23 @@ fn cloud_document_link(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn cloud_create_project(app: AppHandle, store: State<'_, Store>, name: String) -> Result<ProjectSummary, String> {
|
fn cloud_create_project(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
name: String,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
) -> Result<ProjectSummary, String> {
|
||||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
sync::create_cloud_project(&server_url, &token, name.trim())
|
sync::create_cloud_project(&server_url, &token, name.trim(), folder_id.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn cloud_new_document(app: AppHandle, store: State<'_, Store>, title: String) -> Result<sync::DocumentContent, String> {
|
fn cloud_new_document(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
title: String,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
) -> Result<sync::DocumentContent, String> {
|
||||||
let title = title.trim();
|
let title = title.trim();
|
||||||
if title.is_empty() {
|
if title.is_empty() {
|
||||||
return Err("Document name cannot be empty".to_string());
|
return Err("Document name cannot be empty".to_string());
|
||||||
@@ -1009,7 +1151,7 @@ fn cloud_new_document(app: AppHandle, store: State<'_, Store>, title: String) ->
|
|||||||
|
|
||||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
let content = format!("= {}\n\nStart writing here.\n", title);
|
let content = format!("= {}\n\nStart writing here.\n", title);
|
||||||
sync::create_document(&server_url, &token, title, &content)
|
sync::create_document(&server_url, &token, title, &content, folder_id.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1024,9 +1166,10 @@ fn cloud_clone_project(
|
|||||||
store: State<'_, Store>,
|
store: State<'_, Store>,
|
||||||
cloud_project_id: String,
|
cloud_project_id: String,
|
||||||
project_name: String,
|
project_name: String,
|
||||||
|
parent: String,
|
||||||
) -> Result<SyncReport, String> {
|
) -> Result<SyncReport, String> {
|
||||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
let project = project_name.trim().to_string();
|
let project = join_path(&parent, project_name.trim());
|
||||||
let dir = workspace_path(&app, &store, &project)?;
|
let dir = workspace_path(&app, &store, &project)?;
|
||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
return Err(format!("A project named '{}' already exists", project_name));
|
return Err(format!("A project named '{}' already exists", project_name));
|
||||||
@@ -1054,7 +1197,7 @@ fn cloud_link_project(
|
|||||||
|
|
||||||
let cloud_project_id = match cloud_project_id {
|
let cloud_project_id = match cloud_project_id {
|
||||||
Some(id) if !id.trim().is_empty() => id,
|
Some(id) if !id.trim().is_empty() => id,
|
||||||
_ => sync::create_cloud_project(&server_url, &token, &project)?.id,
|
_ => sync::create_cloud_project(&server_url, &token, &project, None)?.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
meta.cloud_project_id = Some(cloud_project_id);
|
meta.cloud_project_id = Some(cloud_project_id);
|
||||||
@@ -1196,11 +1339,20 @@ pub fn run() {
|
|||||||
cloud_account,
|
cloud_account,
|
||||||
cloud_list_projects,
|
cloud_list_projects,
|
||||||
cloud_list_folders,
|
cloud_list_folders,
|
||||||
|
cloud_create_folder,
|
||||||
|
cloud_rename_folder,
|
||||||
|
cloud_move_folder,
|
||||||
|
cloud_delete_folder,
|
||||||
|
cloud_move_project,
|
||||||
|
cloud_move_document,
|
||||||
cloud_list_documents,
|
cloud_list_documents,
|
||||||
cloud_list_shared,
|
cloud_list_shared,
|
||||||
cloud_list_files,
|
cloud_list_files,
|
||||||
cloud_download_file,
|
cloud_download_file,
|
||||||
cloud_delete_file,
|
cloud_delete_file,
|
||||||
|
cloud_upload_file,
|
||||||
|
cloud_rename_file,
|
||||||
|
cloud_move_file,
|
||||||
cloud_download_document,
|
cloud_download_document,
|
||||||
cloud_delete_document,
|
cloud_delete_document,
|
||||||
cloud_sync_document,
|
cloud_sync_document,
|
||||||
|
|||||||
+148
-2
@@ -181,6 +181,7 @@ pub struct ProjectSummary {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub entrypoint: String,
|
pub entrypoint: String,
|
||||||
|
pub folder_id: Option<String>,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -199,11 +200,12 @@ pub fn create_cloud_project(
|
|||||||
server_url: &str,
|
server_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
) -> Result<ProjectSummary, String> {
|
) -> Result<ProjectSummary, String> {
|
||||||
agent()
|
agent()
|
||||||
.post(&endpoint(server_url, "/projects"))
|
.post(&endpoint(server_url, "/projects"))
|
||||||
.set("Authorization", &format!("Bearer {}", token))
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
.send_json(ureq::json!({ "name": name }))
|
.send_json(ureq::json!({ "name": name, "folder_id": folder_id }))
|
||||||
.map_err(describe)?
|
.map_err(describe)?
|
||||||
.into_json::<ProjectSummary>()
|
.into_json::<ProjectSummary>()
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
@@ -222,6 +224,21 @@ pub fn delete_cloud_project(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn move_cloud_project(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
cloud_project_id: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
) -> Result<ProjectSummary, String> {
|
||||||
|
agent()
|
||||||
|
.patch(&endpoint(server_url, &format!("/projects/{}", cloud_project_id)))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({ "folder_id": folder_id }))
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<ProjectSummary>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ManifestEntry {
|
pub struct ManifestEntry {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -750,6 +767,66 @@ pub fn list_folders(server_url: &str, token: &str) -> Result<Vec<CloudFolder>, S
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_folder(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
name: &str,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
) -> Result<CloudFolder, String> {
|
||||||
|
agent()
|
||||||
|
.post(&endpoint(server_url, "/folders"))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({
|
||||||
|
"name": name,
|
||||||
|
"parent_id": parent_id,
|
||||||
|
}))
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<CloudFolder>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_folder(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
folder_id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<CloudFolder, String> {
|
||||||
|
agent()
|
||||||
|
.patch(&endpoint(server_url, &format!("/folders/{}", folder_id)))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({ "name": name }))
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<CloudFolder>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_folder(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
folder_id: &str,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
) -> Result<CloudFolder, String> {
|
||||||
|
agent()
|
||||||
|
.patch(&endpoint(
|
||||||
|
server_url,
|
||||||
|
&format!("/folders/{}/move", folder_id),
|
||||||
|
))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({ "parent_id": parent_id }))
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<CloudFolder>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_folder(server_url: &str, token: &str, folder_id: &str) -> Result<(), String> {
|
||||||
|
agent()
|
||||||
|
.delete(&endpoint(server_url, &format!("/folders/{}", folder_id)))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.call()
|
||||||
|
.map_err(describe)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_documents(
|
pub fn list_documents(
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
@@ -808,6 +885,7 @@ pub fn create_document(
|
|||||||
token: &str,
|
token: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
) -> Result<DocumentContent, String> {
|
) -> Result<DocumentContent, String> {
|
||||||
agent()
|
agent()
|
||||||
.post(&endpoint(server_url, "/documents"))
|
.post(&endpoint(server_url, "/documents"))
|
||||||
@@ -815,13 +893,28 @@ pub fn create_document(
|
|||||||
.send_json(ureq::json!({
|
.send_json(ureq::json!({
|
||||||
"title": title,
|
"title": title,
|
||||||
"content": content,
|
"content": content,
|
||||||
"folder_id": null,
|
"folder_id": folder_id,
|
||||||
}))
|
}))
|
||||||
.map_err(describe)?
|
.map_err(describe)?
|
||||||
.into_json::<DocumentContent>()
|
.into_json::<DocumentContent>()
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn move_document(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
document_id: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
) -> Result<CloudDocument, String> {
|
||||||
|
agent()
|
||||||
|
.patch(&endpoint(server_url, &format!("/documents/{}", document_id)))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({ "folder_id": folder_id }))
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<CloudDocument>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sync_document(
|
pub fn sync_document(
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
@@ -1060,6 +1153,29 @@ pub fn list_account_files(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn upload_account_file(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
name: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
data: &[u8],
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
) -> Result<CloudFile, String> {
|
||||||
|
agent()
|
||||||
|
.post(&endpoint(server_url, "/files"))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({
|
||||||
|
"name": name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"encoding": "base64",
|
||||||
|
"content": BASE64.encode(data),
|
||||||
|
"folder_id": folder_id,
|
||||||
|
}))
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<CloudFile>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pull_account_file(
|
pub fn pull_account_file(
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
@@ -1082,3 +1198,33 @@ pub fn delete_account_file(server_url: &str, token: &str, file_id: &str) -> Resu
|
|||||||
.map_err(describe)?;
|
.map_err(describe)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rename_account_file(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
file_id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<CloudFile, String> {
|
||||||
|
agent()
|
||||||
|
.patch(&endpoint(server_url, &format!("/files/{}", file_id)))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({ "name": name }))
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<CloudFile>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_account_file(
|
||||||
|
server_url: &str,
|
||||||
|
token: &str,
|
||||||
|
file_id: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
) -> Result<CloudFile, String> {
|
||||||
|
agent()
|
||||||
|
.patch(&endpoint(server_url, &format!("/files/{}/move", file_id)))
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.send_json(ureq::json!({ "folder_id": folder_id }))
|
||||||
|
.map_err(describe)?
|
||||||
|
.into_json::<CloudFile>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ pub struct Settings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub autosave_seconds: u32,
|
pub autosave_seconds: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sync_minutes: u32,
|
pub sync_seconds: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
@@ -84,7 +84,7 @@ impl Settings {
|
|||||||
account_email: None,
|
account_email: None,
|
||||||
account_username: None,
|
account_username: None,
|
||||||
autosave_seconds: 5,
|
autosave_seconds: 5,
|
||||||
sync_minutes: 0,
|
sync_seconds: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
@@ -31,6 +31,94 @@
|
|||||||
--color-success: #4cc47f;
|
--color-success: #4cc47f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="slate"] {
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-muted: #f2f5f7;
|
||||||
|
--color-surface-sunken: #e6ebef;
|
||||||
|
--color-line: #d7dee4;
|
||||||
|
--color-ink: #12181f;
|
||||||
|
--color-ink-muted: #5a6672;
|
||||||
|
--color-accent: #0f9b8e;
|
||||||
|
--color-accent-soft: #e1f5f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="slate"][data-theme="dark"] {
|
||||||
|
--color-surface: #12181e;
|
||||||
|
--color-surface-muted: #182027;
|
||||||
|
--color-surface-sunken: #1f2830;
|
||||||
|
--color-line: #2b3640;
|
||||||
|
--color-ink: #eef2f5;
|
||||||
|
--color-ink-muted: #8b98a5;
|
||||||
|
--color-accent: #3fc2b3;
|
||||||
|
--color-accent-soft: #163330;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="sunset"] {
|
||||||
|
--color-surface: #fffdf9;
|
||||||
|
--color-surface-muted: #faf3e9;
|
||||||
|
--color-surface-sunken: #f3e6d3;
|
||||||
|
--color-line: #e7d5b8;
|
||||||
|
--color-ink: #241a10;
|
||||||
|
--color-ink-muted: #7a6650;
|
||||||
|
--color-accent: #e8623f;
|
||||||
|
--color-accent-soft: #fbe4dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="sunset"][data-theme="dark"] {
|
||||||
|
--color-surface: #1f1712;
|
||||||
|
--color-surface-muted: #261c15;
|
||||||
|
--color-surface-sunken: #2f241a;
|
||||||
|
--color-line: #3d2f22;
|
||||||
|
--color-ink: #f7ede1;
|
||||||
|
--color-ink-muted: #b9a48c;
|
||||||
|
--color-accent: #f4805c;
|
||||||
|
--color-accent-soft: #3a2419;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="forest"] {
|
||||||
|
--color-surface: #fbfdfb;
|
||||||
|
--color-surface-muted: #eef5ee;
|
||||||
|
--color-surface-sunken: #dfebe0;
|
||||||
|
--color-line: #c9dccb;
|
||||||
|
--color-ink: #12201a;
|
||||||
|
--color-ink-muted: #57685c;
|
||||||
|
--color-accent: #2f9457;
|
||||||
|
--color-accent-soft: #dcf0e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="forest"][data-theme="dark"] {
|
||||||
|
--color-surface: #121a15;
|
||||||
|
--color-surface-muted: #17211b;
|
||||||
|
--color-surface-sunken: #1e2c22;
|
||||||
|
--color-line: #2b3c30;
|
||||||
|
--color-ink: #e9f3ec;
|
||||||
|
--color-ink-muted: #8fa896;
|
||||||
|
--color-accent: #4fbf7c;
|
||||||
|
--color-accent-soft: #1a3324;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="grape"] {
|
||||||
|
--color-surface: #fdfbff;
|
||||||
|
--color-surface-muted: #f4eefb;
|
||||||
|
--color-surface-sunken: #e8daf5;
|
||||||
|
--color-line: #d7c3ec;
|
||||||
|
--color-ink: #1c1526;
|
||||||
|
--color-ink-muted: #6a5d7c;
|
||||||
|
--color-accent: #8b47d6;
|
||||||
|
--color-accent-soft: #f0e2fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="grape"][data-theme="dark"] {
|
||||||
|
--color-surface: #17121e;
|
||||||
|
--color-surface-muted: #1d1725;
|
||||||
|
--color-surface-sunken: #251d30;
|
||||||
|
--color-line: #362a42;
|
||||||
|
--color-ink: #f1eaf7;
|
||||||
|
--color-ink-muted: #a998b8;
|
||||||
|
--color-accent: #b47af0;
|
||||||
|
--color-accent-soft: #2e2140;
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-contrast="high"] {
|
:root[data-contrast="high"] {
|
||||||
--color-line: #9aa0ab;
|
--color-line: #9aa0ab;
|
||||||
--color-ink-muted: #33363c;
|
--color-ink-muted: #33363c;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import * as api from "$lib/ts/api";
|
import * as api from "$lib/ts/api";
|
||||||
import type { BrowseEntry, EntryKind } from "$lib/ts/api";
|
import type { BrowseEntry, CloudFile, CloudFolder, EntryKind } from "$lib/ts/api";
|
||||||
import { clampMenu } from "$lib/ts/menu-position";
|
import { clampMenu } from "$lib/ts/menu-position";
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
@@ -31,11 +31,16 @@
|
|||||||
onremovedownload: (path: string) => void;
|
onremovedownload: (path: string) => void;
|
||||||
ondownloadfile: (fileId: string, name: string) => void;
|
ondownloadfile: (fileId: string, name: string) => void;
|
||||||
ondeletefile: (fileId: string) => void;
|
ondeletefile: (fileId: string) => void;
|
||||||
|
onuploadfile: () => void;
|
||||||
|
onrenamefile: (file: CloudFile) => void;
|
||||||
oncloneproject: (cloudProjectId: string, name: string) => void;
|
oncloneproject: (cloudProjectId: string, name: string) => void;
|
||||||
ondeleteproject: (cloudProjectId: string) => void;
|
ondeleteproject: (cloudProjectId: string) => void;
|
||||||
ondeletedocument: (documentId: string) => void;
|
ondeletedocument: (documentId: string) => void;
|
||||||
onnewcloudproject: () => void;
|
onnewcloudproject: () => void;
|
||||||
onnewclouddocument: () => void;
|
onnewclouddocument: () => void;
|
||||||
|
onnewcloudfolder: () => void;
|
||||||
|
onrenamecloudfolder: (folder: CloudFolder) => void;
|
||||||
|
ondeletecloudfolder: (folder: CloudFolder) => void;
|
||||||
onsignin: () => void;
|
onsignin: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +58,16 @@
|
|||||||
onremovedownload,
|
onremovedownload,
|
||||||
ondownloadfile,
|
ondownloadfile,
|
||||||
ondeletefile,
|
ondeletefile,
|
||||||
|
onuploadfile,
|
||||||
|
onrenamefile,
|
||||||
oncloneproject,
|
oncloneproject,
|
||||||
ondeleteproject,
|
ondeleteproject,
|
||||||
ondeletedocument,
|
ondeletedocument,
|
||||||
onnewcloudproject,
|
onnewcloudproject,
|
||||||
onnewclouddocument,
|
onnewclouddocument,
|
||||||
|
onnewcloudfolder,
|
||||||
|
onrenamecloudfolder,
|
||||||
|
ondeletecloudfolder,
|
||||||
onsignin,
|
onsignin,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -234,6 +244,55 @@
|
|||||||
if (source) moveTo(source, destination);
|
if (source) moveTo(source, destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CloudDragItem = {
|
||||||
|
kind: "project" | "document" | "folder" | "file";
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
let cloudDragging = $state<CloudDragItem | null>(null);
|
||||||
|
let cloudDropTarget = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function moveCloudItem(item: CloudDragItem, folderId: string | null) {
|
||||||
|
try {
|
||||||
|
if (item.kind === "folder") {
|
||||||
|
await api.cloudMoveFolder(item.id, folderId);
|
||||||
|
} else if (item.kind === "project") {
|
||||||
|
await api.cloudMoveProject(item.id, folderId);
|
||||||
|
} else if (item.kind === "document") {
|
||||||
|
await api.cloudMoveDocument(item.id, folderId);
|
||||||
|
} else {
|
||||||
|
await api.cloudMoveFile(item.id, folderId);
|
||||||
|
}
|
||||||
|
await refreshCloud();
|
||||||
|
} catch (error) {
|
||||||
|
setError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCloudDrag(event: DragEvent, item: CloudDragItem) {
|
||||||
|
cloudDragging = item;
|
||||||
|
event.dataTransfer?.setData("text/plain", item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endCloudDrag() {
|
||||||
|
cloudDragging = null;
|
||||||
|
cloudDropTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowCloudDrop(event: DragEvent, folderId: string) {
|
||||||
|
if (!cloudDragging) return;
|
||||||
|
if (cloudDragging.kind === "folder" && cloudDragging.id === folderId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
cloudDropTarget = folderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloudDrop(event: DragEvent, folderId: string) {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = cloudDragging;
|
||||||
|
cloudDragging = null;
|
||||||
|
cloudDropTarget = null;
|
||||||
|
if (item) moveCloudItem(item, folderId === "" ? null : folderId);
|
||||||
|
}
|
||||||
|
|
||||||
function activate(entry: BrowseEntry) {
|
function activate(entry: BrowseEntry) {
|
||||||
if (entry.kind === "folder") {
|
if (entry.kind === "folder") {
|
||||||
browseTo(entry.path);
|
browseTo(entry.path);
|
||||||
@@ -262,6 +321,7 @@
|
|||||||
onopen: (() => void) | null,
|
onopen: (() => void) | null,
|
||||||
ondownload: () => void,
|
ondownload: () => void,
|
||||||
onremove: (() => void) | null,
|
onremove: (() => void) | null,
|
||||||
|
onrename: (() => void) | null,
|
||||||
ondelete: (() => void) | null,
|
ondelete: (() => void) | null,
|
||||||
)}
|
)}
|
||||||
{#if cloudMenuFor === id}
|
{#if cloudMenuFor === id}
|
||||||
@@ -302,6 +362,17 @@
|
|||||||
Remove from this device
|
Remove from this device
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if onrename}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => {
|
||||||
|
onrename();
|
||||||
|
cloudMenuFor = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if ondelete}
|
{#if ondelete}
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
|
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
|
||||||
@@ -328,11 +399,18 @@
|
|||||||
onremove: (() => void) | null,
|
onremove: (() => void) | null,
|
||||||
ondelete: (() => void) | null,
|
ondelete: (() => void) | null,
|
||||||
)}
|
)}
|
||||||
|
{@const [dragKind, dragId] = id.split(":") as [
|
||||||
|
"project" | "document",
|
||||||
|
string,
|
||||||
|
]}
|
||||||
<div
|
<div
|
||||||
class="group flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-sm"
|
class="group flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-sm"
|
||||||
oncontextmenu={(event) => openCloudContextMenu(event, id)}
|
oncontextmenu={(event) => openCloudContextMenu(event, id)}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(event) => startCloudDrag(event, { kind: dragKind, id: dragId })}
|
||||||
|
ondragend={endCloudDrag}
|
||||||
>
|
>
|
||||||
{@render cloudMenu(id, onopen, ondownload, onremove, ondelete)}
|
{@render cloudMenu(id, onopen, ondownload, onremove, null, ondelete)}
|
||||||
<div
|
<div
|
||||||
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
|
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
|
||||||
>
|
>
|
||||||
@@ -545,20 +623,39 @@
|
|||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5"
|
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5"
|
||||||
>
|
>
|
||||||
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
|
{#if app.scope === "local"}
|
||||||
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
|
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
|
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs transition hover:bg-[var(--color-surface-muted)]
|
||||||
{app.scope === value
|
{app.currentDir === '' ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
|
||||||
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
|
{dropTarget === '' ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
|
||||||
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
onclick={() => browseTo("")}
|
||||||
onclick={() => (app.scope = value as "local" | "cloud")}
|
ondragover={(event) => allowDrop(event, "")}
|
||||||
|
ondragleave={() => {
|
||||||
|
if (dropTarget === "") dropTarget = null;
|
||||||
|
}}
|
||||||
|
ondrop={(event) => handleDrop(event, "")}
|
||||||
>
|
>
|
||||||
<Icon {icon} />
|
<Icon icon="ph:house" />
|
||||||
{label}
|
Workspace
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#each trail as crumb, index}
|
||||||
|
<Icon icon="ph:caret-right" class="text-[10px] text-[var(--color-ink-muted)]" />
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-1 text-xs transition hover:bg-[var(--color-surface-muted)]
|
||||||
|
{index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
|
||||||
|
{dropTarget === crumb.path ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
|
||||||
|
onclick={() => browseTo(crumb.path)}
|
||||||
|
ondragover={(event) => allowDrop(event, crumb.path)}
|
||||||
|
ondragleave={() => {
|
||||||
|
if (dropTarget === crumb.path) dropTarget = null;
|
||||||
|
}}
|
||||||
|
ondrop={(event) => handleDrop(event, crumb.path)}
|
||||||
|
>
|
||||||
|
{crumb.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
@@ -592,6 +689,20 @@
|
|||||||
New document
|
New document
|
||||||
</button>
|
</button>
|
||||||
{:else if app.account}
|
{:else if app.account}
|
||||||
|
<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={onuploadfile}
|
||||||
|
>
|
||||||
|
<Icon icon="ph:upload-simple" />
|
||||||
|
Upload
|
||||||
|
</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)]"
|
||||||
|
onclick={onnewcloudfolder}
|
||||||
|
>
|
||||||
|
<Icon icon="ph:folder-plus" />
|
||||||
|
Folder
|
||||||
|
</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={onnewcloudproject}
|
onclick={onnewcloudproject}
|
||||||
@@ -610,42 +721,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if app.scope === "local"}
|
{#if app.scope === "local"}
|
||||||
<div
|
|
||||||
class="flex items-center gap-1 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2 text-xs"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
|
|
||||||
{app.currentDir === '' ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
|
|
||||||
{dropTarget === '' ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
|
|
||||||
onclick={() => browseTo("")}
|
|
||||||
ondragover={(event) => allowDrop(event, "")}
|
|
||||||
ondragleave={() => {
|
|
||||||
if (dropTarget === "") dropTarget = null;
|
|
||||||
}}
|
|
||||||
ondrop={(event) => handleDrop(event, "")}
|
|
||||||
>
|
|
||||||
<Icon icon="ph:house" />
|
|
||||||
Workspace
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#each trail as crumb, index}
|
|
||||||
<Icon icon="ph:caret-right" class="text-[10px] text-[var(--color-ink-muted)]" />
|
|
||||||
<button
|
|
||||||
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
|
|
||||||
{index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
|
|
||||||
{dropTarget === crumb.path ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
|
|
||||||
onclick={() => browseTo(crumb.path)}
|
|
||||||
ondragover={(event) => allowDrop(event, crumb.path)}
|
|
||||||
ondragleave={() => {
|
|
||||||
if (dropTarget === crumb.path) dropTarget = null;
|
|
||||||
}}
|
|
||||||
ondrop={(event) => handleDrop(event, crumb.path)}
|
|
||||||
>
|
|
||||||
{crumb.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="scroll-thin flex-1 overflow-y-auto p-4">
|
<div class="scroll-thin flex-1 overflow-y-auto p-4">
|
||||||
{#if app.entries.length === 0}
|
{#if app.entries.length === 0}
|
||||||
<div
|
<div
|
||||||
@@ -840,8 +915,14 @@
|
|||||||
class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
|
class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
|
||||||
{app.cloudFolder !== 'shared'
|
{app.cloudFolder !== 'shared'
|
||||||
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white shadow-sm'
|
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white shadow-sm'
|
||||||
: 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}"
|
: 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}
|
||||||
|
{cloudDropTarget === '' ? 'ring-2 ring-[var(--color-accent)]' : ''}"
|
||||||
onclick={() => openCloudFolder(null)}
|
onclick={() => openCloudFolder(null)}
|
||||||
|
ondragover={(event) => allowCloudDrop(event, "")}
|
||||||
|
ondragleave={() => {
|
||||||
|
if (cloudDropTarget === "") cloudDropTarget = null;
|
||||||
|
}}
|
||||||
|
ondrop={(event) => handleCloudDrop(event, "")}
|
||||||
>
|
>
|
||||||
<Icon icon="ph:cloud-fill" class="text-base" />
|
<Icon icon="ph:cloud-fill" class="text-base" />
|
||||||
My Drive
|
My Drive
|
||||||
@@ -869,8 +950,14 @@
|
|||||||
{#if cloudTrail.length > 0}
|
{#if cloudTrail.length > 0}
|
||||||
<div class="mb-3 flex flex-wrap items-center gap-1 text-xs">
|
<div class="mb-3 flex flex-wrap items-center gap-1 text-xs">
|
||||||
<button
|
<button
|
||||||
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface)] hover:text-[var(--color-ink)]"
|
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface)] hover:text-[var(--color-ink)]
|
||||||
|
{cloudDropTarget === '' ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
|
||||||
onclick={() => openCloudFolder(null)}
|
onclick={() => openCloudFolder(null)}
|
||||||
|
ondragover={(event) => allowCloudDrop(event, "")}
|
||||||
|
ondragleave={() => {
|
||||||
|
if (cloudDropTarget === "") cloudDropTarget = null;
|
||||||
|
}}
|
||||||
|
ondrop={(event) => handleCloudDrop(event, "")}
|
||||||
>
|
>
|
||||||
My Drive
|
My Drive
|
||||||
</button>
|
</button>
|
||||||
@@ -884,8 +971,16 @@
|
|||||||
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
|
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
|
||||||
{index === cloudTrail.length - 1
|
{index === cloudTrail.length - 1
|
||||||
? 'font-medium'
|
? 'font-medium'
|
||||||
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}
|
||||||
|
{cloudDropTarget === folder.id
|
||||||
|
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||||
|
: ''}"
|
||||||
onclick={() => openCloudFolder(folder.id)}
|
onclick={() => openCloudFolder(folder.id)}
|
||||||
|
ondragover={(event) => allowCloudDrop(event, folder.id)}
|
||||||
|
ondragleave={() => {
|
||||||
|
if (cloudDropTarget === folder.id) cloudDropTarget = null;
|
||||||
|
}}
|
||||||
|
ondrop={(event) => handleCloudDrop(event, folder.id)}
|
||||||
>
|
>
|
||||||
{folder.name}
|
{folder.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -900,10 +995,22 @@
|
|||||||
{#each app.cloudFolders as folder (folder.id)}
|
{#each app.cloudFolders as folder (folder.id)}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
class="flex w-full 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)]"
|
class="flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]
|
||||||
|
{cloudDropTarget === folder.id
|
||||||
|
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)]'
|
||||||
|
: 'border-[var(--color-line)] bg-[var(--color-surface-sunken)]'}"
|
||||||
onclick={() => openCloudFolder(folder.id)}
|
onclick={() => openCloudFolder(folder.id)}
|
||||||
oncontextmenu={(event) =>
|
oncontextmenu={(event) =>
|
||||||
openCloudContextMenu(event, `folder:${folder.id}`)}
|
openCloudContextMenu(event, `folder:${folder.id}`)}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(event) =>
|
||||||
|
startCloudDrag(event, { kind: "folder", id: folder.id })}
|
||||||
|
ondragend={endCloudDrag}
|
||||||
|
ondragover={(event) => allowCloudDrop(event, folder.id)}
|
||||||
|
ondragleave={() => {
|
||||||
|
if (cloudDropTarget === folder.id) cloudDropTarget = null;
|
||||||
|
}}
|
||||||
|
ondrop={(event) => handleCloudDrop(event, folder.id)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="ph:folder-fill"
|
icon="ph:folder-fill"
|
||||||
@@ -916,7 +1023,8 @@
|
|||||||
() => openCloudFolder(folder.id),
|
() => openCloudFolder(folder.id),
|
||||||
() => {},
|
() => {},
|
||||||
null,
|
null,
|
||||||
null,
|
() => onrenamecloudfolder(folder),
|
||||||
|
() => ondeletecloudfolder(folder),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -965,12 +1073,17 @@
|
|||||||
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
|
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
|
||||||
oncontextmenu={(event) =>
|
oncontextmenu={(event) =>
|
||||||
openCloudContextMenu(event, `file:${file.id}`)}
|
openCloudContextMenu(event, `file:${file.id}`)}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(event) =>
|
||||||
|
startCloudDrag(event, { kind: "file", id: file.id })}
|
||||||
|
ondragend={endCloudDrag}
|
||||||
>
|
>
|
||||||
{@render cloudMenu(
|
{@render cloudMenu(
|
||||||
`file:${file.id}`,
|
`file:${file.id}`,
|
||||||
null,
|
null,
|
||||||
() => ondownloadfile(file.id, file.name),
|
() => ondownloadfile(file.id, file.name),
|
||||||
null,
|
null,
|
||||||
|
() => onrenamefile(file),
|
||||||
() => ondeletefile(file.id),
|
() => ondeletefile(file.id),
|
||||||
)}
|
)}
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
|
applyColorTheme,
|
||||||
applyAccent,
|
applyAccent,
|
||||||
applyTextScale,
|
applyTextScale,
|
||||||
applyReduceMotion,
|
applyReduceMotion,
|
||||||
applyContrast,
|
applyContrast,
|
||||||
|
colorThemes,
|
||||||
refreshEntries,
|
refreshEntries,
|
||||||
restartAutoSync,
|
restartAutoSync,
|
||||||
setError,
|
setError,
|
||||||
@@ -85,7 +87,7 @@
|
|||||||
}, 600);
|
}, 600);
|
||||||
});
|
});
|
||||||
let autosaveSeconds = $state(untrack(() => app.settings?.autosave_seconds ?? 0));
|
let autosaveSeconds = $state(untrack(() => app.settings?.autosave_seconds ?? 0));
|
||||||
let syncMinutes = $state(untrack(() => app.settings?.sync_minutes ?? 0));
|
let syncSeconds = $state(untrack(() => app.settings?.sync_seconds ?? 0));
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let info = $state<AppInfo | null>(null);
|
let info = $state<AppInfo | null>(null);
|
||||||
|
|
||||||
@@ -107,9 +109,11 @@
|
|||||||
|
|
||||||
const syncOptions = [
|
const syncOptions = [
|
||||||
{ value: 0, label: "Off" },
|
{ value: 0, label: "Off" },
|
||||||
{ value: 1, label: "1 minute" },
|
{ value: 15, label: "15 seconds" },
|
||||||
{ value: 2, label: "2 minutes" },
|
{ value: 30, label: "30 seconds" },
|
||||||
{ value: 5, label: "5 minutes" },
|
{ value: 60, label: "1 minute" },
|
||||||
|
{ value: 120, label: "2 minutes" },
|
||||||
|
{ value: 300, label: "5 minutes" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
@@ -149,7 +153,7 @@
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
autosaveSeconds,
|
autosaveSeconds,
|
||||||
syncMinutes,
|
syncSeconds,
|
||||||
});
|
});
|
||||||
restartAutoSync();
|
restartAutoSync();
|
||||||
await refreshEntries();
|
await refreshEntries();
|
||||||
@@ -250,6 +254,30 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 text-xs">
|
||||||
|
<span class="font-medium text-[var(--color-ink-muted)]">Color theme</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each colorThemes as entry}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 rounded-md border px-3 py-2 transition
|
||||||
|
{app.colorTheme === entry.id
|
||||||
|
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||||
|
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
|
||||||
|
onclick={() => applyColorTheme(entry.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-3 w-3 rounded-full"
|
||||||
|
style="background-color: {entry.accent}"
|
||||||
|
></span>
|
||||||
|
{entry.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span class="text-[var(--color-ink-muted)]">
|
||||||
|
Sets the surface colors and default accent for the app.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 text-xs">
|
<div class="flex flex-col gap-2 text-xs">
|
||||||
<span class="font-medium text-[var(--color-ink-muted)]">
|
<span class="font-medium text-[var(--color-ink-muted)]">
|
||||||
Accent color
|
Accent color
|
||||||
@@ -420,7 +448,7 @@
|
|||||||
<span class="font-medium text-[var(--color-ink-muted)]">
|
<span class="font-medium text-[var(--color-ink-muted)]">
|
||||||
Automatic sync
|
Automatic sync
|
||||||
</span>
|
</span>
|
||||||
<select class={fieldClass} bind:value={syncMinutes}>
|
<select class={fieldClass} bind:value={syncSeconds}>
|
||||||
{#each syncOptions as option}
|
{#each syncOptions as option}
|
||||||
<option value={option.value}>{option.label}</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -434,7 +462,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Icon icon="ph:file-code" class="text-3xl text-[var(--color-accent)]" />
|
<span
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-lg bg-[var(--color-accent)]"
|
||||||
|
>
|
||||||
|
<img src="/favicon.png" alt="Typst Desktop" class="h-11 w-11" />
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold">Typst Desktop</p>
|
<p class="text-sm font-semibold">Typst Desktop</p>
|
||||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||||
|
|||||||
+50
-8
@@ -7,7 +7,7 @@ export interface Settings {
|
|||||||
account_email: string | null;
|
account_email: string | null;
|
||||||
account_username: string | null;
|
account_username: string | null;
|
||||||
autosave_seconds: number;
|
autosave_seconds: number;
|
||||||
sync_minutes: number;
|
sync_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
@@ -57,6 +57,7 @@ export interface ProjectSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
|
folder_id: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -248,7 +249,7 @@ export const updateSettings = (changes: {
|
|||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
autosaveSeconds?: number;
|
autosaveSeconds?: number;
|
||||||
syncMinutes?: number;
|
syncSeconds?: number;
|
||||||
}) => invoke<Settings>("update_settings", changes);
|
}) => invoke<Settings>("update_settings", changes);
|
||||||
|
|
||||||
export interface CompatibilityStatus {
|
export interface CompatibilityStatus {
|
||||||
@@ -305,6 +306,21 @@ export interface DocumentLink {
|
|||||||
export const cloudListFolders = () =>
|
export const cloudListFolders = () =>
|
||||||
invoke<CloudFolder[]>("cloud_list_folders");
|
invoke<CloudFolder[]>("cloud_list_folders");
|
||||||
|
|
||||||
|
export const cloudCreateFolder = (name: string, parentId?: string | null) =>
|
||||||
|
invoke<CloudFolder>("cloud_create_folder", {
|
||||||
|
name,
|
||||||
|
parentId: parentId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cloudRenameFolder = (folderId: string, name: string) =>
|
||||||
|
invoke<CloudFolder>("cloud_rename_folder", { folderId, name });
|
||||||
|
|
||||||
|
export const cloudMoveFolder = (folderId: string, parentId: string | null) =>
|
||||||
|
invoke<CloudFolder>("cloud_move_folder", { folderId, parentId });
|
||||||
|
|
||||||
|
export const cloudDeleteFolder = (folderId: string) =>
|
||||||
|
invoke<void>("cloud_delete_folder", { folderId });
|
||||||
|
|
||||||
export const cloudListDocuments = (folderId?: string | null) =>
|
export const cloudListDocuments = (folderId?: string | null) =>
|
||||||
invoke<CloudDocument[]>("cloud_list_documents", {
|
invoke<CloudDocument[]>("cloud_list_documents", {
|
||||||
folderId: folderId ?? null,
|
folderId: folderId ?? null,
|
||||||
@@ -329,6 +345,15 @@ export const cloudDownloadFile = (fileId: string) =>
|
|||||||
export const cloudDeleteFile = (fileId: string) =>
|
export const cloudDeleteFile = (fileId: string) =>
|
||||||
invoke<void>("cloud_delete_file", { fileId });
|
invoke<void>("cloud_delete_file", { fileId });
|
||||||
|
|
||||||
|
export const cloudUploadFile = (path: string, folderId?: string | null) =>
|
||||||
|
invoke<CloudFile>("cloud_upload_file", { path, folderId: folderId ?? null });
|
||||||
|
|
||||||
|
export const cloudRenameFile = (fileId: string, name: string) =>
|
||||||
|
invoke<CloudFile>("cloud_rename_file", { fileId, name });
|
||||||
|
|
||||||
|
export const cloudMoveFile = (fileId: string, folderId: string | null) =>
|
||||||
|
invoke<CloudFile>("cloud_move_file", { fileId, folderId });
|
||||||
|
|
||||||
export const cloudDownloadDocument = (documentId: string, parent: string) =>
|
export const cloudDownloadDocument = (documentId: string, parent: string) =>
|
||||||
invoke<string>("cloud_download_document", { documentId, parent });
|
invoke<string>("cloud_download_document", { documentId, parent });
|
||||||
|
|
||||||
@@ -338,6 +363,9 @@ export const cloudDeleteDocument = (documentId: string) =>
|
|||||||
export const cloudCreateDocument = (path: string, title: string) =>
|
export const cloudCreateDocument = (path: string, title: string) =>
|
||||||
invoke<string>("cloud_create_document", { path, title });
|
invoke<string>("cloud_create_document", { path, title });
|
||||||
|
|
||||||
|
export const cloudMoveDocument = (documentId: string, folderId: string | null) =>
|
||||||
|
invoke<CloudDocument>("cloud_move_document", { documentId, folderId });
|
||||||
|
|
||||||
export interface DocumentContent {
|
export interface DocumentContent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -346,8 +374,11 @@ export interface DocumentContent {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cloudNewDocument = (title: string) =>
|
export const cloudNewDocument = (title: string, folderId?: string | null) =>
|
||||||
invoke<DocumentContent>("cloud_new_document", { title });
|
invoke<DocumentContent>("cloud_new_document", {
|
||||||
|
title,
|
||||||
|
folderId: folderId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
export const cloudSyncDocument = (path: string) =>
|
export const cloudSyncDocument = (path: string) =>
|
||||||
invoke<SyncReport>("cloud_sync_document", { path });
|
invoke<SyncReport>("cloud_sync_document", { path });
|
||||||
@@ -384,14 +415,25 @@ export const cloudDocumentLink = (path: string) =>
|
|||||||
export const cloudUnlinkDocument = (path: string) =>
|
export const cloudUnlinkDocument = (path: string) =>
|
||||||
invoke<void>("cloud_unlink_document", { path });
|
invoke<void>("cloud_unlink_document", { path });
|
||||||
|
|
||||||
export const cloudCreateProject = (name: string) =>
|
export const cloudCreateProject = (name: string, folderId?: string | null) =>
|
||||||
invoke<ProjectSummary>("cloud_create_project", { name });
|
invoke<ProjectSummary>("cloud_create_project", {
|
||||||
|
name,
|
||||||
|
folderId: folderId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
export const cloudDeleteProject = (cloudProjectId: string) =>
|
export const cloudDeleteProject = (cloudProjectId: string) =>
|
||||||
invoke<void>("cloud_delete_project", { cloudProjectId });
|
invoke<void>("cloud_delete_project", { cloudProjectId });
|
||||||
|
|
||||||
export const cloudCloneProject = (cloudProjectId: string, projectName: string) =>
|
export const cloudMoveProject = (
|
||||||
invoke<SyncReport>("cloud_clone_project", { cloudProjectId, projectName });
|
cloudProjectId: string,
|
||||||
|
folderId: string | null,
|
||||||
|
) => invoke<ProjectSummary>("cloud_move_project", { cloudProjectId, folderId });
|
||||||
|
|
||||||
|
export const cloudCloneProject = (
|
||||||
|
cloudProjectId: string,
|
||||||
|
projectName: string,
|
||||||
|
parent: string,
|
||||||
|
) => invoke<SyncReport>("cloud_clone_project", { cloudProjectId, projectName, parent });
|
||||||
|
|
||||||
export const cloudLinkProject = (project: string, cloudProjectId?: string) =>
|
export const cloudLinkProject = (project: string, cloudProjectId?: string) =>
|
||||||
invoke<SyncReport>("cloud_link_project", {
|
invoke<SyncReport>("cloud_link_project", {
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export type LspStatus = "off" | "starting" | "on" | "unavailable";
|
|||||||
export type ThemePreference = "light" | "dark" | "system";
|
export type ThemePreference = "light" | "dark" | "system";
|
||||||
export type TextScale = "small" | "default" | "large" | "xlarge";
|
export type TextScale = "small" | "default" | "large" | "xlarge";
|
||||||
export type ContrastLevel = "normal" | "high";
|
export type ContrastLevel = "normal" | "high";
|
||||||
|
export type ColorTheme = "default" | "slate" | "sunset" | "forest" | "grape";
|
||||||
|
|
||||||
|
export const colorThemes: { id: ColorTheme; label: string; accent: string }[] = [
|
||||||
|
{ id: "default", label: "Default", accent: "#3b6cf6" },
|
||||||
|
{ id: "slate", label: "Slate", accent: "#0f9b8e" },
|
||||||
|
{ id: "sunset", label: "Sunset", accent: "#e8623f" },
|
||||||
|
{ id: "forest", label: "Forest", accent: "#2f9457" },
|
||||||
|
{ id: "grape", label: "Grape", accent: "#8b47d6" },
|
||||||
|
];
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
view: View;
|
view: View;
|
||||||
@@ -58,6 +67,7 @@ interface AppState {
|
|||||||
error: string;
|
error: string;
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
themePreference: ThemePreference;
|
themePreference: ThemePreference;
|
||||||
|
colorTheme: ColorTheme;
|
||||||
accent: string | null;
|
accent: string | null;
|
||||||
textScale: TextScale;
|
textScale: TextScale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
@@ -99,6 +109,7 @@ export const app = $state<AppState>({
|
|||||||
error: "",
|
error: "",
|
||||||
theme: "light",
|
theme: "light",
|
||||||
themePreference: "light",
|
themePreference: "light",
|
||||||
|
colorTheme: "default",
|
||||||
accent: null,
|
accent: null,
|
||||||
textScale: "default",
|
textScale: "default",
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
@@ -146,6 +157,7 @@ export function clearMessages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const THEME_KEY = "typst-desktop-theme";
|
const THEME_KEY = "typst-desktop-theme";
|
||||||
|
const COLOR_THEME_KEY = "typst-desktop-color-theme";
|
||||||
const ACCENT_KEY = "typst-desktop-accent";
|
const ACCENT_KEY = "typst-desktop-accent";
|
||||||
const TEXT_SCALE_KEY = "typst-desktop-text-scale";
|
const TEXT_SCALE_KEY = "typst-desktop-text-scale";
|
||||||
const REDUCE_MOTION_KEY = "typst-desktop-reduce-motion";
|
const REDUCE_MOTION_KEY = "typst-desktop-reduce-motion";
|
||||||
@@ -182,6 +194,13 @@ export function applyTheme(preference: ThemePreference) {
|
|||||||
if (app.accent) applyAccent(app.accent);
|
if (app.accent) applyAccent(app.accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyColorTheme(theme: ColorTheme) {
|
||||||
|
app.colorTheme = theme;
|
||||||
|
document.documentElement.dataset.colorTheme = theme;
|
||||||
|
localStorage.setItem(COLOR_THEME_KEY, theme);
|
||||||
|
applyAccent(null);
|
||||||
|
}
|
||||||
|
|
||||||
function hexToRgb(hex: string): [number, number, number] {
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
const value = parseInt(hex.replace("#", ""), 16);
|
const value = parseInt(hex.replace("#", ""), 16);
|
||||||
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||||
@@ -296,6 +315,13 @@ export async function bootstrap() {
|
|||||||
: "light",
|
: "light",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const storedColorTheme = localStorage.getItem(COLOR_THEME_KEY);
|
||||||
|
const validColorTheme = colorThemes.some((entry) => entry.id === storedColorTheme);
|
||||||
|
document.documentElement.dataset.colorTheme = validColorTheme
|
||||||
|
? (storedColorTheme as ColorTheme)
|
||||||
|
: "default";
|
||||||
|
app.colorTheme = validColorTheme ? (storedColorTheme as ColorTheme) : "default";
|
||||||
|
|
||||||
const storedAccent = localStorage.getItem(ACCENT_KEY);
|
const storedAccent = localStorage.getItem(ACCENT_KEY);
|
||||||
if (storedAccent) applyAccent(storedAccent);
|
if (storedAccent) applyAccent(storedAccent);
|
||||||
|
|
||||||
@@ -382,7 +408,11 @@ export async function refreshCloud() {
|
|||||||
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
|
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
|
||||||
);
|
);
|
||||||
app.cloudDocuments = documents;
|
app.cloudDocuments = documents;
|
||||||
app.cloudProjects = projects;
|
app.cloudProjects = projects.filter(
|
||||||
|
(project) =>
|
||||||
|
project.role !== "owner" ||
|
||||||
|
(project.folder_id ?? null) === app.cloudFolder,
|
||||||
|
);
|
||||||
app.cloudFiles = files;
|
app.cloudFiles = files;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -414,7 +444,7 @@ export async function openCloudFolder(id: string | null | "shared") {
|
|||||||
|
|
||||||
export async function downloadDocument(documentId: string, title: string) {
|
export async function downloadDocument(documentId: string, title: string) {
|
||||||
try {
|
try {
|
||||||
const path = await api.cloudDownloadDocument(documentId, "");
|
const path = await api.cloudDownloadDocument(documentId, app.currentDir);
|
||||||
await refreshCloud();
|
await refreshCloud();
|
||||||
setStatus(`Downloaded '${title}' to this device`);
|
setStatus(`Downloaded '${title}' to this device`);
|
||||||
return path;
|
return path;
|
||||||
@@ -634,12 +664,12 @@ export function restartAutoSync() {
|
|||||||
syncTimer = null;
|
syncTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minutes = app.settings?.sync_minutes ?? 0;
|
const seconds = app.settings?.sync_seconds ?? 0;
|
||||||
if (minutes <= 0) return;
|
if (seconds <= 0) return;
|
||||||
|
|
||||||
syncTimer = setInterval(() => {
|
syncTimer = setInterval(() => {
|
||||||
autoSync();
|
autoSync();
|
||||||
}, minutes * 60 * 1000);
|
}, seconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoSync() {
|
async function autoSync() {
|
||||||
|
|||||||
+118
-10
@@ -25,7 +25,7 @@
|
|||||||
import { insertText } from "$lib/ts/editor-actions";
|
import { insertText } from "$lib/ts/editor-actions";
|
||||||
|
|
||||||
import * as api from "$lib/ts/api";
|
import * as api from "$lib/ts/api";
|
||||||
import type { BrowseEntry } from "$lib/ts/api";
|
import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api";
|
||||||
import { pickFiles } from "$lib/ts/import";
|
import { pickFiles } from "$lib/ts/import";
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
@@ -39,7 +39,6 @@
|
|||||||
openTarget,
|
openTarget,
|
||||||
refreshAccount,
|
refreshAccount,
|
||||||
refreshCloud,
|
refreshCloud,
|
||||||
refreshCloudProjects,
|
|
||||||
refreshEntries,
|
refreshEntries,
|
||||||
refreshTarget,
|
refreshTarget,
|
||||||
removeDownloadedDocument,
|
removeDownloadedDocument,
|
||||||
@@ -64,9 +63,13 @@
|
|||||||
| { kind: "save-document-to-cloud"; entry: BrowseEntry }
|
| { kind: "save-document-to-cloud"; entry: BrowseEntry }
|
||||||
| { kind: "new-cloud-project" }
|
| { kind: "new-cloud-project" }
|
||||||
| { kind: "new-cloud-document" }
|
| { kind: "new-cloud-document" }
|
||||||
|
| { kind: "new-cloud-folder" }
|
||||||
|
| { kind: "rename-cloud-folder"; folder: CloudFolder }
|
||||||
|
| { kind: "delete-cloud-folder"; folder: CloudFolder }
|
||||||
| { kind: "delete-cloud-project"; id: string }
|
| { kind: "delete-cloud-project"; id: string }
|
||||||
| { kind: "delete-cloud-document"; id: string }
|
| { kind: "delete-cloud-document"; id: string }
|
||||||
| { kind: "delete-cloud-file"; id: string }
|
| { kind: "delete-cloud-file"; id: string }
|
||||||
|
| { kind: "rename-cloud-file"; file: CloudFile }
|
||||||
| { kind: "clone-cloud-project"; id: string; name: string }
|
| { kind: "clone-cloud-project"; id: string; name: string }
|
||||||
| { kind: "new-file"; parent: string }
|
| { kind: "new-file"; parent: string }
|
||||||
| { kind: "new-subfolder"; parent: string }
|
| { kind: "new-subfolder"; parent: string }
|
||||||
@@ -154,11 +157,14 @@
|
|||||||
setStatus(`Deleted '${entry.name}'`);
|
setStatus(`Deleted '${entry.name}'`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentCloudFolder = () =>
|
||||||
|
app.cloudFolder === "shared" ? null : app.cloudFolder;
|
||||||
|
|
||||||
const linkEntry = (entry: BrowseEntry) =>
|
const linkEntry = (entry: BrowseEntry) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
const report = await api.cloudLinkProject(entry.path);
|
const report = await api.cloudLinkProject(entry.path);
|
||||||
await refreshEntries();
|
await refreshEntries();
|
||||||
await refreshCloudProjects();
|
await refreshCloud();
|
||||||
setStatus(`Uploaded ${report.pushed.length} files to a new cloud project`);
|
setStatus(`Uploaded ${report.pushed.length} files to a new cloud project`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,20 +177,38 @@
|
|||||||
|
|
||||||
const createCloudProject = (name: string) =>
|
const createCloudProject = (name: string) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
await api.cloudCreateProject(name);
|
await api.cloudCreateProject(name, currentCloudFolder());
|
||||||
await refreshCloudProjects();
|
await refreshCloud();
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCloudDocument = (title: string) =>
|
const createCloudDocument = (title: string) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
await api.cloudNewDocument(title);
|
await api.cloudNewDocument(title, currentCloudFolder());
|
||||||
|
await refreshCloud();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCloudFolder = (name: string) =>
|
||||||
|
guard(async () => {
|
||||||
|
await api.cloudCreateFolder(name, currentCloudFolder());
|
||||||
|
await refreshCloud();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameCloudFolder = (folder: CloudFolder, name: string) =>
|
||||||
|
guard(async () => {
|
||||||
|
await api.cloudRenameFolder(folder.id, name);
|
||||||
|
await refreshCloud();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCloudFolder = (folder: CloudFolder) =>
|
||||||
|
guard(async () => {
|
||||||
|
await api.cloudDeleteFolder(folder.id);
|
||||||
await refreshCloud();
|
await refreshCloud();
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteCloudProject = (id: string) =>
|
const deleteCloudProject = (id: string) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
await api.cloudDeleteProject(id);
|
await api.cloudDeleteProject(id);
|
||||||
await refreshCloudProjects();
|
await refreshCloud();
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteCloudDocument = (id: string) =>
|
const deleteCloudDocument = (id: string) =>
|
||||||
@@ -199,11 +223,31 @@
|
|||||||
await refreshCloud();
|
await refreshCloud();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploadCloudFiles = () =>
|
||||||
|
guard(async () => {
|
||||||
|
const sources = await pickFiles("assets");
|
||||||
|
if (sources.length === 0) return;
|
||||||
|
|
||||||
|
const folderId = currentCloudFolder();
|
||||||
|
for (const source of sources) {
|
||||||
|
await api.cloudUploadFile(source, folderId);
|
||||||
|
}
|
||||||
|
await refreshCloud();
|
||||||
|
setStatus(`Uploaded ${sources.length} file(s) to TypstDrive`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameCloudFile = (file: CloudFile, name: string) =>
|
||||||
|
guard(async () => {
|
||||||
|
await api.cloudRenameFile(file.id, name);
|
||||||
|
await refreshCloud();
|
||||||
|
});
|
||||||
|
|
||||||
const cloneCloudProject = (id: string, name: string) =>
|
const cloneCloudProject = (id: string, name: string) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
await api.cloudCloneProject(id, name);
|
const parent = app.currentDir;
|
||||||
|
await api.cloudCloneProject(id, name, parent);
|
||||||
app.scope = "local";
|
app.scope = "local";
|
||||||
await browseTo("");
|
await browseTo(parent);
|
||||||
setStatus(`Downloaded '${name}' to this device`);
|
setStatus(`Downloaded '${name}' to this device`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -477,7 +521,11 @@
|
|||||||
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
|
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="ph:file-code" class="text-lg text-[var(--color-accent)]" />
|
<span
|
||||||
|
class="flex h-6 w-6 items-center justify-center rounded-md bg-[var(--color-accent)]"
|
||||||
|
>
|
||||||
|
<img src="/favicon.png" alt="Typst Desktop" class="h-6 w-6" />
|
||||||
|
</span>
|
||||||
<span data-tauri-drag-region class="text-sm font-semibold">
|
<span data-tauri-drag-region class="text-sm font-semibold">
|
||||||
Typst Desktop
|
Typst Desktop
|
||||||
</span>
|
</span>
|
||||||
@@ -485,6 +533,23 @@
|
|||||||
|
|
||||||
<div data-tauri-drag-region class="h-full flex-1"></div>
|
<div data-tauri-drag-region class="h-full flex-1"></div>
|
||||||
|
|
||||||
|
{#if app.view !== "editor"}
|
||||||
|
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
|
||||||
|
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
|
||||||
|
{app.scope === value
|
||||||
|
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
|
||||||
|
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
||||||
|
onclick={() => (app.scope = value as "local" | "cloud")}
|
||||||
|
>
|
||||||
|
<Icon {icon} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if app.view === "editor"}
|
{#if app.view === "editor"}
|
||||||
<span
|
<span
|
||||||
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
|
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
|
||||||
@@ -641,8 +706,15 @@
|
|||||||
onremovedownload={removeDownloadedDocument}
|
onremovedownload={removeDownloadedDocument}
|
||||||
ondownloadfile={downloadCloudFile}
|
ondownloadfile={downloadCloudFile}
|
||||||
ondeletefile={(id) => (dialog = { kind: "delete-cloud-file", id })}
|
ondeletefile={(id) => (dialog = { kind: "delete-cloud-file", id })}
|
||||||
|
onuploadfile={uploadCloudFiles}
|
||||||
|
onrenamefile={(file) => (dialog = { kind: "rename-cloud-file", file })}
|
||||||
onnewcloudproject={() => (dialog = { kind: "new-cloud-project" })}
|
onnewcloudproject={() => (dialog = { kind: "new-cloud-project" })}
|
||||||
onnewclouddocument={() => (dialog = { kind: "new-cloud-document" })}
|
onnewclouddocument={() => (dialog = { kind: "new-cloud-document" })}
|
||||||
|
onnewcloudfolder={() => (dialog = { kind: "new-cloud-folder" })}
|
||||||
|
onrenamecloudfolder={(folder) =>
|
||||||
|
(dialog = { kind: "rename-cloud-folder", folder })}
|
||||||
|
ondeletecloudfolder={(folder) =>
|
||||||
|
(dialog = { kind: "delete-cloud-folder", folder })}
|
||||||
oncloneproject={(id, name) =>
|
oncloneproject={(id, name) =>
|
||||||
(dialog = { kind: "clone-cloud-project", id, name })}
|
(dialog = { kind: "clone-cloud-project", id, name })}
|
||||||
ondeleteproject={(id) => (dialog = { kind: "delete-cloud-project", id })}
|
ondeleteproject={(id) => (dialog = { kind: "delete-cloud-project", id })}
|
||||||
@@ -866,6 +938,32 @@
|
|||||||
onsubmit={createCloudDocument}
|
onsubmit={createCloudDocument}
|
||||||
onclose={close}
|
onclose={close}
|
||||||
/>
|
/>
|
||||||
|
{:else if dialog.kind === "new-cloud-folder"}
|
||||||
|
<PromptModal
|
||||||
|
title="New cloud folder"
|
||||||
|
label="Folder name"
|
||||||
|
icon="ph:folder-plus"
|
||||||
|
onsubmit={createCloudFolder}
|
||||||
|
onclose={close}
|
||||||
|
/>
|
||||||
|
{:else if dialog.kind === "rename-cloud-folder"}
|
||||||
|
{@const target = dialog}
|
||||||
|
<PromptModal
|
||||||
|
title="Rename folder"
|
||||||
|
label="New name"
|
||||||
|
value={target.folder.name}
|
||||||
|
confirmLabel="Rename"
|
||||||
|
onsubmit={(name) => renameCloudFolder(target.folder, name)}
|
||||||
|
onclose={close}
|
||||||
|
/>
|
||||||
|
{:else if dialog.kind === "delete-cloud-folder"}
|
||||||
|
{@const target = dialog}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete cloud folder"
|
||||||
|
message="'{target.folder.name}' will be permanently removed from TypstDrive. It must be empty first."
|
||||||
|
onconfirm={() => deleteCloudFolder(target.folder)}
|
||||||
|
onclose={close}
|
||||||
|
/>
|
||||||
{:else if dialog.kind === "delete-cloud-project"}
|
{:else if dialog.kind === "delete-cloud-project"}
|
||||||
{@const target = dialog}
|
{@const target = dialog}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@@ -890,6 +988,16 @@
|
|||||||
onconfirm={() => deleteCloudFile(target.id)}
|
onconfirm={() => deleteCloudFile(target.id)}
|
||||||
onclose={close}
|
onclose={close}
|
||||||
/>
|
/>
|
||||||
|
{:else if dialog.kind === "rename-cloud-file"}
|
||||||
|
{@const target = dialog}
|
||||||
|
<PromptModal
|
||||||
|
title="Rename file"
|
||||||
|
label="New name"
|
||||||
|
value={target.file.name}
|
||||||
|
confirmLabel="Rename"
|
||||||
|
onsubmit={(name) => renameCloudFile(target.file, name)}
|
||||||
|
onclose={close}
|
||||||
|
/>
|
||||||
{:else if dialog.kind === "clone-cloud-project"}
|
{:else if dialog.kind === "clone-cloud-project"}
|
||||||
{@const target = dialog}
|
{@const target = dialog}
|
||||||
<PromptModal
|
<PromptModal
|
||||||
|
|||||||
Reference in New Issue
Block a user