diff options
| author | Fuwn <[email protected]> | 2026-02-10 01:08:11 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-10 01:08:11 -0800 |
| commit | 920d22332069f1ca60740c290173a95846fb38c3 (patch) | |
| tree | 5909cecde94bf1acd83385a5b3d789175f2713f0 | |
| parent | fix: service worker cross-origin image handling and CI env vars (diff) | |
| download | asa.news-920d22332069f1ca60740c290173a95846fb38c3.tar.xz asa.news-920d22332069f1ca60740c290173a95846fb38c3.zip | |
feat: scoped mark-all-read, share enhancements, notification z-index
- Mark all as read now scopes to current feed/folder instead of all
- Added undo button to mark-all-read toast notification
- Share notes can be toggled between public and private visibility
- Track share view count and display in shares list
- Activity-based share expiry: views reset the expiry timer
- Fixed notification panel z-index layering behind content area
| -rw-r--r-- | apps/web/app/api/share/[token]/route.ts | 16 | ||||
| -rw-r--r-- | apps/web/app/api/share/route.ts | 4 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/notification-panel.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/reader-shell.tsx | 7 | ||||
| -rw-r--r-- | apps/web/app/reader/shares/_components/shares-content.tsx | 63 | ||||
| -rw-r--r-- | apps/web/app/shared/[token]/page.tsx | 31 | ||||
| -rw-r--r-- | apps/web/lib/hooks/use-keyboard-navigation.ts | 7 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-mark-all-as-read.ts | 21 | ||||
| -rw-r--r-- | apps/web/lib/stores/user-interface-store.ts | 12 | ||||
| -rw-r--r-- | supabase/schema.sql | 10 |
10 files changed, 155 insertions, 18 deletions
diff --git a/apps/web/app/api/share/[token]/route.ts b/apps/web/app/api/share/[token]/route.ts index 1a3f0ef..a1d8568 100644 --- a/apps/web/app/api/share/[token]/route.ts +++ b/apps/web/app/api/share/[token]/route.ts @@ -82,9 +82,21 @@ export async function PATCH( note = rawNote.trim() || null } + const updatePayload: Record<string, unknown> = {} + if (rawNote !== undefined) { + updatePayload.note = note + } + if (typeof body.noteIsPublic === "boolean") { + updatePayload.note_is_public = body.noteIsPublic + } + + if (Object.keys(updatePayload).length === 0) { + return NextResponse.json({ ok: true }) + } + const { error } = await supabaseClient .from("shared_entries") - .update({ note }) + .update(updatePayload) .eq("share_token", token) .eq("user_id", user.id) @@ -95,5 +107,5 @@ export async function PATCH( ) } - return NextResponse.json({ note }) + return NextResponse.json({ ok: true }) } diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts index ca8f8e2..b7f60c8 100644 --- a/apps/web/app/api/share/route.ts +++ b/apps/web/app/api/share/route.ts @@ -76,6 +76,8 @@ export async function POST(request: Request) { note = rawNote.trim() || null } + const noteIsPublic = body.noteIsPublic === true + let highlightedText: string | null = null let highlightTextOffset: number | null = null let highlightTextLength: number | null = null @@ -159,7 +161,9 @@ export async function POST(request: Request) { entry_id: entryIdentifier, share_token: shareToken, expires_at: expiresAt, + expiry_interval_days: expiryDays, note, + note_is_public: noteIsPublic, highlighted_text: highlightedText, highlight_text_offset: highlightTextOffset, highlight_text_length: highlightTextLength, diff --git a/apps/web/app/reader/_components/notification-panel.tsx b/apps/web/app/reader/_components/notification-panel.tsx index ea4d052..45746b4 100644 --- a/apps/web/app/reader/_components/notification-panel.tsx +++ b/apps/web/app/reader/_components/notification-panel.tsx @@ -51,7 +51,7 @@ export function NotificationPanel({ onClose }: { onClose: () => void }) { return ( <div ref={panelReference} - className="fixed bottom-16 left-2 z-50 w-80 max-w-[calc(100vw-1rem)] border border-border bg-background-secondary shadow-lg md:absolute md:bottom-full md:left-0 md:mb-1" + className="fixed bottom-16 left-2 z-[9998] w-80 max-w-[calc(100vw-1rem)] border border-border bg-background-secondary shadow-lg" > <div className="flex items-center justify-between border-b border-border px-3 py-2"> <span className="text-text-primary">notifications</span> diff --git a/apps/web/app/reader/_components/reader-shell.tsx b/apps/web/app/reader/_components/reader-shell.tsx index 1d50dc2..45621af 100644 --- a/apps/web/app/reader/_components/reader-shell.tsx +++ b/apps/web/app/reader/_components/reader-shell.tsx @@ -88,6 +88,11 @@ export function ReaderShell({ return () => useUserInterfaceStore.getState().setResetDetailLayout(null) }, [detailGroupRef]) + useEffect(() => { + useUserInterfaceStore.getState().setCurrentFeedIdentifier(feedIdentifier ?? null) + useUserInterfaceStore.getState().setCurrentFolderIdentifier(folderIdentifier ?? null) + }, [feedIdentifier, folderIdentifier]) + useRealtimeEntries() const updateSubscriptionTitle = useUpdateSubscriptionTitle() @@ -248,7 +253,7 @@ export function ReaderShell({ <button type="button" onClick={() => - markAllAsRead.mutate({ readState: !allAreRead }) + markAllAsRead.mutate({ feedIdentifier, folderIdentifier, readState: !allAreRead }) } disabled={markAllAsRead.isPending} className="text-text-dim transition-colors hover:text-text-secondary disabled:opacity-50" diff --git a/apps/web/app/reader/shares/_components/shares-content.tsx b/apps/web/app/reader/shares/_components/shares-content.tsx index db50bc7..8012146 100644 --- a/apps/web/app/reader/shares/_components/shares-content.tsx +++ b/apps/web/app/reader/shares/_components/shares-content.tsx @@ -18,6 +18,9 @@ interface SharedEntry { createdAt: string expiresAt: string | null note: string | null + noteIsPublic: boolean + viewCount: number + lastViewedAt: string | null entryTitle: string | null entryUrl: string | null } @@ -30,7 +33,7 @@ function useSharedEntries() { queryFn: async () => { const { data, error } = await supabaseClient .from("shared_entries") - .select("id, entry_id, share_token, created_at, expires_at, note, entries(title, url)") + .select("id, entry_id, share_token, created_at, expires_at, note, note_is_public, view_count, last_viewed_at, entries(title, url)") .order("created_at", { ascending: false }) if (error) throw error @@ -42,13 +45,18 @@ function useSharedEntries() { url: string | null } | null + const rowData = row as Record<string, unknown> + return { identifier: row.id, entryIdentifier: row.entry_id, shareToken: row.share_token, createdAt: row.created_at, expiresAt: row.expires_at, - note: (row as Record<string, unknown>).note as string | null, + note: rowData.note as string | null, + noteIsPublic: (rowData.note_is_public as boolean) ?? false, + viewCount: (rowData.view_count as number) ?? 0, + lastViewedAt: rowData.last_viewed_at as string | null, entryTitle: entryData?.title ?? null, entryUrl: entryData?.url ?? null, } @@ -85,20 +93,23 @@ function useUpdateShareNote() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ shareToken, note }: { shareToken: string; note: string | null }) => { + mutationFn: async ({ shareToken, note, noteIsPublic }: { shareToken: string; note?: string | null; noteIsPublic?: boolean }) => { + const payload: Record<string, unknown> = {} + if (note !== undefined) payload.note = note + if (noteIsPublic !== undefined) payload.noteIsPublic = noteIsPublic const response = await fetch(`/api/share/${shareToken}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ note }), + body: JSON.stringify(payload), }) - if (!response.ok) throw new Error("failed to update note") + if (!response.ok) throw new Error("failed to update share") }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) - notify("note updated") + notify("share updated") }, onError: () => { - notify("failed to update note") + notify("failed to update share") }, }) } @@ -168,6 +179,11 @@ function ShareRow({ {share.entryTitle ?? "untitled"} </span> {expired && <span className="shrink-0 text-status-error">expired</span>} + {share.viewCount > 0 && ( + <span className="shrink-0 text-text-dim"> + {share.viewCount} view{share.viewCount !== 1 ? "s" : ""} + </span> + )} <span className="shrink-0 text-text-dim">{sharedDate}</span> </div> </div> @@ -204,6 +220,12 @@ function ShareRow({ )} </> )} + {share.viewCount > 0 && ( + <> + <span>·</span> + <span>{share.viewCount} view{share.viewCount !== 1 ? "s" : ""}</span> + </> + )} {share.note && ( <> <span>·</span> @@ -281,6 +303,18 @@ function ShareRow({ )} </> )} + {share.viewCount > 0 && ( + <> + {" \u00b7 "} + {share.viewCount} view{share.viewCount !== 1 ? "s" : ""} + </> + )} + {share.noteIsPublic && share.note && ( + <> + {" \u00b7 "} + <span>note is public</span> + </> + )} </p> </div> </div> @@ -304,6 +338,21 @@ function ShareRow({ > {share.note ? "edit note" : "add note"} </button> + {share.note && ( + <button + type="button" + onClick={(event) => { + event.stopPropagation() + updateNote.mutate({ + shareToken: share.shareToken, + noteIsPublic: !share.noteIsPublic, + }) + }} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + {share.noteIsPublic ? "make note private" : "make note public"} + </button> + )} {showRevokeConfirm ? ( <div className="flex items-center gap-1"> <span className="text-text-dim">revoke?</span> diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx index eaab4f1..b913721 100644 --- a/apps/web/app/shared/[token]/page.tsx +++ b/apps/web/app/shared/[token]/page.tsx @@ -17,8 +17,13 @@ interface SharedHighlightData { } interface SharedEntryRow { + id: string entry_id: string expires_at: string | null + note: string | null + note_is_public: boolean + view_count: number + expiry_interval_days: number highlighted_text: string | null highlight_text_offset: number | null highlight_text_length: number | null @@ -45,7 +50,7 @@ async function fetchSharedEntry(token: string) { const { data, error } = await adminClient .from("shared_entries") .select( - "entry_id, expires_at, highlighted_text, highlight_text_offset, highlight_text_length, highlight_text_prefix, highlight_text_suffix, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" + "id, entry_id, expires_at, note, note_is_public, view_count, expiry_interval_days, highlighted_text, highlight_text_offset, highlight_text_length, highlight_text_prefix, highlight_text_suffix, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" ) .eq("share_token", token) .maybeSingle() @@ -73,7 +78,24 @@ async function fetchSharedEntry(token: string) { } } - return { expired: false as const, entry: row.entries, highlightData } + const publicNote = row.note_is_public && row.note ? row.note : null + + const expiryIntervalDays = row.expiry_interval_days ?? 7 + const newExpiresAt = new Date( + Date.now() + expiryIntervalDays * 24 * 60 * 60 * 1000 + ).toISOString() + + adminClient + .from("shared_entries") + .update({ + view_count: row.view_count + 1, + last_viewed_at: new Date().toISOString(), + expires_at: newExpiresAt, + }) + .eq("id", row.id) + .then(() => {}) + + return { expired: false as const, entry: row.entries, highlightData, publicNote } } export async function generateMetadata({ @@ -142,6 +164,11 @@ export default async function SharedPage({ params }: SharedPageProperties) { {entry.author && <span> · {entry.author}</span>} {formattedDate && <span> · {formattedDate}</span>} </div> + {result.publicNote && ( + <blockquote className="mb-6 border-l-2 border-border pl-4 text-text-secondary italic"> + {result.publicNote} + </blockquote> + )} {entry.enclosure_url && ( <div className="mb-4 border border-border p-3"> <audio diff --git a/apps/web/lib/hooks/use-keyboard-navigation.ts b/apps/web/lib/hooks/use-keyboard-navigation.ts index 275db29..ca353f0 100644 --- a/apps/web/lib/hooks/use-keyboard-navigation.ts +++ b/apps/web/lib/hooks/use-keyboard-navigation.ts @@ -284,7 +284,12 @@ export function useKeyboardNavigation() { case "A": { if (event.shiftKey) { event.preventDefault() - markAllAsRead.mutate({}) + const currentFeedIdentifier = useUserInterfaceStore.getState().currentFeedIdentifier + const currentFolderIdentifier = useUserInterfaceStore.getState().currentFolderIdentifier + markAllAsRead.mutate({ + feedIdentifier: currentFeedIdentifier, + folderIdentifier: currentFolderIdentifier, + }) } break diff --git a/apps/web/lib/queries/use-mark-all-as-read.ts b/apps/web/lib/queries/use-mark-all-as-read.ts index fdda661..d2ba82e 100644 --- a/apps/web/lib/queries/use-mark-all-as-read.ts +++ b/apps/web/lib/queries/use-mark-all-as-read.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { toast } from "sonner" import { queryKeys } from "./query-keys" import { notify } from "@/lib/notify" @@ -35,9 +36,27 @@ export function useMarkAllAsRead() { queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) const action = variables?.readState === false ? "unread" : "read" + const undoReadState = !(variables?.readState ?? true) if (affectedCount > 0) { - notify(`marked ${affectedCount} entries as ${action}`) + toast(`marked ${affectedCount} entries as ${action}`, { + action: { + label: "undo", + onClick: () => { + supabaseClient + .rpc("mark_all_as_read", { + p_feed_id: variables?.feedIdentifier ?? null, + p_folder_id: variables?.folderIdentifier ?? null, + p_read_state: undoReadState, + }) + .then(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + }) + }, + }, + }) } }, onError: (_, variables) => { diff --git a/apps/web/lib/stores/user-interface-store.ts b/apps/web/lib/stores/user-interface-store.ts index 42c72ee..4c17735 100644 --- a/apps/web/lib/stores/user-interface-store.ts +++ b/apps/web/lib/stores/user-interface-store.ts @@ -48,10 +48,14 @@ interface UserInterfaceState { toolbarPosition: ToolbarPosition isShortcutsDialogOpen: boolean expandedFolderIdentifiers: string[] + currentFeedIdentifier: string | null + currentFolderIdentifier: string | null navigableEntryIdentifiers: string[] resetSidebarLayout: (() => void) | null resetDetailLayout: (() => void) | null + setCurrentFeedIdentifier: (identifier: string | null) => void + setCurrentFolderIdentifier: (identifier: string | null) => void toggleSidebar: () => void setSidebarCollapsed: (isCollapsed: boolean) => void setCommandPaletteOpen: (isOpen: boolean) => void @@ -106,10 +110,18 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( toolbarPosition: "top", isShortcutsDialogOpen: false, expandedFolderIdentifiers: [], + currentFeedIdentifier: null, + currentFolderIdentifier: null, navigableEntryIdentifiers: [], resetSidebarLayout: null, resetDetailLayout: null, + setCurrentFeedIdentifier: (identifier) => + set({ currentFeedIdentifier: identifier }), + + setCurrentFolderIdentifier: (identifier) => + set({ currentFolderIdentifier: identifier }), + toggleSidebar: () => set((state) => ({ isSidebarCollapsed: !state.isSidebarCollapsed })), diff --git a/supabase/schema.sql b/supabase/schema.sql index 828ffc6..4f61f0b 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- --- \restrict x5c7hoIeZadeb9vWOL0aN2XhOidKEkcJTCLPxElLQoDi4K0JeM3w2RYCHdK42ij +-- \restrict dG3SzK2uS18gU4m6KVz2cn807h79RHaJThBHm9wHtsku5jl48o1kEUpbawNImmT -- Dumped from database version 17.6 -- Dumped by pg_dump version 17.6 @@ -1269,7 +1269,11 @@ CREATE TABLE IF NOT EXISTS "public"."shared_entries" ( "highlight_text_offset" integer, "highlight_text_length" integer, "highlight_text_prefix" "text" DEFAULT ''::"text", - "highlight_text_suffix" "text" DEFAULT ''::"text" + "highlight_text_suffix" "text" DEFAULT ''::"text", + "note_is_public" boolean DEFAULT false NOT NULL, + "view_count" integer DEFAULT 0 NOT NULL, + "last_viewed_at" timestamp with time zone, + "expiry_interval_days" integer DEFAULT 7 NOT NULL ); @@ -3682,5 +3686,5 @@ ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TAB -- PostgreSQL database dump complete -- --- \unrestrict x5c7hoIeZadeb9vWOL0aN2XhOidKEkcJTCLPxElLQoDi4K0JeM3w2RYCHdK42ij +-- \unrestrict dG3SzK2uS18gU4m6KVz2cn807h79RHaJThBHm9wHtsku5jl48o1kEUpbawNImmT |