diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index 7eedfc885..db48e3d27 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -260,6 +260,36 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + /links/metadata: + get: + tags: + - links + summary: Get link metadata + parameters: + - name: url + in: query + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + title: + type: string + description: + type: string + iconUrl: + type: string + required: + - title + - description + - iconUrl + additionalProperties: false /links/home/groups: get: tags: diff --git a/apps/backend/src/lib/api/tools/linkMetadata.ts b/apps/backend/src/lib/api/tools/linkMetadata.ts new file mode 100644 index 000000000..641eff347 --- /dev/null +++ b/apps/backend/src/lib/api/tools/linkMetadata.ts @@ -0,0 +1,90 @@ +export type LinkMetadata = { + title: string; + description: string; + iconUrl: string; +}; + +const LINK_METADATA_HEADERS = { + "User-Agent": "Mozilla/5.0 (compatible; Dashwise link metadata)", + Accept: "text/html,application/xhtml+xml", +}; + +function decodeHtmlEntities(value: string) { + return value + .replace(/&/gi, "&") + .replace(/"/gi, '"') + .replace(/'|'/gi, "'") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/&#x([\da-f]+);/gi, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 10))); +} + +function getAttribute(tag: string, attribute: string) { + const match = tag.match(new RegExp(`\\b${attribute}\\s*=\\s*["']([^"']*)["']`, "i")); + return match?.[1] ? decodeHtmlEntities(match[1]).trim() : ""; +} + +function getMetaContent(tags: string[], names: string[]) { + for (const tag of tags) { + const name = getAttribute(tag, "property") || getAttribute(tag, "name"); + if (names.includes(name.toLowerCase())) { + const content = getAttribute(tag, "content"); + if (content) return content; + } + } + + return ""; +} + +function getTitle(html: string) { + const title = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""; + return decodeHtmlEntities(title).replace(/\s+/g, " ").trim(); +} + +function getIconUrl(html: string, baseUrl: string) { + const linkTags = html.match(/]*>/gi) ?? []; + for (const tag of linkTags) { + const rel = getAttribute(tag, "rel").toLowerCase(); + const href = getAttribute(tag, "href"); + if (!href || !/(^|\s)(shortcut\s+icon|icon|apple-touch-icon)(\s|$)/i.test(rel)) continue; + + try { + return new URL(href, baseUrl).href; + } catch { + // Try the next icon declaration. + } + } + + return new URL("/favicon.ico", baseUrl).href; +} + +export async function getLinkMetadata(url: string): Promise { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new Error("Enter a valid URL to fetch link details"); + } + + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Only HTTP and HTTPS URLs are supported"); + } + + const response = await fetch(parsedUrl, { headers: LINK_METADATA_HEADERS }); + if (!response.ok) { + throw new Error(`Unable to fetch URL (${response.status})`); + } + + const html = (await response.text()).slice(0, 2_000_000); + const finalUrl = response.url || parsedUrl.href; + const metaTags = html.match(/]*>/gi) ?? []; + const title = getMetaContent(metaTags, ["og:title", "twitter:title"]) || getTitle(html); + const description = getMetaContent(metaTags, ["og:description", "twitter:description", "description"]); + + return { + title: title || new URL(finalUrl).hostname, + description, + iconUrl: getIconUrl(html, finalUrl), + }; +} diff --git a/apps/backend/src/routes/links.route.ts b/apps/backend/src/routes/links.route.ts index e9b3fc331..797336ffe 100644 --- a/apps/backend/src/routes/links.route.ts +++ b/apps/backend/src/routes/links.route.ts @@ -5,10 +5,15 @@ import { createCollection, createCollectionLinkItem, createHomeLinkGroup, create import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; import { config } from "../lib/config"; import { ApiActionError } from "../lib/data/auth"; +import { getLinkMetadata } from "../lib/api/tools/linkMetadata"; const linksRoute = new Hono(); linksRoute + .get("/api/v1/links/metadata", withJson(async (c) => { + await requireAuth({ token: readAuthToken(c) }); + return getLinkMetadata(String(c.req.query("url") ?? "").trim()); + })) .get("/api/v1/links/collections", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); return getLinksCollections(userId); @@ -55,6 +60,7 @@ linksRoute list: String(body?.list ?? ""), name: String(body?.name ?? ""), parentFolder: typeof body?.parentFolder === "string" ? body.parentFolder : undefined, + icon: typeof body?.icon === "string" ? body.icon : undefined, }); })) .get("/api/v1/links/items", withJson(async (c) => { diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index 73120529c..ea4503371 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -434,6 +434,53 @@ } } }, + "/links/metadata": { + "get": { + "tags": [ + "links" + ], + "summary": "Get link metadata", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "title", + "description", + "iconUrl" + ], + "additionalProperties": false + } + } + } + } + } + } + }, "/links/home/groups": { "get": { "tags": [ diff --git a/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx b/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx index 35388f73f..af43d5233 100644 --- a/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx @@ -80,6 +80,11 @@ export default function LinksListDetailPage() { queryClient.setQueryData(["api", token, ...queryKeys.links.items(listId)], (current = []) => [link as LinkItemRecord, ...current.filter((item) => item.id !== link.id)]); }} + onFolderCreated={(folder) => { + if (folder.list !== listId) return; + + queryClient.setQueryData(["api", token, ...queryKeys.links.folders(listId)], (current = []) => [folder as LinkFolderRecord, ...current.filter((item) => item.id !== folder.id)]); + }} /> ); diff --git a/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx b/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx index 151efb5c5..737ab6365 100644 --- a/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx @@ -147,6 +147,9 @@ export default function LinksTagDetailPage() { queryClient.setQueryData(["api", token, ...queryKeys.links.tagDetail(tagId)], (current: typeof detailQuery.data) => current ? { ...current, items: [link as LinkItemRecord, ...current.items.filter((item) => item.id !== link.id)] } : current); }} + onFolderCreated={(folder) => { + queryClient.setQueryData(["api", token, ...queryKeys.links.tagDetail(tagId)], (current: typeof detailQuery.data) => current ? { ...current, folders: [folder as LinkFolderRecord, ...current.folders.filter((item) => item.id !== folder.id)] } : current); + }} /> ); diff --git a/apps/web/src/components/links/CreateLinksItemDialog.tsx b/apps/web/src/components/links/CreateLinksItemDialog.tsx index 7a42baa2d..372269179 100644 --- a/apps/web/src/components/links/CreateLinksItemDialog.tsx +++ b/apps/web/src/components/links/CreateLinksItemDialog.tsx @@ -4,8 +4,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; import AppIcon from "@dashwise/app-icon"; import { createLinkItemAction, + createLinksFolderAction, getLinksCollectionsAction, getLinksFoldersAction, + getLinksMetadataAction, getLinksTagsAction, } from '@/lib/apiClient'; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -22,10 +24,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import useAuth from "@/context/useAuth"; import { cn } from "@/lib/utils"; -import { Check, ChevronsUpDown } from "lucide-react"; +import IconPickerComponent, { type IconResult } from "@/components/settings/IconPicker"; +import { Check, ChevronsUpDown, FolderPlus, Link2 } from "lucide-react"; type CollectionRecord = { id: string; @@ -80,8 +84,43 @@ type Props = { defaultCollectionId?: string; defaultTagIds?: string[]; onCreated?: (item: CreatedLinkItem) => void; + onFolderCreated?: (folder: FolderRecord) => void; }; +function IconPickerButton({ + value, + open, + onOpenChange, + onChange, +}: { + value: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onChange: (value: string) => void; +}) { + return ( + + + + + + onChange(icon.url?.trim() ?? "")} + /> + + + ); +} + function buildTargetGroups(collections: CollectionRecord[], folders: FolderRecord[]): TargetGroup[] { const foldersByCollection = new Map(); for (const folder of folders) { @@ -192,13 +231,13 @@ function ListFolderPicker({ - - + + No destinations found. {groups.map((group) => ( - + {group.options.map((option) => ( - - + + No tags found. - + {tags.map((tag) => ( toggleTag(tag.id)}> @@ -315,6 +354,7 @@ export default function CreateLinksItemDialog({ defaultCollectionId, defaultTagIds = [], onCreated, + onFolderCreated, }: Props) { const { withAuth } = useAuth(); const [title, setTitle] = useState(""); @@ -326,16 +366,26 @@ export default function CreateLinksItemDialog({ const [tags, setTags] = useState([]); const [selectedTargetKey, setSelectedTargetKey] = useState(""); const [selectedTagIds, setSelectedTagIds] = useState([]); + const [activeTab, setActiveTab] = useState<"link" | "folder">("link"); + const [folderName, setFolderName] = useState(""); + const [folderIconUrl, setFolderIconUrl] = useState(""); const [loadingData, setLoadingData] = useState(false); + const [loadingMetadata, setLoadingMetadata] = useState(false); const [saving, setSaving] = useState(false); + const [iconPickerOpen, setIconPickerOpen] = useState(false); + const [folderIconPickerOpen, setFolderIconPickerOpen] = useState(false); const [alert, setAlert] = useState<{ open: boolean; title: string; description?: string; variant?: "success" | "error" }>({ open: false, title: "", description: "", variant: "success" }); const initializedTargetRef = useRef(false); + const metadataRequestRef = useRef(0); + const autoMetadataRef = useRef({ title: "", iconUrl: "", description: "" }); const defaultTagKey = defaultTagIds.join("|"); useEffect(() => { if (!open) { initializedTargetRef.current = false; + metadataRequestRef.current += 1; + autoMetadataRef.current = { title: "", iconUrl: "", description: "" }; setTitle(""); setUrl(""); setIconUrl(""); @@ -345,13 +395,21 @@ export default function CreateLinksItemDialog({ setTags([]); setSelectedTargetKey(""); setSelectedTagIds([]); + setActiveTab("link"); + setFolderName(""); + setFolderIconUrl(""); setLoadingData(false); + setLoadingMetadata(false); setSaving(false); + setIconPickerOpen(false); + setFolderIconPickerOpen(false); setAlert({ open: false, title: "", description: "", variant: "success" }); return; } initializedTargetRef.current = false; + metadataRequestRef.current += 1; + autoMetadataRef.current = { title: "", iconUrl: "", description: "" }; setTitle(""); setUrl(""); setIconUrl(""); @@ -361,6 +419,11 @@ export default function CreateLinksItemDialog({ setTags([]); setSelectedTargetKey(""); setSelectedTagIds(defaultTagIds); + setActiveTab("link"); + setFolderName(""); + setFolderIconUrl(""); + setIconPickerOpen(false); + setFolderIconPickerOpen(false); setAlert({ open: false, title: "", description: "", variant: "success" }); setLoadingData(true); @@ -420,6 +483,49 @@ export default function CreateLinksItemDialog({ }; }, [defaultTagKey, open, withAuth]); + useEffect(() => { + if (!open) return; + + const requestId = ++metadataRequestRef.current; + + const candidateUrl = url.trim(); + let parsedUrl: URL; + try { + parsedUrl = new URL(candidateUrl); + } catch { + setLoadingMetadata(false); + return; + } + + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + setLoadingMetadata(false); + return; + } + + const timeout = window.setTimeout(() => { + setLoadingMetadata(true); + + void withAuth((auth) => getLinksMetadataAction(auth, candidateUrl)) + .then((metadata) => { + if (requestId !== metadataRequestRef.current) return; + + const previousAutoMetadata = autoMetadataRef.current; + setTitle((current) => !current.trim() || current === previousAutoMetadata.title ? metadata.title : current); + setIconUrl((current) => !current.trim() || current === previousAutoMetadata.iconUrl ? metadata.iconUrl : current); + setDescription((current) => !current.trim() || current === previousAutoMetadata.description ? metadata.description : current); + autoMetadataRef.current = metadata; + }) + .catch(() => { + // Metadata is an enhancement; the link can still be entered manually. + }) + .finally(() => { + if (requestId === metadataRequestRef.current) setLoadingMetadata(false); + }); + }, 500); + + return () => window.clearTimeout(timeout); + }, [open, url, withAuth]); + const targetGroups = useMemo(() => buildTargetGroups(collections, folders), [collections, folders]); const allTargetOptions = useMemo(() => targetGroups.flatMap((group) => group.options), [targetGroups]); const tagsById = useMemo(() => new Map(tags.map((tag) => [tag.id, tag])), [tags]); @@ -442,10 +548,6 @@ export default function CreateLinksItemDialog({ } }, [allTargetOptions, defaultCollectionId, open]); - const selectedTagRecords = selectedTagIds - .map((tagId) => tagsById.get(tagId)) - .filter((tag): tag is TagRecord => Boolean(tag)); - const handleSave = async () => { if (!title.trim() || !url.trim() || !selectedTarget) return; @@ -474,11 +576,31 @@ export default function CreateLinksItemDialog({ onCreated?.(createdItem); }; + const handleCreateFolder = async () => { + if (!folderName.trim() || !selectedTarget) return; + + const created = await withAuth((auth) => createLinksFolderAction(auth, { + list: selectedTarget.collectionId, + name: folderName.trim(), + parentFolder: selectedTarget.folderId, + icon: folderIconUrl.trim() || undefined, + })); + + const createdFolder = created as FolderRecord; + setAlert({ + open: true, + title: "Folder created", + description: `Created folder "${folderName.trim()}".`, + variant: "success", + }); + onFolderCreated?.(createdFolder); + }; + return ( - Add Link + Add to list {alert.open && ( @@ -500,107 +622,172 @@ export default function CreateLinksItemDialog({ )} -
{ - event.preventDefault(); + setActiveTab(value as "link" | "folder")} className="space-y-5"> + + + + Add link + + + + Add folder + + + + { + event.preventDefault(); + + try { + setSaving(true); + if (activeTab === "link") { + await handleSave(); + } else { + await handleCreateFolder(); + } + onOpenChange(false); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setAlert({ + open: true, + title: activeTab === "link" ? "Failed to create link" : "Failed to create folder", + description: message, + variant: "error", + }); + } finally { + setSaving(false); + } + }} + > + +
+
+ + setUrl(event.target.value)} + placeholder="https://n8n.io" + required={activeTab === "link"} + /> + {loadingMetadata &&

Fetching link details…

} +
- try { - setSaving(true); - await handleSave(); - onOpenChange(false); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAlert({ - open: true, - title: "Failed to create link", - description: message, - variant: "error", - }); - } finally { - setSaving(false); - } - }} - > -
- - setUrl(event.target.value)} - placeholder="https://n8n.io" - required - /> -
+
+ + +
+
+ +
+ +
+
+ +
+ { + autoMetadataRef.current.iconUrl = ""; + setIconUrl(value); + setIconPickerOpen(false); + }} + /> + { + autoMetadataRef.current.title = ""; + setTitle(event.target.value); + }} + placeholder="n8n" + required={activeTab === "link"} + className="min-w-0 flex-1" + /> +
+
+
-
+
+ + +
-
- - setTitle(event.target.value)} - placeholder="n8n" - required + +