From b822d57a7ca525759113c18ece7b755b28cf67b4 Mon Sep 17 00:00:00 2001 From: jhayniffy Date: Thu, 22 Oct 2026 09:36:09 +0000 Subject: [PATCH 1/4] fix: #621 [Trivial] Add a sticky action bar for primary CTAs on mobile v Closes #621 --- src/components/StickyActionBar.tsx | 26 ++++++++++++++++++++++++++ src/components/VotePanel.tsx | 23 +++++++++++++---------- 2 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 src/components/StickyActionBar.tsx 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/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 */} + + + ); } From ae1d65b94b20dfb0dc3e2a8667455cb0befe79db Mon Sep 17 00:00:00 2001 From: jhayniffy Date: Thu, 22 Oct 2026 09:36:22 +0000 Subject: [PATCH 2/4] fix: #622 [Medium] Add swipe-to-dismiss gesture support for toast notifi Closes #622 --- src/components/ToastViewport.tsx | 129 +++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) 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) => ( + + ))} +
+ ); +} From 327c87b42be711d51682211aafbe7a112ce122f0 Mon Sep 17 00:00:00 2001 From: jhayniffy Date: Thu, 22 Oct 2026 09:36:44 +0000 Subject: [PATCH 3/4] fix: #623 [Medium] Add an "import view from URL" action to SavedViews Closes #623 --- src/components/SavedViews.tsx | 98 +++++++++++++++++++++++++++++++++++ src/store/views.ts | 27 ++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/components/SavedViews.tsx b/src/components/SavedViews.tsx index 17d0ab9..df4b483 100644 --- a/src/components/SavedViews.tsx +++ b/src/components/SavedViews.tsx @@ -27,6 +27,27 @@ 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; + } +} + /** Saved + built-in filter views with keyboard-operable rename/delete/reorder (#442). */ export function SavedViews({ scope, currentParams, onApply }: SavedViewsProps) { const { t } = useTranslation(); @@ -40,6 +61,9 @@ 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; @@ -64,6 +88,29 @@ 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); @@ -122,6 +169,57 @@ 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 && ( diff --git a/src/store/views.ts b/src/store/views.ts index bb8a3c8..367935a 100644 --- a/src/store/views.ts +++ b/src/store/views.ts @@ -79,6 +79,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); From 4797997770ceaaeb123ae3b6dbfc17c9119111c2 Mon Sep 17 00:00:00 2001 From: jhayniffy Date: Thu, 22 Oct 2026 09:37:06 +0000 Subject: [PATCH 4/4] fix: #624 [Trivial] Add a last-used indicator to SavedViews entries Closes #624 --- src/components/SavedViews.tsx | 197 ++++++++++++++++++++++------------ src/store/views.ts | 50 +++++++-- 2 files changed, 174 insertions(+), 73 deletions(-) diff --git a/src/components/SavedViews.tsx b/src/components/SavedViews.tsx index df4b483..3bd8785 100644 --- a/src/components/SavedViews.tsx +++ b/src/components/SavedViews.tsx @@ -48,12 +48,33 @@ function extractSearch(input: string): string | 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)); @@ -68,9 +89,10 @@ export function SavedViews({ scope, currentParams, onApply }: SavedViewsProps) { 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); }; @@ -116,6 +138,8 @@ export function SavedViews({ scope, currentParams, onApply }: SavedViewsProps) { setEditingId(null); }; + const now = Date.now(); + return (
@@ -203,7 +227,7 @@ export function SavedViews({ scope, currentParams, onApply }: SavedViewsProps) { 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/store/views.ts b/src/store/views.ts index 367935a..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[] = [ @@ -132,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 { @@ -143,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 }; } @@ -158,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 { @@ -176,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) => { @@ -203,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,