diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index c1eb2f8e..776b2efe 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -367,6 +367,14 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + /links/dev/user-links: + delete: + tags: + - links + summary: Wipe development user links + responses: + "200": + $ref: "#/components/responses/JsonOk" /links/tags: get: tags: diff --git a/apps/backend/src/lib/data/links.ts b/apps/backend/src/lib/data/links.ts index 42220f1b..e9502fe8 100644 --- a/apps/backend/src/lib/data/links.ts +++ b/apps/backend/src/lib/data/links.ts @@ -893,6 +893,59 @@ export async function deleteCollection( await pb.collection("linksLists").delete(listId); } +export async function wipeUserLinks( + userId: string, +): Promise<{ deletedCollections: number; deletedFolders: number; deletedItems: number }> { + const pb = getServerPB(); + const lists = await pb.collection("linksLists").getFullList({ + filter: `user = "${userId}"`, + }); + const userLists = lists.filter((list: any) => { + const type = String(list.type ?? "").trim().toLowerCase(); + const name = String(list.name ?? "").trim().toLowerCase(); + return type !== "home" && name !== "home"; + }); + + const records = await Promise.all( + userLists.map(async (list: any) => { + const [items, folders] = await Promise.all([ + pb.collection("linkItems").getFullList({ filter: `collection = "${list.id}"` }), + pb.collection("linksFolders").getFullList({ filter: `list = "${list.id}"` }), + ]); + return { items, folders, list }; + }), + ); + const items = records.flatMap(({ items: listItems }) => listItems); + const folders = records.flatMap(({ folders: listFolders }) => listFolders); + + try { + const monitorPB = await getSuperuserPB(); + const linkIds = new Set(items.map((item: any) => String(item.id || ""))); + const monitors = await monitorPB.collection("monitors").getFullList({ + filter: `userId = "${userId}"`, + }); + await Promise.all( + monitors + .filter((monitor: any) => linkIds.has(getMonitorLinkId(monitor))) + .map((monitor: any) => monitorPB.collection("monitors").delete(monitor.id)), + ); + } catch { + // Ignore monitoring cleanup failures; link wipe should continue. + } + + await Promise.all([ + ...items.map((item: any) => pb.collection("linkItems").delete(item.id)), + ...folders.map((folder: any) => pb.collection("linksFolders").delete(folder.id)), + ]); + await Promise.all(userLists.map((list: any) => pb.collection("linksLists").delete(list.id))); + + return { + deletedCollections: userLists.length, + deletedFolders: folders.length, + deletedItems: items.length, + }; +} + export async function createLinkItem(data: { url: string; title: string; diff --git a/apps/backend/src/routes/links.route.ts b/apps/backend/src/routes/links.route.ts index ba640924..e9b3fc33 100644 --- a/apps/backend/src/routes/links.route.ts +++ b/apps/backend/src/routes/links.route.ts @@ -1,8 +1,10 @@ import { Hono } from "hono"; -import { createCollection, createCollectionLinkItem, createHomeLinkGroup, createHomeLinkItem, createLinkTag, createLinksFolder, deleteLinkItem, getHomeLinkGroups, getHomeLinks, getLinksCollections, getLinksFolders, getLinksItems, getLinksTags, reorderLinks, updateCollection, updateHomeLinkFolderIcon, updateHomeLinkItem, updateLinkTag } from "../lib/data/links"; +import { createCollection, createCollectionLinkItem, createHomeLinkGroup, createHomeLinkItem, createLinkTag, createLinksFolder, deleteLinkItem, getHomeLinkGroups, getHomeLinks, getLinksCollections, getLinksFolders, getLinksItems, getLinksTags, reorderLinks, updateCollection, updateHomeLinkFolderIcon, updateHomeLinkItem, updateLinkTag, wipeUserLinks } from "../lib/data/links"; import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; +import { config } from "../lib/config"; +import { ApiActionError } from "../lib/data/auth"; const linksRoute = new Hono(); @@ -79,6 +81,13 @@ linksRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return deleteLinkItem(userId, String(c.req.param("linkId") ?? "")); })) + .delete("/api/v1/links/dev/user-links", withJson(async (c) => { + if (config.ENVIRONMENT !== "dev") { + throw new ApiActionError("Not found", 404, { error: "Not found" }); + } + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return wipeUserLinks(userId); + })) .post("/api/v1/links/reorder", withJson(async (c) => { const body = await readJsonBody(c); const { userId } = await requireAuth({ token: readAuthToken(c) }); diff --git a/apps/web/package.json b/apps/web/package.json index f6618c7d..acf159ac 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "iconify-picker": "^0.7.2", "lucide-react": "^0.471.2", "qrcode": "^1.5.4", + "radix-ui": "^1.6.7", "react": "^19.2.1", "react-colorful": "^5.6.1", "react-day-picker": "^9.11.1", diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index 5e7ba5ad..8a37f1e8 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -606,6 +606,19 @@ } } }, + "/links/dev/user-links": { + "delete": { + "tags": [ + "links" + ], + "summary": "Wipe development user links", + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, "/links/tags": { "get": { "tags": [ diff --git a/apps/web/src/app/(authenticated)/settings/apps/page.tsx b/apps/web/src/app/(authenticated)/settings/apps/page.tsx new file mode 100644 index 00000000..d2ca8db5 --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/apps/page.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Icon } from "@iconify-icon/react"; +import LinksHtmlTransfer from "@/components/settings/LinksHtmlTransfer"; +import useAuth from "@/context/useAuth"; +import { getNewsFeedsAction, subscribeNewsFeedAction } from "@/lib/apiClient"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; + +type EmptyAppSectionProps = { + title: string; + icon: string; + description?: string; + children?: ReactNode; +}; + +function EmptyAppSection({ title, icon, description, children }: EmptyAppSectionProps) { + return ( +
+

+ + {title} +

+ {description && ( +
+ {description} +
+ )} + {children} +
+ ); +} + +type NewsFeedOption = { + id: string; + title?: string; +}; + +function DefaultNewsFeedSetting({ feeds }: { feeds: NewsFeedOption[] }) { + const { user, updateUserProperty } = useAuth(); + + const newsPreferences = user?.newsPreferences; + const preferences = newsPreferences && typeof newsPreferences === "object" + ? newsPreferences as Record + : {}; + const configuredDefaultNewsPage = typeof preferences.defaultNewsPage === "string" + ? preferences.defaultNewsPage.replace(/\/+$/, "") + : ""; + const defaultNewsPage = configuredDefaultNewsPage && configuredDefaultNewsPage !== "/apps/news" + ? configuredDefaultNewsPage + : "/apps/news/all"; + + async function handleChange(value: string) { + await updateUserProperty("newsPreferences", { + ...preferences, + defaultNewsPage: value, + }); + } + + return ( +
+ +

Default News Feed

+ + + +
+ ); +} + +function BulkNewsImportSetting({ + feeds, + onFeedsChange, +}: { + feeds: NewsFeedOption[]; + onFeedsChange: (feeds: NewsFeedOption[]) => void; +}) { + const { token, withAuth } = useAuth(); + const [bulkImportOpen, setBulkImportOpen] = useState(false); + const [bulkImportUrls, setBulkImportUrls] = useState(""); + const [bulkImportFeedId, setBulkImportFeedId] = useState("unsorted"); + const [bulkImporting, setBulkImporting] = useState(false); + const [bulkImportError, setBulkImportError] = useState(null); + const [bulkImportStatus, setBulkImportStatus] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const bulkImportRequest = searchParams.get("openNewsBulkImportModal"); + + const openBulkImport = useCallback(() => { + setBulkImportUrls(""); + setBulkImportFeedId("unsorted"); + setBulkImportError(null); + setBulkImportStatus(null); + setBulkImportOpen(true); + }, []); + + useEffect(() => { + const value = bulkImportRequest?.trim().toLowerCase(); + if (!value || ["false", "0", "no", "off"].includes(value)) return; + + openBulkImport(); + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.delete("openNewsBulkImportModal"); + return next; + }, { replace: true }); + }, [bulkImportRequest, openBulkImport, setSearchParams]); + + async function handleBulkImport() { + const urls = Array.from(new Set( + bulkImportUrls + .split("\n") + .map((url) => url.trim()) + .filter(Boolean), + )); + + if (!urls.length) { + setBulkImportError("Add at least one feed URL."); + return; + } + + if (!token) { + setBulkImportError("You must be signed in to import feeds."); + return; + } + + setBulkImporting(true); + setBulkImportError(null); + setBulkImportStatus(null); + + const failures: string[] = []; + let imported = 0; + + try { + for (const url of urls) { + try { + await withAuth((auth) => subscribeNewsFeedAction(auth, { + feedUrl: url, + feedIds: bulkImportFeedId === "unsorted" ? [] : [bulkImportFeedId], + newFeedTitles: bulkImportFeedId === "unsorted" ? ["Unsorted"] : [], + })); + imported += 1; + } catch (error) { + failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + const response = await getNewsFeedsAction({ token }); + onFeedsChange(response.feeds ?? []); + + if (failures.length) { + setBulkImportError(`${imported} imported. Failed: ${failures.join("; ")}`); + } else { + setBulkImportStatus(`${imported} feed${imported === 1 ? "" : "s"} imported.`); + setBulkImportUrls(""); + window.setTimeout(() => setBulkImportOpen(false), 700); + } + } catch (error) { + setBulkImportError(error instanceof Error ? error.message : String(error)); + } finally { + setBulkImporting(false); + } + } + + return ( + <> + + + !bulkImporting && setBulkImportOpen(open)}> + + + Bulk import news feeds + + Add one RSS, Atom, YouTube, GitHub, or Reddit URL per line. + + + +
+
+ +