diff --git a/.gitignore b/.gitignore index a71f80f..f97a376 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.pnpm-store/ dist .env .DS_Store diff --git a/src/views/collection.tsx b/src/views/collection.tsx index 5309d93..af94135 100644 --- a/src/views/collection.tsx +++ b/src/views/collection.tsx @@ -3,8 +3,18 @@ import { Client } from "@atcute/client"; import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons"; import * as TID from "@atcute/tid"; import { A, type RouteSectionProps, useParams, useSearchParams } from "@solidjs/router"; -import { createMemo, createResource, createSignal, For, onMount, Show } from "solid-js"; +import { + createMemo, + createResource, + createSignal, + For, + type JSX, + onCleanup, + onMount, + Show, +} from "solid-js"; import { createStore } from "solid-js/store"; +import { Portal } from "solid-js/web"; import { agent } from "../auth/state"; import { Button } from "../components/button.jsx"; import HoverCard from "../components/hover-card/base"; @@ -15,7 +25,6 @@ import { addNotification, removeNotification } from "../components/notification. import { PermissionButton } from "../components/permission-button.jsx"; import { Spinner } from "../components/spinner.jsx"; import Tooltip from "../components/tooltip.jsx"; -import { canHover } from "../layout.jsx"; import { createLatch } from "../lib/create-latch.js"; import { useFilterShortcut } from "../lib/keyboard.js"; import { useRepo } from "../lib/repo-context.jsx"; @@ -31,23 +40,96 @@ interface AtprotoRecord { } const DEFAULT_LIMIT = 100; +const PREVIEW_VALUE_MAX_LENGTH = 200; +const PREVIEW_FIELD_OPTION_MAX_DEPTH = 2; + +const getRecordValue = (record: AtprotoRecord) => record.record.value as JSONType; + +const isRecordObject = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const getSearchParam = (param: string | string[] | undefined) => + Array.isArray(param) ? param[0] : (param ?? ""); + +const getPathValue = (value: JSONType, path: string) => { + let current: JSONType | undefined = value; + + for (const segment of path.split(".")) { + if (!segment) return; + + if (Array.isArray(current)) { + const index = Number(segment); + if (!Number.isInteger(index) || index < 0 || index >= current.length) return; + current = current[index]; + } else if (isRecordObject(current)) { + if (!Object.hasOwn(current, segment)) return; + current = current[segment]; + } else return; + } + + return current; +}; + +const addPreviewFieldOptions = (value: JSONType, fields: Set, path = "", depth = 1) => { + if (!isRecordObject(value) || depth > PREVIEW_FIELD_OPTION_MAX_DEPTH) return; + + for (const key of Object.keys(value)) { + if (key === "$type") continue; + + const fieldPath = path ? `${path}.${key}` : key; + fields.add(fieldPath); + addPreviewFieldOptions(value[key], fields, fieldPath, depth + 1); + } +}; + +const formatPreviewValue = (value: JSONType | undefined) => { + if (value === undefined) return; + + const str = + typeof value === "string" ? JSON.stringify(value) + : typeof value === "number" || typeof value === "boolean" || value === null ? String(value) + : JSON.stringify(value); + + if (str.length <= PREVIEW_VALUE_MAX_LENGTH) return str; + + return `${str.slice(0, PREVIEW_VALUE_MAX_LENGTH)}...`; +}; + +const RecordLink = (props: { record: AtprotoRecord; previewField?: string }) => { + const previewValue = () => { + const field = props.previewField; + const value = getRecordValue(props.record); + if (!field) return; + return formatPreviewValue(getPathValue(value, field)); + }; -const RecordLink = (props: { record: AtprotoRecord }) => { return ( - - {props.record.rkey} - - - {props.record.cid} - - - - {localDateFromTimestamp(props.record.timestamp!)} + + + {props.record.rkey} + + {props.record.cid} + + + + {localDateFromTimestamp(props.record.timestamp!)} + + + + + {(preview) => ( + + + {props.previewField}: + + {preview()} + + )} } @@ -63,6 +145,138 @@ const RecordLink = (props: { record: AtprotoRecord }) => { ); }; +const PreviewFieldMenu = (props: { + value: string; + options: string[]; + onChange: (field: string) => void; +}) => { + const [open, setOpen] = createSignal(false); + const [menu, setMenu] = createSignal(); + const [button, setButton] = createSignal(); + const [buttonRect, setButtonRect] = createSignal(); + + const updatePosition = () => { + const rect = button()?.getBoundingClientRect(); + if (rect) setButtonRect(rect); + }; + + const menuStyle = (): JSX.CSSProperties | undefined => { + const rect = buttonRect(); + if (!rect) return; + + const menuWidth = Math.min(260, window.innerWidth - 16); + const left = Math.min(Math.max(rect.left, 8), window.innerWidth - menuWidth - 8); + + return { + position: "fixed", + top: `${rect.top - 4}px`, + left: `${left}px`, + width: `${menuWidth}px`, + transform: "translateY(-100%)", + }; + }; + + const closeOnOutsideClick = (event: MouseEvent) => { + const target = event.target as Node; + if (!button()?.contains(target) && !menu()?.contains(target)) setOpen(false); + }; + + const selectField = (field: string) => { + props.onChange(field); + setOpen(false); + }; + + onMount(() => { + window.addEventListener("click", closeOnOutsideClick); + window.addEventListener("scroll", updatePosition, true); + window.addEventListener("resize", updatePosition); + }); + + onCleanup(() => { + window.removeEventListener("click", closeOnOutsideClick); + window.removeEventListener("scroll", updatePosition, true); + window.removeEventListener("resize", updatePosition); + }); + + return ( + <> + + + +
+ + +
+
+ + {(field) => ( + + )} + +
+ + props.onChange(e.currentTarget.value)} + /> +
+ +
+ + ); +}; + export const CollectionLayout = (props: RouteSectionProps) => { const params = useParams(); const hasChild = () => !!params.rkey; @@ -88,6 +302,7 @@ const CollectionView = () => { const [batchDelete, setBatchDelete] = createSignal(false); const [lastSelected, setLastSelected] = createSignal(); const [reverse, setReverse] = createSignal(searchParams.reverse === "true"); + const previewField = () => getSearchParam(searchParams.preview); const limit = () => { const limitParam = Array.isArray(searchParams.limit) ? searchParams.limit[0] : searchParams.limit; @@ -148,6 +363,21 @@ const CollectionView = () => { ), ); + const previewFieldOptions = createMemo(() => { + const fields = new Set(); + + for (const record of records) { + addPreviewFieldOptions(getRecordValue(record), fields); + } + + return [...fields].sort((a, b) => a.localeCompare(b)); + }); + + const updatePreviewField = (field: string) => { + const nextField = field.trim(); + setSearchParams({ preview: nextField || undefined }, { replace: true }); + }; + const deleteRecords = async () => { const recsToDel = records.filter((record) => record.toDelete); let writes: Array< @@ -321,7 +551,7 @@ const CollectionView = () => { {/* Record list */} -
+
0} fallback={ @@ -352,7 +582,7 @@ const CollectionView = () => { setRecords(index(), "toDelete", !record.toDelete); }} > - +
@@ -360,7 +590,7 @@ const CollectionView = () => { href={`/at://${did}/${params.collection}/${record.rkey}`} class="rounded select-none hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" > - + @@ -400,34 +630,36 @@ const CollectionView = () => { {/* Fixed bottom panel */} 1}>
- {/* Filter */} -
{ - const input = e.currentTarget.querySelector("input"); - if (e.target !== input) input?.focus(); - }} - > - - setFilter(e.currentTarget.value)} +
+ {/* Filter */} +
{ + const input = e.currentTarget.querySelector("input"); + if (e.target !== input) input?.focus(); + }} + > + + setFilter(e.currentTarget.value)} + /> +
+ - - - / - -
{/* Pagination */} -
+
{/* Record count */} -
+
{records.filter((rec) => rec.toDelete).length} / diff --git a/src/views/labels.tsx b/src/views/labels.tsx index d320fb7..90ab787 100644 --- a/src/views/labels.tsx +++ b/src/views/labels.tsx @@ -9,7 +9,6 @@ import DidHoverCard from "../components/hover-card/did.jsx"; import RecordHoverCard from "../components/hover-card/record.jsx"; import { TagInput } from "../components/tag-input.jsx"; import { TextInput } from "../components/text-input.jsx"; -import { canHover } from "../layout.jsx"; import { getPDS, labelerCache, resolveHandle } from "../lib/api.js"; import { useFilterShortcut } from "../lib/keyboard.js"; import { localDateFromTimestamp } from "../utils/date.js"; @@ -278,7 +277,7 @@ export const LabelView = () => { 1}>
{ const input = e.currentTarget.querySelector("input"); if (e.target !== input) input?.focus(); @@ -291,16 +290,11 @@ export const LabelView = () => { spellcheck={false} autocapitalize="off" autocomplete="off" - class="grow py-2 select-none placeholder:text-sm focus:outline-none" + class="grow py-1.5 select-none placeholder:text-xs focus:outline-none" placeholder="Filter labels... (* for partial, -exclude)" value={filter()} onInput={(e) => setFilter(e.currentTarget.value)} /> - - - / - -
diff --git a/src/views/repo/index.tsx b/src/views/repo/index.tsx index d9a82f1..bd680fc 100644 --- a/src/views/repo/index.tsx +++ b/src/views/repo/index.tsx @@ -574,7 +574,7 @@ const RepoView = () => {
{ const input = e.currentTarget.querySelector("input"); if (e.target !== input) input?.focus(); @@ -587,17 +587,12 @@ const RepoView = () => { spellcheck={false} autocapitalize="off" autocomplete="off" - class="grow py-2 select-none placeholder:text-sm focus:outline-none" + class="grow py-1.5 select-none placeholder:text-xs focus:outline-none" name="filter" placeholder="Filter collections..." value={filter() ?? ""} onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} /> - - - / - -