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 ? (
+
+ ) : (
+ setImportOpen(true)}
+ disabled={allViews.length >= MAX_VIEWS}
+ className={`${chipClass} border-vx-border text-vx-muted hover:text-vx-text disabled:opacity-40`}
+ >
+ {t("views.import")}
+
+ )}
{views.length > 0 && (
-
- {views.map((v, i) => (
-
- {editingId === v.id ? (
-
- ) : (
- apply(v.params)}
- className={`${chipClass} border-transparent ${isActive(v.params) ? "text-vx-text" : "text-vx-muted hover:text-vx-text"}`}
- >
- {/* Rendered as text: names are sanitised and never parsed as HTML. */}
- {v.name}
-
- )}
- moveView(v.id, -1)} aria-label={t("views.moveUp", { name: v.name })}>
- ↑
-
- moveView(v.id, 1)} aria-label={t("views.moveDown", { name: v.name })}>
- ↓
-
- {
- setEditingId(v.id);
- setEditName(v.name);
- }}
- aria-label={t("views.renameNamed", { name: v.name })}
- >
- ✎
-
- void share(v.params)} aria-label={t("views.share", { name: v.name })}>
- ⧉
-
- deleteView(v.id)} aria-label={t("views.delete", { name: v.name })}>
- ×
-
-
- ))}
+
+ {views.map((v, i) => {
+ const active = isActive(v.params);
+ const lastUsed = formatRelativeTime(v.lastUsedAt, now);
+ return (
+
+ {editingId === v.id ? (
+
+ ) : (
+ <>
+ apply(v.params, v.id)}
+ className={`${chipClass} ${active ? "border-vx-sage text-vx-text" : "border-vx-border text-vx-muted hover:text-vx-text"}`}
+ >
+ {v.name}
+
+
+ {lastUsed ?? t("views.neverUsed")}
+
+ {
+ setEditingId(v.id);
+ setEditName(v.name);
+ }}
+ className={iconBtnClass}
+ aria-label={t("views.rename")}
+ >
+ ✎
+
+ share(v.params)}
+ className={iconBtnClass}
+ aria-label={t("views.share")}
+ >
+ ↗
+
+ moveView(v.id, -1)}
+ disabled={i === 0}
+ className={iconBtnClass}
+ aria-label={t("views.moveUp")}
+ >
+ ↑
+
+ moveView(v.id, 1)}
+ disabled={i === views.length - 1}
+ className={iconBtnClass}
+ aria-label={t("views.moveDown")}
+ >
+ ↓
+
+ deleteView(v.id)}
+ className={iconBtnClass}
+ aria-label={t("views.delete")}
+ >
+ ✕
+
+ >
+ )}
+
+ );
+ })}
)}
-
- {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}
+
onDismiss(toast.id)}
+ className="shrink-0 rounded p-1 text-current/70 outline-none transition hover:text-current focus-visible:ring-2 focus-visible:ring-sky-500"
+ >
+ ×
+
+
+ );
+}
+
+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 */}
-
- {isPending ? STATUS_LABELS[status] ?? "Processing…" : "Submit Vote"}
-
+ {/* Submit — pinned to a sticky bar on mobile widths */}
+
+
+ {isPending ? STATUS_LABELS[status] ?? "Processing…" : "Submit Vote"}
+
+
);
}
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,