Fixed Sharing Issue and added Shared Drive Folder
This commit is contained in:
+78
-2
@@ -8,7 +8,7 @@ use serde::Deserialize;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{Collaborator, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest},
|
models::{Collaborator, CollaboratorView, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ pub async fn invite_collaborator(
|
|||||||
return Err((StatusCode::FORBIDDEN, "Only the owner can invite collaborators".to_string()));
|
return Err((StatusCode::FORBIDDEN, "Only the owner can invite collaborators".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash FROM users WHERE email = ?")
|
let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE email = ?")
|
||||||
.bind(&payload.email)
|
.bind(&payload.email)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
@@ -103,6 +103,82 @@ pub async fn accept_invite(
|
|||||||
Ok(Json(collab))
|
Ok(Json(collab))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_collaborators(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(doc_id): Path<String>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Vec<CollaboratorView>>, (StatusCode, String)> {
|
||||||
|
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||||
|
|
||||||
|
// Only owner or collaborators on the document can see the list
|
||||||
|
let has_access = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM documents WHERE id = ? AND owner_id = ? \
|
||||||
|
UNION ALL SELECT COUNT(*) FROM collaborators WHERE document_id = ? AND user_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&doc_id).bind(&user_id).bind(&doc_id).bind(&user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.into_iter().sum::<i64>() > 0;
|
||||||
|
|
||||||
|
if !has_access {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let collaborators = sqlx::query_as::<_, CollaboratorView>(
|
||||||
|
"SELECT c.id, c.user_id, u.username, u.email, c.role, c.created_at \
|
||||||
|
FROM collaborators c \
|
||||||
|
INNER JOIN users u ON u.id = c.user_id \
|
||||||
|
WHERE c.document_id = ? \
|
||||||
|
ORDER BY c.created_at ASC"
|
||||||
|
)
|
||||||
|
.bind(&doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(collaborators))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_collaborator(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((doc_id, collab_id)): Path<(String, String)>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||||
|
|
||||||
|
// Only the document owner can remove collaborators
|
||||||
|
let is_owner = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM documents WHERE id = ? AND owner_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&doc_id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? > 0;
|
||||||
|
|
||||||
|
if !is_owner {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only the document owner can remove collaborators".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM collaborators WHERE id = ? AND document_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&collab_id)
|
||||||
|
.bind(&doc_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Collaborator not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_comments(
|
pub async fn get_comments(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(doc_id): Path<String>,
|
Path(doc_id): Path<String>,
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ pub struct ListDocsQuery {
|
|||||||
pub folder_id: Option<String>,
|
pub folder_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_shared_documents(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Vec<Document>>, (StatusCode, String)> {
|
||||||
|
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||||
|
|
||||||
|
let docs = sqlx::query_as::<_, Document>(
|
||||||
|
"SELECT d.id, d.owner_id, d.folder_id, d.title, d.content, d.thumbnail_svg, \
|
||||||
|
d.public_role, d.created_at, d.updated_at, c.role as effective_role \
|
||||||
|
FROM documents d \
|
||||||
|
INNER JOIN collaborators c ON c.document_id = d.id AND c.user_id = ? \
|
||||||
|
ORDER BY d.updated_at DESC"
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(docs))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_documents(
|
pub async fn list_documents(
|
||||||
axum::extract::Query(query): axum::extract::Query<ListDocsQuery>,
|
axum::extract::Query(query): axum::extract::Query<ListDocsQuery>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
+27
-2
@@ -208,7 +208,7 @@ pub async fn compile_handler(
|
|||||||
|
|
||||||
if has_access {
|
if has_access {
|
||||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
||||||
.bind(doc.owner_id)
|
.bind(&doc.owner_id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -216,6 +216,19 @@ pub async fn compile_handler(
|
|||||||
files_map.insert(name, data);
|
files_map.insert(name, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Also include files uploaded by collaborators specifically for this document
|
||||||
|
if let Ok(collab_files) = sqlx::query_as::<_, (String, Vec<u8>)>(
|
||||||
|
"SELECT name, data FROM files WHERE document_id = ? AND owner_id != ?"
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.bind(&doc.owner_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
for (name, data) in collab_files {
|
||||||
|
files_map.insert(name, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +311,7 @@ pub async fn export_handler(
|
|||||||
|
|
||||||
if has_access {
|
if has_access {
|
||||||
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = ?")
|
||||||
.bind(doc.owner_id)
|
.bind(&doc.owner_id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -306,6 +319,18 @@ pub async fn export_handler(
|
|||||||
files_map.insert(name, data);
|
files_map.insert(name, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Ok(collab_files) = sqlx::query_as::<_, (String, Vec<u8>)>(
|
||||||
|
"SELECT name, data FROM files WHERE document_id = ? AND owner_id != ?"
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.bind(&doc.owner_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
for (name, data) in collab_files {
|
||||||
|
files_map.insert(name, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,10 +113,13 @@ async fn main() {
|
|||||||
.route("/files", get(files::list_files).post(files::upload_file_global))
|
.route("/files", get(files::list_files).post(files::upload_file_global))
|
||||||
.route("/files/{id}", delete(files::delete_file).patch(files::update_file))
|
.route("/files/{id}", delete(files::delete_file).patch(files::update_file))
|
||||||
.route("/files/{id}/data", get(files::get_file_data))
|
.route("/files/{id}/data", get(files::get_file_data))
|
||||||
|
.route("/docs/shared", get(docs::list_shared_documents))
|
||||||
.route("/docs", get(docs::list_documents).post(docs::create_document))
|
.route("/docs", get(docs::list_documents).post(docs::create_document))
|
||||||
.route("/docs/accept-invite", get(collab::accept_invite))
|
.route("/docs/accept-invite", get(collab::accept_invite))
|
||||||
.route("/docs/{id}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document))
|
.route("/docs/{id}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document))
|
||||||
.route("/docs/{id}/files", post(docs::upload_file))
|
.route("/docs/{id}/files", post(docs::upload_file))
|
||||||
|
.route("/docs/{id}/collaborators", get(collab::list_collaborators))
|
||||||
|
.route("/docs/{id}/collaborators/{collab_id}", delete(collab::remove_collaborator))
|
||||||
.route("/docs/{id}/invite", post(collab::invite_collaborator))
|
.route("/docs/{id}/invite", post(collab::invite_collaborator))
|
||||||
.route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment))
|
.route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment))
|
||||||
.route("/docs/{id}/versions", get(collab::get_versions).post(collab::create_version))
|
.route("/docs/{id}/versions", get(collab::get_versions).post(collab::create_version))
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ pub struct Collaborator {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct CollaboratorView {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub role: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct Invitation {
|
pub struct Invitation {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -5,15 +5,41 @@
|
|||||||
|
|
||||||
let { onClose, docId = undefined } = $props<{ onClose: () => void, docId?: string }>();
|
let { onClose, docId = undefined } = $props<{ onClose: () => void, docId?: string }>();
|
||||||
|
|
||||||
|
type CollaboratorView = { id: string; user_id: string; username: string; email: string; role: string; created_at: string };
|
||||||
|
|
||||||
let link = $state('');
|
let link = $state('');
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let role = $state('editor');
|
let role = $state('editor');
|
||||||
|
|
||||||
|
let collaborators = $state<CollaboratorView[]>([]);
|
||||||
|
let collabLoading = $state(false);
|
||||||
|
let removingId = $state<string | null>(null);
|
||||||
|
|
||||||
let inviteEmail = $state('');
|
let inviteEmail = $state('');
|
||||||
let inviteRole = $state('editor');
|
let inviteRole = $state('editor');
|
||||||
let inviteStatus = $state<'idle' | 'loading' | 'success' | 'error'>('idle');
|
let inviteStatus = $state<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
let inviteMessage = $state('');
|
let inviteMessage = $state('');
|
||||||
|
|
||||||
|
async function loadCollaborators() {
|
||||||
|
if (!docId) return;
|
||||||
|
collabLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/docs/${docId}/collaborators`);
|
||||||
|
if (res.ok) collaborators = await res.json();
|
||||||
|
} catch {}
|
||||||
|
collabLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCollaborator(collab: CollaboratorView) {
|
||||||
|
if (!docId) return;
|
||||||
|
removingId = collab.id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/docs/${docId}/collaborators/${collab.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) collaborators = collaborators.filter(c => c.id !== collab.id);
|
||||||
|
} catch {}
|
||||||
|
removingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function inviteUser(e: Event) {
|
async function inviteUser(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!docId || !inviteEmail.trim()) return;
|
if (!docId || !inviteEmail.trim()) return;
|
||||||
@@ -24,9 +50,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/docs/${docId}/invite`, {
|
const res = await fetch(`/api/docs/${docId}/invite`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole })
|
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,25 +58,23 @@
|
|||||||
inviteStatus = 'success';
|
inviteStatus = 'success';
|
||||||
inviteMessage = 'User invited successfully!';
|
inviteMessage = 'User invited successfully!';
|
||||||
inviteEmail = '';
|
inviteEmail = '';
|
||||||
|
loadCollaborators();
|
||||||
} else {
|
} else {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
inviteStatus = 'error';
|
inviteStatus = 'error';
|
||||||
inviteMessage = text || 'Failed to invite user';
|
inviteMessage = text || 'Failed to invite user';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
|
||||||
inviteStatus = 'error';
|
inviteStatus = 'error';
|
||||||
inviteMessage = 'Network error occurred';
|
inviteMessage = 'Network error occurred';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
||||||
const baseUrl = window.location.origin;
|
const baseUrl = window.location.origin;
|
||||||
const docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href;
|
const docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href;
|
||||||
|
|
||||||
|
|
||||||
link = `${docUrl}?role=${role}`;
|
link = `${docUrl}?role=${role}`;
|
||||||
|
loadCollaborators();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -125,6 +147,44 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if collabLoading}
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-400 dark:text-gray-500 py-1">
|
||||||
|
<Icon icon="mdi:loading" class="animate-spin text-base" />
|
||||||
|
Loading collaborators...
|
||||||
|
</div>
|
||||||
|
{:else if collaborators.length > 0}
|
||||||
|
<div class="h-px bg-gray-200 dark:bg-zinc-800/50"></div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-semibold text-[var(--theme-text)]" style="color: var(--theme-text);">People with access</div>
|
||||||
|
{#each collaborators as collab (collab.id)}
|
||||||
|
<div class="flex items-center gap-3 py-1.5">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 text-sm font-bold flex-shrink-0">
|
||||||
|
{collab.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{collab.username}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{collab.email}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold px-2 py-0.5 rounded-full flex-shrink-0 {collab.role === 'editor' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300' : 'bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-400'}">
|
||||||
|
{collab.role}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => removeCollaborator(collab)}
|
||||||
|
disabled={removingId === collab.id}
|
||||||
|
title="Remove collaborator"
|
||||||
|
class="flex-shrink-0 p-1 rounded text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{#if removingId === collab.id}
|
||||||
|
<Icon icon="mdi:loading" class="text-base animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Icon icon="mdi:close" class="text-base" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="h-px bg-gray-200 dark:bg-zinc-800/50"></div>
|
<div class="h-px bg-gray-200 dark:bg-zinc-800/50"></div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -716,7 +716,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if isShareModalOpen}
|
{#if isShareModalOpen}
|
||||||
<ShareModal onClose={() => (isShareModalOpen = false)} />
|
<ShareModal {docId} onClose={() => (isShareModalOpen = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isPageSettingsOpen}
|
{#if isPageSettingsOpen}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToBreadcrumb(index: number) {
|
function navigateToBreadcrumb(index: number) {
|
||||||
|
inSharedDrive = false;
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
folderPath = [];
|
folderPath = [];
|
||||||
currentFolderId = null;
|
currentFolderId = null;
|
||||||
@@ -314,6 +315,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let inSharedDrive = $state(false);
|
||||||
|
let sharedDocs = $state<any[]>([]);
|
||||||
|
let sharedDocsLoading = $state(false);
|
||||||
|
|
||||||
|
async function loadSharedDocs() {
|
||||||
|
sharedDocsLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/docs/shared');
|
||||||
|
if (res.ok) sharedDocs = await res.json();
|
||||||
|
} catch {}
|
||||||
|
sharedDocsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterSharedDrive() {
|
||||||
|
inSharedDrive = true;
|
||||||
|
folderPath = [];
|
||||||
|
currentFolderId = null;
|
||||||
|
loadSharedDocs();
|
||||||
|
}
|
||||||
|
|
||||||
let showShareModal = $state(false);
|
let showShareModal = $state(false);
|
||||||
let shareTarget = $state<any>(null);
|
let shareTarget = $state<any>(null);
|
||||||
|
|
||||||
@@ -429,6 +450,12 @@
|
|||||||
class="hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === -1 ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : ''}">
|
class="hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === -1 ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : ''}">
|
||||||
<Icon icon="mdi:home" class="text-lg inline-block pb-0.5" /> Home
|
<Icon icon="mdi:home" class="text-lg inline-block pb-0.5" /> Home
|
||||||
</button>
|
</button>
|
||||||
|
{#if inSharedDrive}
|
||||||
|
<Icon icon="mdi:chevron-right" class="text-lg text-gray-400" />
|
||||||
|
<span class="font-medium text-purple-600 dark:text-purple-400 flex items-center gap-1 px-2 py-1">
|
||||||
|
<Icon icon="mdi:folder-account" class="text-base" /> Shared with me
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
{#each folderPath as folder, index}
|
{#each folderPath as folder, index}
|
||||||
<Icon icon="mdi:chevron-right" class="text-lg text-gray-400" />
|
<Icon icon="mdi:chevron-right" class="text-lg text-gray-400" />
|
||||||
<button
|
<button
|
||||||
@@ -440,37 +467,71 @@
|
|||||||
{folder.name}
|
{folder.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if inSharedDrive}
|
||||||
|
{#if sharedDocsLoading}
|
||||||
|
<div class="min-h-[50vh] flex items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-4 text-gray-500 dark:text-gray-400 animate-pulse">
|
||||||
|
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
|
||||||
|
<p class="text-lg font-medium">Loading shared documents...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if sharedDocs.length === 0}
|
||||||
|
<div class="min-h-[50vh] flex items-center justify-center">
|
||||||
|
<div class="text-center p-12 bg-white/50 dark:bg-black/20 rounded-2xl shadow-sm border border-gray-200 dark:border-white/10 max-w-md w-full">
|
||||||
|
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-purple-100/50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400 mb-6">
|
||||||
|
<Icon icon="mdi:folder-account-outline" class="text-4xl" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">No shared documents</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Documents shared with you by other users will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
{#each sharedDocs as doc}
|
||||||
|
<DocCard
|
||||||
|
{doc}
|
||||||
|
{activeMenu}
|
||||||
|
setActiveMenu={(id) => activeMenu = id}
|
||||||
|
{openInfo}
|
||||||
|
openRename={() => {}}
|
||||||
|
{shareItem}
|
||||||
|
deleteDoc={() => {}}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if loading}
|
||||||
<div class="min-h-[50vh] flex items-center justify-center">
|
<div class="min-h-[50vh] flex items-center justify-center">
|
||||||
<div class="flex flex-col items-center gap-4 text-gray-500 dark:text-gray-400 animate-pulse">
|
<div class="flex flex-col items-center gap-4 text-gray-500 dark:text-gray-400 animate-pulse">
|
||||||
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
|
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
|
||||||
<p class="text-lg font-medium">Loading your workspace...</p>
|
<p class="text-lg font-medium">Loading your workspace...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if documents.length === 0 && folders.length === 0 && files.length === 0 && currentFolderId === null}
|
|
||||||
<div class="min-h-[50vh] flex items-center justify-center">
|
|
||||||
<div class="text-center p-12 bg-white/50 dark:bg-black/20 backdrop-blur-sm rounded-2xl shadow-sm border border-gray-200 dark:border-white/10 max-w-md w-full">
|
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-blue-100/50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 mb-6">
|
|
||||||
<Icon icon="mdi:file-document-outline" class="text-4xl" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">No documents yet</h3>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-8">Get started by creating your first Typst document. It's fast, collaborative, and beautiful.</p>
|
|
||||||
<button onclick={openCreateModal} class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg shadow-sm text-base font-medium transition-colors">
|
|
||||||
<Icon icon="mdi:plus" class="text-xl" />
|
|
||||||
Create Document
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Folders section — always visible at root (includes "Shared with me" virtual folder) -->
|
||||||
{#if folders.length > 0}
|
{#if currentFolderId === null || folders.length > 0}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="px-2 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
<div class="px-2 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Folders</div>
|
||||||
Folders
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{#if currentFolderId === null}
|
||||||
|
<!-- Shared with me — permanent, undeletable virtual folder -->
|
||||||
|
<div
|
||||||
|
class="flex flex-row items-center p-3 bg-white/50 dark:bg-black/20 border border-gray-200 dark:border-white/10 rounded-xl shadow-sm hover:shadow-md cursor-pointer group transition-all duration-200 hover:-translate-y-0.5 hover:border-purple-300 dark:hover:border-purple-500/30"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={enterSharedDrive}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && enterSharedDrive()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center w-10 h-10 bg-purple-50 dark:bg-purple-500/10 rounded-lg group-hover:scale-105 transition-transform pointer-events-none shrink-0 mr-3">
|
||||||
|
<Icon icon="mdi:folder-account" class="text-2xl text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white text-sm truncate w-full pointer-events-none">Shared with me</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#each folders as folder}
|
{#each folders as folder}
|
||||||
<FolderRow
|
<FolderRow
|
||||||
{folder}
|
{folder}
|
||||||
@@ -485,7 +546,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
{#if documents.length > 0 || files.length > 0}
|
{#if documents.length > 0 || files.length > 0}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
{#each documents as doc}
|
{#each documents as doc}
|
||||||
@@ -499,17 +559,29 @@
|
|||||||
{deleteDoc}
|
{deleteDoc}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each files as file}
|
{#each files as file}
|
||||||
<FileCard {file} {deleteFile} />
|
<FileCard {file} {deleteFile} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if documents.length === 0 && folders.length === 0 && files.length === 0}
|
<!-- Empty states -->
|
||||||
<div class="min-h-[50vh] flex items-center justify-center">
|
{#if documents.length === 0 && files.length === 0 && currentFolderId !== null && folders.length === 0}
|
||||||
|
<div class="min-h-[30vh] flex items-center justify-center">
|
||||||
<p class="text-gray-500 dark:text-gray-400">This folder is empty.</p>
|
<p class="text-gray-500 dark:text-gray-400">This folder is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if documents.length === 0 && files.length === 0 && folders.length === 0 && currentFolderId === null}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100/50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 mb-4">
|
||||||
|
<Icon icon="mdi:file-document-outline" class="text-3xl" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-1">No documents yet</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-6 text-sm">Create your first Typst document to get started.</p>
|
||||||
|
<button onclick={openCreateModal} class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg shadow-sm text-sm font-medium transition-colors">
|
||||||
|
<Icon icon="mdi:plus" class="text-lg" />
|
||||||
|
Create Document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user