Notes: live folder-sharing (read-only) — Task #445
Owner can share a whole folder with other users; recipients see it
under "Shared with me" in the folders rail and view notes read-only
(cannot edit, add, move, or delete).
- DB: new noteFolderSharesTable (folderId/recipientUserId, cascade,
unique idx). Pushed via drizzle-kit.
- API (artifacts/api-server/src/routes/notes.ts):
- GET/PATCH/POST /note-folders return sharedWithCount.
- GET /note-folders/shared-with-me, GET /note-folders/:id/shares,
PUT /note-folders/:id/shares (idempotent diff), DELETE
/note-folders/:id/shares/:userId, GET /note-folders/:id/shared-notes
(verifies recipient via noteFolderSharesTable, stamps readOnly).
- Emits note-folder-shared / note-folder-unshared on share changes.
- Existing note write paths remain owner-scoped → recipients can't
mutate owner notes.
- Frontend types/hooks (notes-api.ts): SharedFolder, FolderShareRecipient,
SharedFolderNote/View; useFolderShares, useUpdateFolderShares,
useSharedWithMeFolders, useSharedFolderNotes.
- FoldersRail: Share menu item, sharedWithCount badge, "Shared with me"
section, "shared-folder" selection kind, onShareFolder prop.
- notes.tsx: SharedFolderView (no Composer, read-only cards using
ChecklistView), FolderShareDialog (seeds existing recipients,
idempotent PUT on save), revocation fallback effect, dialog mount.
- use-notifications-socket.ts: subscribe to note-folder-shared /
-unshared → invalidate shared-with-me + open shared-folder notes for
live rail/view refresh.
- i18n: AR/EN keys for share/sharedBy/sharedByName/sharedWithCount/
sharedWithMe/readOnly/shareRevoked/sharedEmpty/shareSaved/shareFailed.
Pre-existing failing `test` workflow (executive-meetings type errors)
is unrelated and out of scope.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
noteLabelAssignmentsTable,
|
||||
noteRecipientsTable,
|
||||
noteRepliesTable,
|
||||
noteFolderSharesTable,
|
||||
usersTable,
|
||||
notificationsTable,
|
||||
} from "@workspace/db";
|
||||
@@ -1136,7 +1137,7 @@ router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
|
||||
eq(notesTable.userId, userId),
|
||||
eq(notesTable.isArchived, archived),
|
||||
);
|
||||
const [folders, counts] = await Promise.all([
|
||||
const [folders, counts, shareCounts] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(noteFoldersTable)
|
||||
@@ -1150,12 +1151,34 @@ router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
|
||||
.from(notesTable)
|
||||
.where(noteScope)
|
||||
.groupBy(notesTable.folderId),
|
||||
db
|
||||
.select({
|
||||
folderId: noteFolderSharesTable.folderId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(noteFolderSharesTable)
|
||||
.innerJoin(
|
||||
noteFoldersTable,
|
||||
eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
|
||||
)
|
||||
.where(eq(noteFoldersTable.userId, userId))
|
||||
.groupBy(noteFolderSharesTable.folderId),
|
||||
]);
|
||||
const countMap = new Map<number, number>();
|
||||
for (const row of counts) {
|
||||
if (row.folderId != null) countMap.set(row.folderId, Number(row.count));
|
||||
}
|
||||
res.json(folders.map((f) => ({ ...f, noteCount: countMap.get(f.id) ?? 0 })));
|
||||
const shareCountMap = new Map<number, number>();
|
||||
for (const row of shareCounts) {
|
||||
shareCountMap.set(row.folderId, Number(row.count));
|
||||
}
|
||||
res.json(
|
||||
folders.map((f) => ({
|
||||
...f,
|
||||
noteCount: countMap.get(f.id) ?? 0,
|
||||
sharedWithCount: shareCountMap.get(f.id) ?? 0,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
|
||||
@@ -1170,7 +1193,7 @@ router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
|
||||
.insert(noteFoldersTable)
|
||||
.values({ userId, name: parsed.name })
|
||||
.returning();
|
||||
res.status(201).json({ ...folder, noteCount: 0 });
|
||||
res.status(201).json({ ...folder, noteCount: 0, sharedWithCount: 0 });
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) {
|
||||
res.status(409).json({ error: "Folder already exists" });
|
||||
@@ -1215,7 +1238,15 @@ router.patch("/note-folders/:id", requireAuth, async (req, res): Promise<void> =
|
||||
eq(notesTable.folderId, folder.id),
|
||||
),
|
||||
);
|
||||
res.json({ ...folder, noteCount: Number(countRow?.count ?? 0) });
|
||||
const [shareRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(noteFolderSharesTable)
|
||||
.where(eq(noteFolderSharesTable.folderId, folder.id));
|
||||
res.json({
|
||||
...folder,
|
||||
noteCount: Number(countRow?.count ?? 0),
|
||||
sharedWithCount: Number(shareRow?.count ?? 0),
|
||||
});
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) {
|
||||
res.status(409).json({ error: "Folder already exists" });
|
||||
@@ -1243,4 +1274,272 @@ router.delete("/note-folders/:id", requireAuth, async (req, res): Promise<void>
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ---------- Folder-level live sharing ----------
|
||||
//
|
||||
// A folder owner can share a folder read-only with other users. Recipients see
|
||||
// the owner's CURRENT notes inside the folder (live, not snapshot). They cannot
|
||||
// edit, add, move, or delete anything; the existing per-note routes already
|
||||
// filter by `notesTable.userId = req.session.userId`, so any write attempt by a
|
||||
// recipient just 404s — no extra gating required there.
|
||||
|
||||
router.get(
|
||||
"/note-folders/shared-with-me",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const rows = await db
|
||||
.select({
|
||||
id: noteFoldersTable.id,
|
||||
name: noteFoldersTable.name,
|
||||
ownerId: noteFoldersTable.userId,
|
||||
sharedAt: noteFolderSharesTable.sharedAt,
|
||||
ownerUsername: usersTable.username,
|
||||
ownerDisplayNameAr: usersTable.displayNameAr,
|
||||
ownerDisplayNameEn: usersTable.displayNameEn,
|
||||
})
|
||||
.from(noteFolderSharesTable)
|
||||
.innerJoin(
|
||||
noteFoldersTable,
|
||||
eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
|
||||
)
|
||||
.innerJoin(usersTable, eq(noteFoldersTable.userId, usersTable.id))
|
||||
.where(eq(noteFolderSharesTable.recipientUserId, userId))
|
||||
.orderBy(noteFoldersTable.name);
|
||||
res.json(rows);
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/note-folders/:id/shares",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const id = parseIdParam(req.params.id);
|
||||
if (!id.ok) {
|
||||
res.status(400).json({ error: id.error });
|
||||
return;
|
||||
}
|
||||
if (!(await userOwnsFolder(userId, id.id))) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
sharedAt: noteFolderSharesTable.sharedAt,
|
||||
})
|
||||
.from(noteFolderSharesTable)
|
||||
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
|
||||
.where(eq(noteFolderSharesTable.folderId, id.id))
|
||||
.orderBy(noteFolderSharesTable.sharedAt);
|
||||
res.json(rows);
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/note-folders/:id/shares",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const id = parseIdParam(req.params.id);
|
||||
if (!id.ok) {
|
||||
res.status(400).json({ error: id.error });
|
||||
return;
|
||||
}
|
||||
if (!(await userOwnsFolder(userId, id.id))) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== "object" || !Array.isArray(body.recipientUserIds)) {
|
||||
res.status(400).json({ error: "Invalid recipientUserIds" });
|
||||
return;
|
||||
}
|
||||
const desired = new Set<number>();
|
||||
for (const x of body.recipientUserIds) {
|
||||
const n = Number(x);
|
||||
if (Number.isInteger(n) && n > 0 && n !== userId) desired.add(n);
|
||||
}
|
||||
// Validate that every desired recipient actually exists.
|
||||
if (desired.size > 0) {
|
||||
const valid = await db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(inArray(usersTable.id, Array.from(desired)));
|
||||
const validIds = new Set(valid.map((r) => r.id));
|
||||
for (const id of Array.from(desired)) {
|
||||
if (!validIds.has(id)) desired.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Diff current vs desired and apply incrementally so repeated PUTs are
|
||||
// idempotent and we only emit notifications for genuinely new recipients.
|
||||
const current = await db
|
||||
.select({ recipientUserId: noteFolderSharesTable.recipientUserId })
|
||||
.from(noteFolderSharesTable)
|
||||
.where(eq(noteFolderSharesTable.folderId, id.id));
|
||||
const currentSet = new Set(current.map((r) => r.recipientUserId));
|
||||
|
||||
const toAdd: number[] = [];
|
||||
const toRemove: number[] = [];
|
||||
for (const r of desired) if (!currentSet.has(r)) toAdd.push(r);
|
||||
for (const r of currentSet) if (!desired.has(r)) toRemove.push(r);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
await db
|
||||
.insert(noteFolderSharesTable)
|
||||
.values(toAdd.map((rid) => ({ folderId: id.id, recipientUserId: rid })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
if (toRemove.length > 0) {
|
||||
await db
|
||||
.delete(noteFolderSharesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(noteFolderSharesTable.folderId, id.id),
|
||||
inArray(noteFolderSharesTable.recipientUserId, toRemove),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Emit live socket events so recipients' rails refresh without polling.
|
||||
for (const rid of toAdd) {
|
||||
void emitToUser(rid, "note-folder-shared", { folderId: id.id });
|
||||
}
|
||||
for (const rid of toRemove) {
|
||||
void emitToUser(rid, "note-folder-unshared", { folderId: id.id });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
sharedAt: noteFolderSharesTable.sharedAt,
|
||||
})
|
||||
.from(noteFolderSharesTable)
|
||||
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
|
||||
.where(eq(noteFolderSharesTable.folderId, id.id))
|
||||
.orderBy(noteFolderSharesTable.sharedAt);
|
||||
res.json(rows);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/note-folders/:id/shares/:userId",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const ownerId = req.session.userId!;
|
||||
const id = parseIdParam(req.params.id);
|
||||
const target = parseIdParam(req.params.userId);
|
||||
if (!id.ok || !target.ok) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await userOwnsFolder(ownerId, id.id))) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.delete(noteFolderSharesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(noteFolderSharesTable.folderId, id.id),
|
||||
eq(noteFolderSharesTable.recipientUserId, target.id),
|
||||
),
|
||||
);
|
||||
void emitToUser(target.id, "note-folder-unshared", { folderId: id.id });
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /note-folders/:id/shared-notes
|
||||
*
|
||||
* Recipient view of a shared folder. Returns the OWNER's current notes inside
|
||||
* the folder (live join — not a snapshot), with `readOnly: true` so the client
|
||||
* knows to disable every write affordance. 403 if the caller is not a current
|
||||
* recipient of the share.
|
||||
*/
|
||||
router.get(
|
||||
"/note-folders/:id/shared-notes",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const id = parseIdParam(req.params.id);
|
||||
if (!id.ok) {
|
||||
res.status(400).json({ error: id.error });
|
||||
return;
|
||||
}
|
||||
const [share] = await db
|
||||
.select({ folderId: noteFolderSharesTable.folderId })
|
||||
.from(noteFolderSharesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(noteFolderSharesTable.folderId, id.id),
|
||||
eq(noteFolderSharesTable.recipientUserId, userId),
|
||||
),
|
||||
);
|
||||
if (!share) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
const [folder] = await db
|
||||
.select()
|
||||
.from(noteFoldersTable)
|
||||
.where(eq(noteFoldersTable.id, id.id));
|
||||
if (!folder) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const notes = await db
|
||||
.select()
|
||||
.from(notesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(notesTable.userId, folder.userId),
|
||||
eq(notesTable.folderId, folder.id),
|
||||
eq(notesTable.isArchived, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(notesTable.isPinned), desc(notesTable.updatedAt));
|
||||
|
||||
let withLabels: Array<(typeof notes)[number] & { labelIds: number[] }> = [];
|
||||
if (notes.length > 0) {
|
||||
const ids = notes.map((n) => n.id);
|
||||
const assignments = await db
|
||||
.select()
|
||||
.from(noteLabelAssignmentsTable)
|
||||
.where(inArray(noteLabelAssignmentsTable.noteId, ids));
|
||||
const map = new Map<number, number[]>();
|
||||
for (const a of assignments) {
|
||||
const arr = map.get(a.noteId) ?? [];
|
||||
arr.push(a.labelId);
|
||||
map.set(a.noteId, arr);
|
||||
}
|
||||
withLabels = notes.map((n) => ({ ...n, labelIds: map.get(n.id) ?? [] }));
|
||||
}
|
||||
|
||||
const [owner] = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, folder.userId));
|
||||
|
||||
res.json({
|
||||
folder: { id: folder.id, name: folder.name, ownerId: folder.userId },
|
||||
owner: owner ?? null,
|
||||
notes: withLabels.map((n) => ({ ...n, readOnly: true as const })),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
Layers,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Share2,
|
||||
Trash2,
|
||||
Users,
|
||||
Check,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -32,20 +34,32 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
type NoteFolder,
|
||||
type SharedFolder,
|
||||
useCreateFolder,
|
||||
useDeleteFolder,
|
||||
useSharedWithMeFolders,
|
||||
useUpdateFolder,
|
||||
userDisplayName,
|
||||
} from "@/lib/notes-api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export type FolderSelection =
|
||||
| { kind: "all" }
|
||||
| { kind: "unfiled" }
|
||||
| { kind: "folder"; id: number };
|
||||
| { kind: "folder"; id: number }
|
||||
// A folder shared WITH the current user. Distinct kind because the
|
||||
// notes inside come from a different endpoint, the rail row carries
|
||||
// the owner's display name, and every write affordance is hidden.
|
||||
| { kind: "shared-folder"; id: number };
|
||||
|
||||
export function selectionMatches(a: FolderSelection, b: FolderSelection): boolean {
|
||||
if (a.kind !== b.kind) return false;
|
||||
if (a.kind === "folder" && b.kind === "folder") return a.id === b.id;
|
||||
if (
|
||||
(a.kind === "folder" && b.kind === "folder") ||
|
||||
(a.kind === "shared-folder" && b.kind === "shared-folder")
|
||||
) {
|
||||
return a.id === b.id;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -130,6 +144,9 @@ interface Props {
|
||||
unfiledCount: number;
|
||||
totalCount: number;
|
||||
onDropNote: (target: "unfiled" | number) => void;
|
||||
// Owner action: open the folder-share dialog. The page handles mounting
|
||||
// <FolderShareDialog> so the rail stays focused on navigation/selection.
|
||||
onShareFolder?: (folder: NoteFolder) => void;
|
||||
// "rail" → full sidebar / chip rail with folder rows (default).
|
||||
// "chips-only" → render only the All-notes / Unfiled / "+ New folder"
|
||||
// chips. Folder rows are owned by the FoldersBrowser in the
|
||||
@@ -144,9 +161,14 @@ export function FoldersRail({
|
||||
unfiledCount,
|
||||
totalCount,
|
||||
onDropNote,
|
||||
onShareFolder,
|
||||
mode = "rail",
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// Folders shared WITH the current user. Rendered in a dedicated section
|
||||
// below the owner's folders so the two namespaces are visually distinct.
|
||||
const { data: sharedFolders = [] } = useSharedWithMeFolders();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
@@ -398,6 +420,24 @@ export function FoldersRail({
|
||||
>
|
||||
{f.noteCount}
|
||||
</span>
|
||||
{f.sharedWithCount > 0 && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||
isSel
|
||||
? "bg-background/20 text-background"
|
||||
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
}`}
|
||||
title={t("notes.folders.sharedWithCount", {
|
||||
defaultValue_one: "Shared with 1 person",
|
||||
defaultValue_other: "Shared with {{count}} people",
|
||||
count: f.sharedWithCount,
|
||||
})}
|
||||
data-testid={`folder-shared-badge-${f.id}`}
|
||||
>
|
||||
<Users size={10} />
|
||||
{f.sharedWithCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center pe-1">
|
||||
<DropdownMenu>
|
||||
@@ -414,7 +454,7 @@ export function FoldersRail({
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setEditingId(f.id);
|
||||
@@ -425,6 +465,15 @@ export function FoldersRail({
|
||||
<Pencil size={14} className="me-2" />
|
||||
{t("notes.folders.rename", "Rename folder")}
|
||||
</DropdownMenuItem>
|
||||
{onShareFolder && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onShareFolder(f)}
|
||||
data-testid={`folder-share-${f.id}`}
|
||||
>
|
||||
<Share2 size={14} className="me-2" />
|
||||
{t("notes.folders.share", "Share folder")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPendingDelete(f)}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
@@ -483,6 +532,64 @@ export function FoldersRail({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mode === "rail" && sharedFolders.length > 0 && (
|
||||
<div
|
||||
className="contents md:block md:pt-3"
|
||||
data-testid="shared-with-me-section"
|
||||
>
|
||||
<div className="hidden md:block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground px-2 mb-1 mt-3">
|
||||
{t("notes.folders.sharedWithMe", "Shared with me")}
|
||||
</div>
|
||||
{sharedFolders.map((sf: SharedFolder) => {
|
||||
const isSel =
|
||||
selected.kind === "shared-folder" && selected.id === sf.id;
|
||||
const ownerName = userDisplayName(
|
||||
{
|
||||
id: sf.ownerId,
|
||||
username: sf.ownerUsername,
|
||||
displayNameAr: sf.ownerDisplayNameAr,
|
||||
displayNameEn: sf.ownerDisplayNameEn,
|
||||
},
|
||||
lang,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={`shared:${sf.id}`}
|
||||
className={`group rounded-lg border transition shrink-0 md:shrink ${
|
||||
isSel
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-transparent hover:bg-slate-100"
|
||||
}`}
|
||||
data-testid={`shared-folder-row-${sf.id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ kind: "shared-folder", id: sf.id })}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start whitespace-nowrap"
|
||||
data-testid={`shared-folder-select-${sf.id}`}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
<Share2 size={14} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block truncate">{sf.name}</span>
|
||||
<span
|
||||
className={`block truncate text-[10px] ${
|
||||
isSel ? "text-background/80" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("notes.folders.sharedByName", "by {{name}}", {
|
||||
name: ownerName,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden md:block text-[10px] text-muted-foreground/80 px-2 pt-1 leading-snug">
|
||||
{t(
|
||||
"notes.folders.dragHint",
|
||||
|
||||
@@ -323,6 +323,30 @@ export function useNotificationsSocket() {
|
||||
},
|
||||
);
|
||||
|
||||
// Folder-sharing live updates (#445). Owner shares/unshares a folder
|
||||
// with this user → invalidate the rail's "Shared with me" list, and any
|
||||
// open shared-folder note view for that folder so it either refreshes
|
||||
// (still shared) or surfaces the revoked empty state (unshared) without
|
||||
// a manual reload.
|
||||
socket.on(
|
||||
"note-folder-shared",
|
||||
(payload?: { folderId?: number }) => {
|
||||
invalidate(["note-folders", "shared-with-me"]);
|
||||
if (typeof payload?.folderId === "number") {
|
||||
invalidate(["notes", "shared-folder", payload.folderId]);
|
||||
}
|
||||
},
|
||||
);
|
||||
socket.on(
|
||||
"note-folder-unshared",
|
||||
(payload?: { folderId?: number }) => {
|
||||
invalidate(["note-folders", "shared-with-me"]);
|
||||
if (typeof payload?.folderId === "number") {
|
||||
invalidate(["notes", "shared-folder", payload.folderId]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
socket.on("apps_changed", () => {
|
||||
invalidate(getListAppsQueryKey());
|
||||
invalidate(getGetMeQueryKey());
|
||||
|
||||
@@ -36,10 +36,39 @@ export interface NoteFolder {
|
||||
userId: number;
|
||||
name: string;
|
||||
noteCount: number;
|
||||
// Number of distinct recipients the owner has shared this folder with.
|
||||
// 0 when the folder is not shared. Always present on the wire (server
|
||||
// defaults missing rows to 0), so the rail can render a "shared" badge
|
||||
// unconditionally without tristate logic.
|
||||
sharedWithCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// A folder shared WITH the current user by another user. Returned by
|
||||
// GET /note-folders/shared-with-me. Distinct from `NoteFolder` because the
|
||||
// caller is not the owner: there are no per-tab note counts (notes change
|
||||
// live as the owner edits) and the owner's display info travels with the row
|
||||
// so the rail can render "by <name>" without an extra fetch.
|
||||
export interface SharedFolder {
|
||||
id: number;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
sharedAt: string;
|
||||
ownerUsername: string;
|
||||
ownerDisplayNameAr: string | null;
|
||||
ownerDisplayNameEn: string | null;
|
||||
}
|
||||
|
||||
// One row in a folder's share list (people the owner has shared TO).
|
||||
export interface FolderShareRecipient {
|
||||
userId: number;
|
||||
username: string;
|
||||
displayNameAr: string | null;
|
||||
displayNameEn: string | null;
|
||||
sharedAt: string;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -518,6 +547,79 @@ export function useMoveNoteToFolder() {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Folder sharing ----------
|
||||
|
||||
export const folderSharesKey = (folderId: number) =>
|
||||
["note-folder-shares", folderId] as const;
|
||||
export const sharedWithMeKey = ["note-folders", "shared-with-me"] as const;
|
||||
export const sharedFolderNotesKey = (folderId: number) =>
|
||||
["notes", "shared-folder", folderId] as const;
|
||||
|
||||
// People the OWNER has shared a given folder with. 404s for non-owners.
|
||||
export function useFolderShares(folderId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: folderSharesKey(folderId ?? 0),
|
||||
queryFn: () =>
|
||||
req<FolderShareRecipient[]>(`/note-folders/${folderId}/shares`),
|
||||
enabled: folderId != null,
|
||||
});
|
||||
}
|
||||
|
||||
// Replace the recipient set on a folder. Server diffs idempotently so this
|
||||
// can be called repeatedly with the desired final set.
|
||||
export function useUpdateFolderShares() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
folderId,
|
||||
recipientUserIds,
|
||||
}: {
|
||||
folderId: number;
|
||||
recipientUserIds: number[];
|
||||
}) =>
|
||||
req<FolderShareRecipient[]>(`/note-folders/${folderId}/shares`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ recipientUserIds }),
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: folderSharesKey(vars.folderId) });
|
||||
// Owner's folder list shows the "shared with N" badge — refresh it.
|
||||
qc.invalidateQueries({ queryKey: ["note-folders"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Folders shared WITH the current user.
|
||||
export function useSharedWithMeFolders() {
|
||||
return useQuery({
|
||||
queryKey: sharedWithMeKey,
|
||||
queryFn: () => req<SharedFolder[]>(`/note-folders/shared-with-me`),
|
||||
});
|
||||
}
|
||||
|
||||
// Note returned inside a shared-folder view. Carries the readOnly flag the
|
||||
// server stamps on every record so the UI doesn't have to recompute access.
|
||||
export interface SharedFolderNote extends Note {
|
||||
readOnly: true;
|
||||
}
|
||||
|
||||
export interface SharedFolderView {
|
||||
folder: { id: number; name: string; ownerId: number };
|
||||
owner: UserSummary | null;
|
||||
notes: SharedFolderNote[];
|
||||
}
|
||||
|
||||
// Notes inside a folder shared with the caller. Live (owner's current
|
||||
// notes), not a snapshot.
|
||||
export function useSharedFolderNotes(folderId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: sharedFolderNotesKey(folderId ?? 0),
|
||||
queryFn: () =>
|
||||
req<SharedFolderView>(`/note-folders/${folderId}/shared-notes`),
|
||||
enabled: folderId != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLabel() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1063,7 +1063,25 @@
|
||||
"bulkDeleteTitle": "حذف المجلدات المحددة؟",
|
||||
"bulkDeleteConfirm": "ستنتقل الملاحظات داخل هذه المجلدات إلى \"بدون مجلد\".",
|
||||
"bulkDeleteFailed": "تعذّر حذف بعض المجلدات",
|
||||
"backToFolders": "العودة إلى المجلدات"
|
||||
"backToFolders": "العودة إلى المجلدات",
|
||||
"share": "مشاركة المجلد",
|
||||
"shareTitle": "مشاركة المجلد",
|
||||
"shareDescription": "يمكن للمستلمين عرض الملاحظات في هذا المجلد دون تعديلها.",
|
||||
"shareSaved": "تم تحديث المشاركة",
|
||||
"shareFailed": "تعذّر تحديث المشاركة",
|
||||
"shareRevoked": "لم يعد هذا المجلد مشاركاً معك",
|
||||
"sharedEmpty": "لا توجد ملاحظات في هذا المجلد بعد",
|
||||
"sharedWithMe": "مشاركة معي",
|
||||
"sharedBy": "مشاركة من",
|
||||
"sharedByName": "بواسطة {{name}}",
|
||||
"sharedWithCount_zero": "غير مشارك",
|
||||
"sharedWithCount_one": "مشاركة مع شخص واحد",
|
||||
"sharedWithCount_two": "مشاركة مع شخصين",
|
||||
"sharedWithCount_few": "مشاركة مع {{count}} أشخاص",
|
||||
"sharedWithCount_many": "مشاركة مع {{count}} شخصاً",
|
||||
"sharedWithCount_other": "مشاركة مع {{count}} شخص",
|
||||
"sharedWithCount": "مشاركة مع {{count}} أشخاص",
|
||||
"readOnly": "للقراءة فقط"
|
||||
},
|
||||
"tabs": {
|
||||
"active": "ملاحظاتي",
|
||||
|
||||
@@ -964,7 +964,21 @@
|
||||
"bulkDeleteTitle": "Delete selected folders?",
|
||||
"bulkDeleteConfirm": "Notes inside these folders will move to Unfiled.",
|
||||
"bulkDeleteFailed": "Some folders could not be deleted",
|
||||
"backToFolders": "Back to folders"
|
||||
"backToFolders": "Back to folders",
|
||||
"share": "Share folder",
|
||||
"shareTitle": "Share folder",
|
||||
"shareDescription": "Recipients can view but not edit notes in this folder.",
|
||||
"shareSaved": "Sharing updated",
|
||||
"shareFailed": "Could not update sharing",
|
||||
"shareRevoked": "This folder is no longer shared with you",
|
||||
"sharedEmpty": "This folder has no notes yet",
|
||||
"sharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by",
|
||||
"sharedByName": "by {{name}}",
|
||||
"sharedWithCount_one": "Shared with {{count}} person",
|
||||
"sharedWithCount_other": "Shared with {{count}} people",
|
||||
"sharedWithCount": "Shared with {{count}} people",
|
||||
"readOnly": "Read-only"
|
||||
},
|
||||
"tabs": {
|
||||
"active": "My Notes",
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
Pencil,
|
||||
MoreVertical,
|
||||
ChevronLeft,
|
||||
Share2,
|
||||
Users,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -98,6 +101,11 @@ import {
|
||||
useUpdateNote,
|
||||
useUserDirectory,
|
||||
userDisplayName,
|
||||
useFolderShares,
|
||||
useSharedFolderNotes,
|
||||
useSharedWithMeFolders,
|
||||
useUpdateFolderShares,
|
||||
type FolderShareRecipient,
|
||||
} from "@/lib/notes-api";
|
||||
import {
|
||||
FoldersRail,
|
||||
@@ -196,18 +204,29 @@ export default function NotesPage() {
|
||||
const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived");
|
||||
const { data: labels = [] } = useNoteLabels();
|
||||
const { data: folders = [] } = useNoteFolders(view === "archived");
|
||||
const { data: sharedFolders = [] } = useSharedWithMeFolders();
|
||||
const moveNote = useMoveNoteToFolder();
|
||||
const showFolders = view === "active" || view === "archived";
|
||||
|
||||
// Folder the share dialog is currently open for. `null` → dialog closed.
|
||||
// Owned folder only — recipients never see this entry point.
|
||||
const [folderShareFor, setFolderShareFor] = useState<NoteFolder | null>(null);
|
||||
|
||||
// If the user switches tabs (or a folder is deleted) such that the current
|
||||
// selection no longer exists in this view's folder set, fall back to "all"
|
||||
// so they don't get stuck on an empty list.
|
||||
// so they don't get stuck on an empty list. Same guard for "shared-folder"
|
||||
// selections when the owner revokes the share.
|
||||
useEffect(() => {
|
||||
if (folderSelection.kind !== "folder") return;
|
||||
if (!folders.some((f) => f.id === folderSelection.id)) {
|
||||
setFolderSelection({ kind: "all" });
|
||||
if (folderSelection.kind === "folder") {
|
||||
if (!folders.some((f) => f.id === folderSelection.id)) {
|
||||
setFolderSelection({ kind: "all" });
|
||||
}
|
||||
} else if (folderSelection.kind === "shared-folder") {
|
||||
if (!sharedFolders.some((f) => f.id === folderSelection.id)) {
|
||||
setFolderSelection({ kind: "all" });
|
||||
}
|
||||
}
|
||||
}, [folders, folderSelection]);
|
||||
}, [folders, sharedFolders, folderSelection]);
|
||||
const { data: receivedNotes = [], isLoading: loadingReceived } =
|
||||
useReceivedNotes(false);
|
||||
const { data: archivedReceivedNotes = [], isLoading: loadingArchivedReceived } =
|
||||
@@ -446,11 +465,23 @@ export default function NotesPage() {
|
||||
unfiledCount={unfiledCount}
|
||||
totalCount={notes.length}
|
||||
onDropNote={handleDropNote}
|
||||
onShareFolder={(f) => setFolderShareFor(f)}
|
||||
mode={view === "active" && folderSelection.kind === "all" ? "chips-only" : "rail"}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{view === "active" && (
|
||||
{view === "active" && folderSelection.kind === "shared-folder" && (
|
||||
// Recipient view of a folder shared with the current user. Rendered
|
||||
// before the regular active branch so we never mount the Composer
|
||||
// (recipients are read-only) or run the owner-only filteredNotes
|
||||
// pipeline against the wrong note set.
|
||||
<SharedFolderView
|
||||
folderId={folderSelection.id}
|
||||
labels={labels}
|
||||
onBack={() => setFolderSelection({ kind: "all" })}
|
||||
/>
|
||||
)}
|
||||
{view === "active" && folderSelection.kind !== "shared-folder" && (
|
||||
<>
|
||||
{folderSelection.kind === "all" && (
|
||||
<FoldersBrowser
|
||||
@@ -570,6 +601,13 @@ export default function NotesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{folderShareFor && (
|
||||
<FolderShareDialog
|
||||
folder={folderShareFor}
|
||||
onClose={() => setFolderShareFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openThreadId !== null && (
|
||||
<ThreadDialog
|
||||
noteId={openThreadId}
|
||||
@@ -2914,3 +2952,281 @@ function LabelsDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Recipient view of a folder shared with the current user.
|
||||
//
|
||||
// Strict read-only: no Composer, no edit/send/archive/delete affordances on
|
||||
// cards. Notes are rendered with the same styling as the inbox/sent grids
|
||||
// (which are themselves read-only) so the visual language stays familiar
|
||||
// without us having to thread a `readOnly` flag through the full NoteCard.
|
||||
// Drives the "Shared with me" entry in the folders rail and is unmounted
|
||||
// the moment the owner revokes access (parent effect falls back to "all").
|
||||
function SharedFolderView({
|
||||
folderId,
|
||||
labels: _labels,
|
||||
onBack,
|
||||
}: {
|
||||
folderId: number;
|
||||
labels: NoteLabel[];
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { data, isLoading, error } = useSharedFolderNotes(folderId);
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
// 403/404 — the owner unshared the folder while we were viewing it. Show a
|
||||
// brief "no longer shared" empty state; the parent effect will switch the
|
||||
// selection back to "all" on the next sharedFolders tick.
|
||||
if (error || !data) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mb-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
data-testid="notes-folders-back"
|
||||
>
|
||||
<ChevronLeft size={14} className="rtl:rotate-180" />
|
||||
<span>{t("notes.folders.backToFolders", "Back to folders")}</span>
|
||||
</button>
|
||||
<Empty
|
||||
icon={<Lock size={48} />}
|
||||
text={t(
|
||||
"notes.folders.shareRevoked",
|
||||
"This folder is no longer shared with you",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mb-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
data-testid="notes-folders-back"
|
||||
>
|
||||
<ChevronLeft size={14} className="rtl:rotate-180" />
|
||||
<span>{t("notes.folders.backToFolders", "Back to folders")}</span>
|
||||
<span className="text-foreground/80 font-medium ms-1">
|
||||
{data.folder.name}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className="mb-4 flex items-center gap-2 text-xs text-muted-foreground rounded-lg bg-slate-50 dark:bg-slate-900/40 border border-slate-200/70 dark:border-slate-700/60 px-3 py-2"
|
||||
data-testid="shared-folder-banner"
|
||||
>
|
||||
<Lock size={12} />
|
||||
<span>
|
||||
{t("notes.folders.sharedBy", "Shared by")}{" "}
|
||||
<span className="text-foreground/80 font-medium">
|
||||
{userDisplayName(data.owner, lang)}
|
||||
</span>
|
||||
{" · "}
|
||||
{t("notes.folders.readOnly", "Read-only")}
|
||||
</span>
|
||||
</div>
|
||||
{data.notes.length === 0 ? (
|
||||
<Empty
|
||||
icon={<StickyNote size={48} />}
|
||||
text={t("notes.folders.sharedEmpty", "This folder has no notes yet")}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="gap-3 mt-2 [column-fill:_balance]"
|
||||
style={{ columnWidth: 260 }}
|
||||
data-testid="shared-folder-notes"
|
||||
>
|
||||
{data.notes.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
data-testid={`shared-folder-note-${n.id}`}
|
||||
className={`text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 ${colorBg(
|
||||
n.color,
|
||||
)}`}
|
||||
>
|
||||
{n.title && (
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{n.title}
|
||||
</div>
|
||||
)}
|
||||
{n.kind === "checklist" ? (
|
||||
<ChecklistView
|
||||
items={n.items ?? []}
|
||||
testIdPrefix={`shared-folder-note-checklist-${n.id}`}
|
||||
className="mt-1"
|
||||
noteColor={n.color}
|
||||
/>
|
||||
) : (
|
||||
n.content && (
|
||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
|
||||
{n.content}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Owner-side dialog: pick which users this folder is shared with.
|
||||
//
|
||||
// Renders the directory minus self; pre-checks anyone already in the share
|
||||
// list. "Save" calls the idempotent PUT endpoint with the full desired set
|
||||
// — server diffs adds/removes and broadcasts share/unshare events so
|
||||
// recipients' rails update live. Closing without changes is a no-op.
|
||||
function FolderShareDialog({
|
||||
folder,
|
||||
onClose,
|
||||
}: {
|
||||
folder: NoteFolder;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { user } = useAuth();
|
||||
const { data: directory = [], isLoading: loadingDir } = useUserDirectory();
|
||||
const { data: existing = [], isLoading: loadingShares } = useFolderShares(
|
||||
folder.id,
|
||||
);
|
||||
const update = useUpdateFolderShares();
|
||||
const [search, setSearch] = useState("");
|
||||
const [picked, setPicked] = useState<Set<number> | null>(null);
|
||||
|
||||
// Seed the picked set from the existing shares once both sources have
|
||||
// landed. We only do this on the first non-loading tick so user toggles
|
||||
// don't get clobbered by a refetch.
|
||||
useEffect(() => {
|
||||
if (picked !== null) return;
|
||||
if (loadingShares) return;
|
||||
setPicked(new Set(existing.map((r) => r.userId)));
|
||||
}, [existing, loadingShares, picked]);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const me = user?.id;
|
||||
return directory.filter((u) => u.id !== me);
|
||||
}, [directory, user]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return candidates;
|
||||
return candidates.filter((u) =>
|
||||
[u.username, u.displayNameAr, u.displayNameEn]
|
||||
.filter(Boolean)
|
||||
.some((s) => (s as string).toLowerCase().includes(q)),
|
||||
);
|
||||
}, [candidates, search]);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev ?? []);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (picked === null) return;
|
||||
update.mutate(
|
||||
{ folderId: folder.id, recipientUserIds: Array.from(picked) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: t("notes.folders.shareSaved", "Sharing updated") });
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t("notes.folders.shareFailed", "Could not update sharing"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading = loadingDir || loadingShares || picked === null;
|
||||
const count = picked?.size ?? 0;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md" data-testid="folder-share-dialog">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>
|
||||
{t("notes.folders.shareTitle", "Share folder")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-xs text-muted-foreground -mt-1">
|
||||
<span className="text-foreground/80 font-medium">{folder.name}</span>
|
||||
{" · "}
|
||||
{t("notes.folders.shareDescription", "Recipients can view but not edit notes in this folder.")}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground start-2.5"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
|
||||
className="ps-9"
|
||||
data-testid="folder-share-search"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
|
||||
{isLoading && (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">—</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
filtered.map((u) => {
|
||||
const checked = picked?.has(u.id) ?? false;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => toggle(u.id)}
|
||||
data-testid={`folder-share-option-${u.id}`}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${
|
||||
checked ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{userDisplayName(u, lang)}</span>
|
||||
{checked && <Check size={14} className="text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("notes.folders.sharedWithCount", { count })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={isLoading || update.isPending}
|
||||
data-testid="folder-share-submit"
|
||||
>
|
||||
<Share2 size={14} className="me-1" />
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,3 +106,31 @@ export type NoteFolder = typeof noteFoldersTable.$inferSelect;
|
||||
|
||||
export type NoteRecipient = typeof noteRecipientsTable.$inferSelect;
|
||||
export type NoteReply = typeof noteRepliesTable.$inferSelect;
|
||||
|
||||
/**
|
||||
* Folder-level live share. The folder owner (`noteFoldersTable.userId`) grants
|
||||
* a recipient (`recipientUserId`) read-only access to ALL notes currently in
|
||||
* the folder. Unlike `noteRecipientsTable`, this is NOT a snapshot — recipients
|
||||
* always see the owner's current notes via a join, so adds/edits/removes by the
|
||||
* owner are reflected live in the recipient's "Shared with me" view.
|
||||
*
|
||||
* Read-only by design: there is no role column. Recipients cannot edit, add,
|
||||
* move, or delete notes inside a shared folder; they can only read.
|
||||
*/
|
||||
export const noteFolderSharesTable = pgTable("note_folder_shares", {
|
||||
id: serial("id").primaryKey(),
|
||||
folderId: integer("folder_id")
|
||||
.notNull()
|
||||
.references(() => noteFoldersTable.id, { onDelete: "cascade" }),
|
||||
recipientUserId: integer("recipient_user_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
sharedAt: timestamp("shared_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
folderRecipientUnique: uniqueIndex("note_folder_shares_folder_recipient_unique").on(
|
||||
t.folderId,
|
||||
t.recipientUserId,
|
||||
),
|
||||
}));
|
||||
|
||||
export type NoteFolderShare = typeof noteFolderSharesTable.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user