diff --git a/src/components/SavedViews.tsx b/src/components/SavedViews.tsx index 17d0ab9..3bd8785 100644 --- a/src/components/SavedViews.tsx +++ b/src/components/SavedViews.tsx @@ -27,12 +27,54 @@ const chipClass = const iconBtnClass = "px-1.5 py-0.5 rounded text-xs text-vx-muted hover:text-vx-text disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage"; +/** + * Extracts the query string from a pasted URL or bare query string. + * Returns null when the input cannot be parsed as a URL. + */ +function extractSearch(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + // Bare query string (with or without leading "?"). + if (trimmed.startsWith("?") || !trimmed.includes("://")) { + const q = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed; + // A bare query string must not contain a path/space; treat as search params. + if (!q.includes(" ")) return q; + } + try { + const url = new URL(trimmed, window.location.origin); + return url.search.startsWith("?") ? url.search.slice(1) : url.search; + } catch { + return null; + } +} + +/** + * Formats a timestamp as a short relative-time label (e.g. "just now", "5m ago"). + * Returns null when the timestamp is missing or invalid so callers can show "never". + */ +function formatRelativeTime(ts: number | undefined, now: number): string | null { + if (typeof ts !== "number" || !Number.isFinite(ts)) return null; + const diffMs = now - ts; + if (diffMs < 0) return "just now"; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return "just now"; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + if (day < 30) return `${day}d ago`; + const mo = Math.floor(day / 30); + if (mo < 12) return `${mo}mo ago`; + return `${Math.floor(mo / 12)}y ago`; +} + /** Saved + built-in filter views with keyboard-operable rename/delete/reorder (#442). */ export function SavedViews({ scope, currentParams, onApply }: SavedViewsProps) { const { t } = useTranslation(); const { copy } = useCopyToClipboard(); const allViews = useViewsStore((s) => s.views); - const { saveView, renameView, deleteView, moveView } = useViewsStore.getState(); + const { saveView, renameView, deleteView, moveView, markViewUsed } = useViewsStore.getState(); const views = allViews.filter((v) => v.scope === scope); const builtIns = BUILT_IN_VIEWS.filter((v) => v.scopes.includes(scope)); @@ -40,13 +82,17 @@ export function SavedViews({ scope, currentParams, onApply }: SavedViewsProps) { const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); const [notice, setNotice] = useState(null); + const [importOpen, setImportOpen] = useState(false); + const [importUrl, setImportUrl] = useState(""); + const [importName, setImportName] = useState(""); const currentSearch = viewParamsToSearch(currentParams); const isActive = (params: ViewParams) => viewParamsToSearch(params) === currentSearch; - const apply = (params: ViewParams) => { + const apply = (params: ViewParams, viewId?: string) => { const { params: clean, degraded } = sanitizeViewParams(scope, params); setNotice(degraded ? t("views.notice.degraded") : null); + if (viewId) markViewUsed(viewId); onApply(clean); }; @@ -64,11 +110,36 @@ export function SavedViews({ scope, currentParams, onApply }: SavedViewsProps) { if (id) setDraftName(null); }; + const submitImport = () => { + if (allViews.length >= MAX_VIEWS) { + setNotice(t("views.notice.saveFailed", { max: MAX_VIEWS })); + return; + } + const search = extractSearch(importUrl); + if (search === null) { + setNotice(t("views.notice.importInvalid")); + return; + } + const raw: ViewParams = Object.fromEntries(new URLSearchParams(search).entries()); + const { params: clean, degraded } = sanitizeViewParams(scope, raw); + const id = saveView(scope, importName, clean); + if (!id) { + setNotice(t("views.notice.saveFailed", { max: MAX_VIEWS })); + return; + } + setNotice(degraded ? t("views.notice.degraded") : t("views.notice.imported")); + setImportOpen(false); + setImportUrl(""); + setImportName(""); + }; + const submitRename = () => { if (editingId) renameView(editingId, editName); setEditingId(null); }; + const now = Date.now(); + return (
@@ -122,75 +193,167 @@ export function SavedViews({ scope, currentParams, onApply }: SavedViewsProps) { )} + {importOpen ? ( +
{ + e.preventDefault(); + submitImport(); + }} + > + + setImportUrl(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && setImportOpen(false)} + placeholder={t("views.importUrlPlaceholder")} + className="bg-vx-surface border border-vx-border rounded-lg px-2 py-1 text-xs text-vx-text focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage" + /> + + setImportName(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && setImportOpen(false)} + placeholder={t("views.nameLabel")} + className="bg-vx-surface border border-vx-border rounded-lg px-2 py-1 text-xs text-vx-text focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage" + /> + + +
+ ) : ( + + )}
{views.length > 0 && ( -
    - {views.map((v, i) => ( -
  • - {editingId === v.id ? ( -
    { - e.preventDefault(); - submitRename(); - }} - > - - setEditName(e.target.value)} - onBlur={submitRename} - onKeyDown={(e) => e.key === "Escape" && setEditingId(null)} - className="bg-vx-surface rounded px-1.5 py-0.5 text-xs text-vx-text focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage" - /> -
    - ) : ( - - )} - - - - - -
  • - ))} +
      + {views.map((v, i) => { + const active = isActive(v.params); + const lastUsed = formatRelativeTime(v.lastUsedAt, now); + return ( +
    • + {editingId === v.id ? ( +
      { + e.preventDefault(); + submitRename(); + }} + > + + setEditName(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && setEditingId(null)} + className="bg-vx-surface border border-vx-border rounded-lg px-2 py-1 text-xs text-vx-text focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage" + /> + + +
      + ) : ( + <> + + + {lastUsed ?? t("views.neverUsed")} + + + + + + + + )} +
    • + ); + })}
    )} -

    - {notice} -

    + {notice && ( +

    + {notice} +

    + )}
); } diff --git a/src/components/StickyActionBar.tsx b/src/components/StickyActionBar.tsx new file mode 100644 index 0000000..f0ab0a5 --- /dev/null +++ b/src/components/StickyActionBar.tsx @@ -0,0 +1,26 @@ +'use client'; + +import type { ReactNode } from 'react'; + +interface StickyActionBarProps { + children: ReactNode; + className?: string; +} + +/** + * Pins a single primary call-to-action to the bottom of the viewport on + * narrow (mobile) widths so it stays reachable without scrolling back up. + * On `sm:` and wider it renders inline exactly where it is placed. + * + * Respects iOS safe-area insets via `env(safe-area-inset-bottom)`. + */ +export default function StickyActionBar({ children, className = '' }: StickyActionBarProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ToastViewport.tsx b/src/components/ToastViewport.tsx index e69de29..cc07026 100644 --- a/src/components/ToastViewport.tsx +++ b/src/components/ToastViewport.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useToastStore, type Toast, type ToastVariant } from "@/store/toast"; + +const SWIPE_DISMISS_THRESHOLD = 80; + +const variantStyles: Record = { + success: + "border-emerald-500/40 bg-emerald-50 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-100", + error: + "border-red-500/40 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100", + info: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100", +}; + +function prefersReducedMotion(): boolean { + if (typeof window === "undefined") return false; + if (document.documentElement.dataset.motion === "reduce") return true; + return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; +} + +interface ToastItemProps { + toast: Toast; + onDismiss: (id: string) => void; +} + +function ToastItem({ toast, onDismiss }: ToastItemProps) { + const [offsetX, setOffsetX] = useState(0); + const [dragging, setDragging] = useState(false); + const startXRef = useRef(null); + const pointerIdRef = useRef(null); + + const handlePointerDown = useCallback((event: React.PointerEvent) => { + if (event.pointerType === "mouse") return; + startXRef.current = event.clientX; + pointerIdRef.current = event.pointerId; + setDragging(true); + event.currentTarget.setPointerCapture?.(event.pointerId); + }, []); + + const handlePointerMove = useCallback((event: React.PointerEvent) => { + if (startXRef.current === null || pointerIdRef.current !== event.pointerId) return; + setOffsetX(event.clientX - startXRef.current); + }, []); + + const endDrag = useCallback( + (event: React.PointerEvent) => { + if (startXRef.current === null || pointerIdRef.current !== event.pointerId) return; + const delta = event.clientX - startXRef.current; + startXRef.current = null; + pointerIdRef.current = null; + setDragging(false); + event.currentTarget.releasePointerCapture?.(event.pointerId); + + if (Math.abs(delta) >= SWIPE_DISMISS_THRESHOLD) { + onDismiss(toast.id); + } else { + setOffsetX(0); + } + }, + [onDismiss, toast.id], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onDismiss(toast.id); + } + }, + [onDismiss, toast.id], + ); + + const reducedMotion = prefersReducedMotion(); + const role = toast.variant === "error" ? "alert" : "status"; + const opacity = dragging ? Math.max(0.4, 1 - Math.abs(offsetX) / 200) : 1; + + return ( +
+

{toast.message}

+ +
+ ); +} + +export function ToastViewport() { + const toasts = useToastStore((state) => state.toasts); + const removeToast = useToastStore((state) => state.removeToast); + + useEffect(() => { + // no-op: keeps subscription stable across renders + }, []); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} diff --git a/src/components/VotePanel.tsx b/src/components/VotePanel.tsx index 0771ff1..31a9453 100644 --- a/src/components/VotePanel.tsx +++ b/src/components/VotePanel.tsx @@ -15,6 +15,7 @@ import type { GovernanceProposal } from "@/lib/governanceApi"; import { useVote, type VoteChoice, type VoteFlowStatus } from "@/hooks/useVote"; import { formatVotingPower } from "@/hooks/useVotingPower"; +import { StickyActionBar } from "@/components/StickyActionBar"; type Props = { proposal: GovernanceProposal; @@ -237,16 +238,18 @@ export function VotePanel({ proposal, votingPower }: Props) { choice carefully before signing.

- {/* Submit */} - + {/* Submit — pinned to a sticky bar on mobile widths */} + + + ); } diff --git a/src/store/views.ts b/src/store/views.ts index bb8a3c8..7433577 100644 --- a/src/store/views.ts +++ b/src/store/views.ts @@ -13,7 +13,16 @@ export const MAX_VIEW_NAME_LENGTH = 40; export type ViewScope = "explore" | "my-intents"; export type ViewParams = Record; -export type SavedView = { id: string; name: string; scope: ViewScope; params: ViewParams }; +export type SavedView = { + id: string; + name: string; + scope: ViewScope; + params: ViewParams; + /** ISO timestamp of when the view was created; absent on legacy entries. */ + createdAt?: string; + /** ISO timestamp of when the view was last applied; absent means "never". */ + lastUsedAt?: string; +}; export type BuiltInView = { id: string; nameKey: MessageKey; scopes: ViewScope[]; params: ViewParams }; export const BUILT_IN_VIEWS: readonly BuiltInView[] = [ @@ -79,6 +88,33 @@ export function viewParamsToSearch(params: ViewParams): string { return qs ? `?${qs}` : ""; } +/** + * Extracts the query params from a pasted shared-view URL or bare query string. + * Returns `null` when the input cannot be parsed as a URL/query at all. + */ +export function parseViewImport(input: string): ViewParams | null { + const trimmed = input.trim(); + if (!trimmed) return null; + let search: string; + if (trimmed.startsWith("?")) { + search = trimmed; + } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + try { + search = new URL(trimmed).search; + } catch { + return null; + } + } else if (trimmed.includes("=")) { + // Bare query string without a leading "?". + search = `?${trimmed}`; + } else { + return null; + } + const params: ViewParams = {}; + for (const [key, value] of new URLSearchParams(search)) params[key] = value; + return params; +} + /** Sanitises a user-supplied view name and resolves collisions with " (2)", " (3)"... */ export function normalizeViewName(raw: string, existing: readonly string[]): string { const base = sanitizeDisplayText(raw).replace(/\s+/g, " ").trim().slice(0, MAX_VIEW_NAME_LENGTH); @@ -105,6 +141,13 @@ function isSavedView(value: unknown): value is SavedView { ); } +/** Returns a valid ISO timestamp string, or `undefined` for missing/invalid values. */ +function sanitizeTimestamp(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const time = Date.parse(value); + return Number.isNaN(time) ? undefined : new Date(time).toISOString(); +} + type PersistedViews = { views: SavedView[] }; function isPersistedViews(value: unknown): value is PersistedViews { @@ -116,11 +159,17 @@ export function sanitizeViews(value: PersistedViews): PersistedViews { const views = (value.views as unknown[]) .filter(isSavedView) .slice(0, MAX_VIEWS) - .map((v) => ({ - ...v, - name: sanitizeDisplayText(v.name).slice(0, MAX_VIEW_NAME_LENGTH), - params: sanitizeViewParams(v.scope, v.params).params, - })); + .map((v) => { + const createdAt = sanitizeTimestamp(v.createdAt); + const lastUsedAt = sanitizeTimestamp(v.lastUsedAt); + return { + ...v, + name: sanitizeDisplayText(v.name).slice(0, MAX_VIEW_NAME_LENGTH), + params: sanitizeViewParams(v.scope, v.params).params, + ...(createdAt ? { createdAt } : {}), + ...(lastUsedAt ? { lastUsedAt } : {}), + }; + }); return { views }; } @@ -131,6 +180,8 @@ type ViewsState = PersistedViews & { deleteView: (id: string) => void; /** Moves a view up (`-1`) or down (`+1`) within its scope. */ moveView: (id: string, delta: -1 | 1) => void; + /** Records that a view was just applied, stamping `lastUsedAt`. */ + markViewUsed: (id: string) => void; }; function newId(): string { @@ -149,7 +200,13 @@ export const useViewsStore = create()( const clean = normalizeViewName(name, views.filter((v) => v.scope === scope).map((v) => v.name)); if (!clean) return null; const id = newId(); - set({ views: [...views, { id, name: clean, scope, params: sanitizeViewParams(scope, params).params }] }); + const createdAt = new Date().toISOString(); + set({ + views: [ + ...views, + { id, name: clean, scope, params: sanitizeViewParams(scope, params).params, createdAt }, + ], + }); return id; }, renameView: (id, name) => { @@ -176,6 +233,12 @@ export const useViewsStore = create()( views[index] = neighbour; set({ views }); }, + markViewUsed: (id) => { + const views = get().views; + if (!views.some((v) => v.id === id)) return; + const lastUsedAt = new Date().toISOString(); + set({ views: views.map((v) => (v.id === id ? { ...v, lastUsedAt } : v)) }); + }, }), { name: VIEWS_KEY,