summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-10 01:08:11 -0800
committerFuwn <[email protected]>2026-02-10 01:08:11 -0800
commit920d22332069f1ca60740c290173a95846fb38c3 (patch)
tree5909cecde94bf1acd83385a5b3d789175f2713f0
parentfix: service worker cross-origin image handling and CI env vars (diff)
downloadasa.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.ts16
-rw-r--r--apps/web/app/api/share/route.ts4
-rw-r--r--apps/web/app/reader/_components/notification-panel.tsx2
-rw-r--r--apps/web/app/reader/_components/reader-shell.tsx7
-rw-r--r--apps/web/app/reader/shares/_components/shares-content.tsx63
-rw-r--r--apps/web/app/shared/[token]/page.tsx31
-rw-r--r--apps/web/lib/hooks/use-keyboard-navigation.ts7
-rw-r--r--apps/web/lib/queries/use-mark-all-as-read.ts21
-rw-r--r--apps/web/lib/stores/user-interface-store.ts12
-rw-r--r--supabase/schema.sql10
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>&middot;</span>
+ <span>{share.viewCount} view{share.viewCount !== 1 ? "s" : ""}</span>
+ </>
+ )}
{share.note && (
<>
<span>&middot;</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> &middot; {entry.author}</span>}
{formattedDate && <span> &middot; {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