fix(control-plane): knowledge-pages polish (#3080)
* fix(control-plane): stop next-intl parsing 'type:<x>' tag hint as an unclosed tag The Tags hint used 'type:<x>', which next-intl reads as an unclosed rich-text tag (INVALID_MESSAGE: UNCLOSED_TAG), crashing the create/edit page dialog. Replace the angle-bracket placeholder with 'type:…' across all locales. * refactor(control-plane): replace loading-emoji hourglasses with a shared Spinner The UI used a spinning ⏳ emoji for loading in several places — inconsistent and not accessible. Add a shared <Spinner> (wrapping lucide Loader2, sized xs–xl, role=status) and use it for all 7 loading states: the large centered 'loading…' placeholders (documents / document / chunks / chunk-modal) and the inline save-button spinners (tags / content / edit-memory). * feat(control-plane): branded tumbling-logo spinner for loading states Add a LogoSpinner that renders the Hindsight mark doing a looping 2D 'tumble' (crouch → hop + 360° flip → land squash), ported from the motion lab. The hop is expressed as a % of the element so one keyframe scales across sizes, and reduced-motion falls back to an opacity pulse. Use it for the prominent centered loading states (documents list, document/ chunk modal); the compact lucide Spinner stays for tiny inline spots. * feat(control-plane): make the tumbling logo the shared spinner everywhere Fold LogoSpinner into the shared Spinner so every loading state renders the Hindsight mark, and swap all remaining ad-hoc spinners (lucide Loader2, the hand-rolled ring-<div> spinners, the sonner toast loader, and the two RefreshCw-as-loader stand-ins) over to it — ~65 sites across 22 files. Spinner now has two motions: - variant "flip" (default): an in-place 360° flip + squash, safe inline and in buttons (only a tiny vertical bob, stays on the text baseline). - variant "jump": the full tumble (crouch + hop + flip), for prominent centered loaders that have vertical room. RefreshCw icons that spin only while refreshing are left as-is — a spinning refresh arrow reads as "refreshing", which is distinct from a generic loader. * fix(control-plane): show the loader (not empty state) on Documents, and finish the spinner sweep Documents: the mount fetch is debounced, so the empty state ("No documents found" / "0 documents") flashed before the spinner. Track a `loaded` flag and gate the empty state + count on it, so the loader shows until the first fetch resolves. Also swap the 📄/📊 emoji empty states for lucide icons. Finish converting the loaders the first sweep missed (they used non-spinner patterns, so grepping for Loader2/animate-spin didn't find them): - pulsing Clock full-view loaders → jump Spinner (stats page, bank profile) - emoji + text-only loaders (entities graph/list/linked-memories, llm-requests and audit-logs chart loaders) → Spinner - emoji empty states (no chunks / no entities / no data) → lucide icons * fix(control-plane): render the Documents loader on first paint (hard refresh) On a hard refresh, bank-context starts currentBank=null and only resolves it from the URL in an effect (after the first paint, before hydration + theme), so the previous `!!currentBank` guard made the empty state win the very first render — you'd see "No documents found" flash before anything else. Gate the loader on `!loaded` alone. currentBank always resolves on a /banks/[id] route and the fetch's finally flips `loaded` (even on an invalid bank whose fetch errors), so it can't get stuck. Verified: the SSR HTML now renders the loading Spinner, not the empty state. * fix(control-plane): apply theme before first paint (no light flash on hard refresh) ThemeProvider only reads the saved/system theme in a useEffect (after paint), so a dark-mode user saw a light flash on every hard refresh. Add a tiny blocking inline script as the first <body> child that sets the .dark class synchronously before the content paints — the standard anti-FOUC pattern (what next-themes does internally). <html> already has suppressHydrationWarning for the class mutation. Logic mirrors lib/theme-context.tsx exactly (saved || system). * feat(control-plane): add the tumbling mascot to the no-bank welcome screen Loop the jump Spinner above 'Welcome to Hindsight' as a friendly greeting on the dashboard shown when no bank is selected. * feat(control-plane): spin the header logo on sidebar navigation Clicking a sidebar item now gives the header Hindsight logo a one-shot 'spin round' — a little playful nav feedback. The sidebar dispatches a 'hindsight:logo-spin' window event (decoupled, like DOCUMENTS_REFRESH_EVENT) and the header listens and toggles a one-shot animation, cleared on animationEnd. The logo is a wide lockup, so a 2D rotate would swing it vertical and overflow the header — use a rotateY card-flip that stays within its footprint instead. * feat(control-plane): spin only the logo mark (not the wordmark) on navigation Split the header lockup into two pieces: the octopus mark (favicon.png, a standalone image so it can rotate freely) and the 'Hindsight' wordmark (the right slice of the full logo.png, shown via a cropped background). Their widths sum to the full logo so they butt together seamlessly. Only the mark carries the one-shot spin, so the wordmark stays put. Because the mark alone is ~square, the spin is now a clean 2D rotate (reverted the rotateY card-flip workaround that the wide full lockup had required). * feat(control-plane): soften logo nav feedback from a spin to a subtle wiggle A full 360 rotate was too much. Replace it with a small tilt that springs back (logo-wiggle, ~450ms) on the mark — less impactful but still a bit of life. * chore(control-plane): drop the dead logo-spin-once reduced-motion block Leftover from the rename to logo-wiggle. * style(control-plane): apply prettier formatting to the spinner-sweep files Ran scripts/hooks/lint.sh — the earlier commits were eslint-clean but not prettier-formatted, which tripped verify-generated-files.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
@@ -49,13 +49,13 @@ import {
|
||||
Brain,
|
||||
Download,
|
||||
Trash2,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Activity,
|
||||
FlaskConical,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { LlmHealthDialog } from "@/components/llm-health-dialog";
|
||||
import { ExtractDialog } from "@/components/extract-dialog";
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function BankPage() {
|
||||
}
|
||||
>
|
||||
{isConsolidating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
@@ -321,7 +321,7 @@ export default function BankPage() {
|
||||
}
|
||||
>
|
||||
{isRecoveringConsolidation ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
@@ -770,7 +770,7 @@ export default function BankPage() {
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
@@ -807,7 +807,7 @@ export default function BankPage() {
|
||||
<AlertDialogAction onClick={handleResetConfig} disabled={isResettingConfig}>
|
||||
{isResettingConfig ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("resetting")}
|
||||
</>
|
||||
) : (
|
||||
@@ -850,7 +850,7 @@ export default function BankPage() {
|
||||
>
|
||||
{isClearingObservations ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("clearing")}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BankSelector } from "@/components/bank-selector";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { bankRoute } from "@/lib/bank-url";
|
||||
|
||||
@@ -26,6 +27,8 @@ export default function DashboardPage() {
|
||||
|
||||
<div className="flex items-center justify-center h-[calc(100vh-80px)] bg-muted/20">
|
||||
<div className="text-center p-10 bg-card rounded-lg border-2 border-border shadow-lg max-w-md">
|
||||
{/* Tumbling Hindsight mascot — loops forever as a friendly greeting. */}
|
||||
<Spinner size="xl" variant="jump" className="mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold mb-3 text-card-foreground">{t("welcome")}</h3>
|
||||
<p className="text-muted-foreground">{t("selectBank")}</p>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,21 @@ export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
// Anti-FOUC: apply the theme's `.dark` class before first paint. ThemeProvider
|
||||
// only reads localStorage/system preference in a useEffect (after paint), so
|
||||
// without this a dark-mode user gets a light flash on every hard refresh. This
|
||||
// runs synchronously as the first <body> child, before the content below it
|
||||
// paints. Keep the logic identical to lib/theme-context.tsx (saved || system).
|
||||
const THEME_INIT_SCRIPT = `
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
var dark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -28,6 +43,7 @@ export default async function LocaleLayout({
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className="bg-background text-foreground">
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||
<ThemeProvider>
|
||||
<FeaturesProvider>
|
||||
<BankProvider>
|
||||
|
||||
@@ -330,6 +330,114 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Branded loading spinner — the Hindsight mark doing a looping "tumble": a
|
||||
crouch, a hop with an in-plane 360° flip, then a squash on landing. It's pure
|
||||
2D squash-and-stretch (scale x/y + in-plane rotate, no 3D transforms) so the
|
||||
flat logo reads as a bouncy little character. The hop distance is a % of the
|
||||
element's own height, so one keyframe scales across every LogoSpinner size.
|
||||
Ported from the "full tumble" motion-lab experiment. */
|
||||
@keyframes logo-tumble {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg) scale(1, 1);
|
||||
}
|
||||
10% {
|
||||
transform: translateY(6%) scale(1.1, 0.84); /* crouch / anticipation */
|
||||
}
|
||||
34% {
|
||||
transform: translateY(-72%) rotate(200deg) scale(0.92, 1.08); /* rise + flip, stretched */
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-46%) rotate(360deg) scale(1, 1); /* flip done, falling */
|
||||
}
|
||||
80% {
|
||||
transform: translateY(0) rotate(360deg) scale(1.14, 0.82); /* land squash */
|
||||
}
|
||||
90%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(360deg) scale(1, 1); /* settle + brief rest */
|
||||
}
|
||||
}
|
||||
|
||||
.animate-logo-tumble {
|
||||
/* Overshoot easing (negative/over-1 control points) gives the hop its spring. */
|
||||
animation: logo-tumble 1400ms cubic-bezier(0.4, -0.15, 0.3, 1.3) infinite;
|
||||
transform-origin: 50% 54%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* One-shot logo wiggle — subtle nav feedback fired on sidebar navigation.
|
||||
Applied to the mark ALONE (the header logo is split into an animatable mark +
|
||||
a static wordmark). A small tilt that springs back — far less impactful than a
|
||||
full spin, but still a bit of life. */
|
||||
@keyframes logo-wiggle {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
35% {
|
||||
transform: rotate(-9deg);
|
||||
}
|
||||
65% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
.animate-logo-wiggle {
|
||||
animation: logo-wiggle 450ms ease-in-out;
|
||||
transform-origin: 50% 54%;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-logo-wiggle {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* In-place flip variant: same 360° tumble + squash, but only a tiny vertical
|
||||
bob (±8%) so it stays on the text baseline inside buttons and inline labels.
|
||||
This is the default spinner motion; the big hop above is reserved for roomy
|
||||
centered loaders. */
|
||||
@keyframes logo-flip {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg) scale(1, 1);
|
||||
}
|
||||
15% {
|
||||
transform: translateY(4%) scale(1.08, 0.9); /* squash */
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8%) rotate(200deg) scale(0.94, 1.06); /* flip, slight lift + stretch */
|
||||
}
|
||||
85% {
|
||||
transform: translateY(0) rotate(360deg) scale(1.08, 0.9); /* land squash */
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) rotate(360deg) scale(1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-logo-flip {
|
||||
animation: logo-flip 1200ms cubic-bezier(0.45, 0, 0.25, 1) infinite;
|
||||
transform-origin: 50% 54%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Respect reduced-motion: drop the bounce for a calm opacity pulse. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-logo-tumble,
|
||||
.animate-logo-flip {
|
||||
animation: logo-tumble-pulse 1200ms ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@keyframes logo-tumble-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
/* Themed scrollbars — match the app surface instead of the default white track.
|
||||
Theme tokens are oklch, so wrap with color-mix to apply opacity. */
|
||||
* {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { LineChart, Line, XAxis, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
type TranslateFn = (key: string) => string;
|
||||
@@ -197,8 +198,9 @@ function AuditChart({ bankId }: { bankId: string }) {
|
||||
<CardContent>
|
||||
<div className="h-[120px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
{t("chartLoading")}
|
||||
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground text-sm">
|
||||
<Spinner size="md" />
|
||||
<span>{t("chartLoading")}</span>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
|
||||
@@ -42,7 +42,8 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, AlertCircle, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { AlertCircle, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -554,7 +555,7 @@ export function BankConfigView() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1386,7 +1387,7 @@ function ConfigSection({
|
||||
<Button size="sm" disabled={!dirty || saving} onClick={onSave}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("saving")}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
X,
|
||||
RotateCcw,
|
||||
Code,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
Trash2,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { DocumentChunkModal } from "./document-chunk-modal";
|
||||
|
||||
interface Operation {
|
||||
@@ -181,7 +181,7 @@ export function BankOperationsView() {
|
||||
if (status === "processing") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -625,7 +625,7 @@ export function BankOperationsView() {
|
||||
disabled={cancellingOpId === op.id}
|
||||
>
|
||||
{cancellingOpId === op.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
@@ -644,7 +644,7 @@ export function BankOperationsView() {
|
||||
disabled={retryingOpId === op.id}
|
||||
>
|
||||
{retryingOpId === op.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
@@ -665,7 +665,7 @@ export function BankOperationsView() {
|
||||
disabled={deletingOpId === op.id}
|
||||
>
|
||||
{deletingOpId === op.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
@@ -731,7 +731,7 @@ export function BankOperationsView() {
|
||||
</DialogHeader>
|
||||
{loadingDetails ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
</div>
|
||||
) : selectedOperation ? (
|
||||
<div className="space-y-4">
|
||||
@@ -833,7 +833,7 @@ export function BankOperationsView() {
|
||||
disabled={cancellingOpId === selectedOperation.operation_id}
|
||||
>
|
||||
{cancellingOpId === selectedOperation.operation_id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
@@ -850,7 +850,7 @@ export function BankOperationsView() {
|
||||
disabled={retryingOpId === selectedOperation.operation_id}
|
||||
>
|
||||
{retryingOpId === selectedOperation.operation_id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
@@ -868,7 +868,7 @@ export function BankOperationsView() {
|
||||
disabled={deletingOpId === selectedOperation.operation_id}
|
||||
>
|
||||
{deletingOpId === selectedOperation.operation_id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
@@ -976,7 +976,7 @@ export function BankOperationsView() {
|
||||
disabled={loadingPayload}
|
||||
>
|
||||
{loadingPayload ? (
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<Code className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
|
||||
@@ -33,16 +33,15 @@ import {
|
||||
import {
|
||||
RefreshCw,
|
||||
Brain,
|
||||
Clock,
|
||||
Trash2,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
Plus,
|
||||
Tag,
|
||||
Loader2,
|
||||
X,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { TagChip } from "@/components/ui/facet-chip";
|
||||
|
||||
interface DispositionTraits {
|
||||
@@ -281,7 +280,7 @@ export function BankProfileView({ hideReflectFields = false }: { hideReflectFiel
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="text-center py-10">
|
||||
<Clock className="w-12 h-12 mx-auto mb-3 text-muted-foreground animate-pulse" />
|
||||
<Spinner size="lg" variant="jump" className="mx-auto mb-3" />
|
||||
<div className="text-lg text-muted-foreground">{t("loadingProfile")}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -520,7 +519,7 @@ export function BankProfileView({ hideReflectFields = false }: { hideReflectFiel
|
||||
>
|
||||
{isClearingObservations ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{tBank("clearing")}
|
||||
</>
|
||||
) : (
|
||||
@@ -567,7 +566,7 @@ export function BankProfileView({ hideReflectFields = false }: { hideReflectFiel
|
||||
disabled={deletingDirective}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deletingDirective ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
|
||||
{deletingDirective ? <Spinner size="sm" className="mr-1" /> : null}
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -912,7 +911,7 @@ function DirectiveFormDialog({
|
||||
disabled={submitting || !form.name.trim() || !form.content.trim()}
|
||||
className="bg-rose-500 hover:bg-rose-600"
|
||||
>
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
|
||||
{submitting ? <Spinner size="sm" className="mr-1" /> : null}
|
||||
{mode === "create" ? t("create") : t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
LogOut,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "@/lib/theme-context";
|
||||
import { useFeatures } from "@/lib/features-context";
|
||||
@@ -98,6 +99,9 @@ function BankSelectorInner() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { features } = useFeatures();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
// One-shot spin of the header logo, fired by sidebar navigation (see the
|
||||
// "hindsight:logo-spin" listener below). Reset on animationEnd so it can replay.
|
||||
const [logoSpinning, setLogoSpinning] = React.useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
const [newBankId, setNewBankId] = React.useState("");
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
@@ -172,6 +176,18 @@ function BankSelectorInner() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Spin the header logo whenever a sidebar item is clicked. Decoupled via a
|
||||
// window event (like DOCUMENTS_REFRESH_EVENT) since the sidebar and this header
|
||||
// are siblings, not parent/child. onAnimationEnd clears the flag so the next
|
||||
// click replays it. (A mid-spin re-click is a no-op — the flag is already set —
|
||||
// which is fine; we avoid a requestAnimationFrame restart because rAF is paused
|
||||
// in background tabs, which would drop the spin entirely.)
|
||||
React.useEffect(() => {
|
||||
const spin = () => setLogoSpinning(true);
|
||||
window.addEventListener("hindsight:logo-spin", spin);
|
||||
return () => window.removeEventListener("hindsight:logo-spin", spin);
|
||||
}, []);
|
||||
|
||||
const sortedBanks = React.useMemo(() => {
|
||||
// Sort by last document inserted descending, then by created_at
|
||||
return [...bankInfos].sort((a, b) => {
|
||||
@@ -477,15 +493,28 @@ function BankSelectorInner() {
|
||||
return (
|
||||
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary-gradient">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{/* Logo */}
|
||||
<Image
|
||||
src={withBasePath("/logo.png")}
|
||||
alt="Hindsight"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-auto"
|
||||
unoptimized
|
||||
/>
|
||||
{/* Logo, split so only the mark spins on navigation while the wordmark
|
||||
stays put. The mark is the standalone favicon.png (so it can rotate
|
||||
freely); the wordmark is the right slice of the full lockup (logo.png)
|
||||
shown via a cropped background. Their widths sum to the full logo, so
|
||||
the two pieces butt together seamlessly at h-10. */}
|
||||
<div className="flex items-center h-10 select-none" aria-label="Hindsight">
|
||||
<img
|
||||
src={withBasePath("/favicon.png")}
|
||||
alt=""
|
||||
className={cn("h-10 w-auto", logoSpinning && "animate-logo-wiggle")}
|
||||
onAnimationEnd={() => setLogoSpinning(false)}
|
||||
/>
|
||||
<div
|
||||
className="h-10 w-[99px]"
|
||||
style={{
|
||||
backgroundImage: `url(${withBasePath("/logo.png")})`,
|
||||
backgroundSize: "auto 100%",
|
||||
backgroundPosition: "right center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-8 w-px bg-border" />
|
||||
@@ -520,7 +549,7 @@ function BankSelectorInner() {
|
||||
<CommandEmpty>
|
||||
{banksLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<Spinner size="sm" />
|
||||
<span>{tCommon("loading")}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -1095,7 +1096,7 @@ export function BankStatsView() {
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Clock className="w-12 h-12 mx-auto mb-3 text-muted-foreground animate-pulse" />
|
||||
<Spinner size="lg" variant="jump" className="mx-auto mb-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Search,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -473,7 +474,7 @@ export function DataView({
|
||||
<div>
|
||||
{loading && !data ? (
|
||||
<div className="text-center py-12">
|
||||
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
|
||||
<Spinner size="lg" variant="jump" className="mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">{t("loadingMemories")}</p>
|
||||
</div>
|
||||
) : data && data.total_units === 0 && !hasActiveMemoryFilters ? (
|
||||
@@ -507,7 +508,10 @@ export function DataView({
|
||||
{/* Text search */}
|
||||
<div className="relative max-w-xs flex-1">
|
||||
{loading ? (
|
||||
<RefreshCw className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none animate-spin" />
|
||||
<Spinner
|
||||
size="sm"
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
/>
|
||||
) : (
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
)}
|
||||
@@ -1057,7 +1061,7 @@ export function DataView({
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📊</div>
|
||||
<ScatterChart className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<div className="text-sm text-muted-foreground">{t("noDataAvailable")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { client } from "@/lib/api";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { TagChip } from "@/components/ui/facet-chip";
|
||||
@@ -79,7 +79,7 @@ export function DirectiveDetailModal({ directiveId, onClose }: DirectiveDetailMo
|
||||
</VisuallyHidden>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
interface DocumentChunkModalProps {
|
||||
type: "document" | "chunk";
|
||||
@@ -70,7 +71,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">⏳</div>
|
||||
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">{t("loadingType", { type })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { TagFilterInput } from "./tag-filter-input";
|
||||
import { FacetLegend, MetadataChip, TagChip } from "@/components/ui/facet-chip";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { HarnessLogo } from "@/components/ui/harness-logo";
|
||||
import { documentHarness, resolveHarnessLogo } from "@/lib/harness-logo";
|
||||
|
||||
@@ -690,6 +691,11 @@ export function DocumentsView() {
|
||||
const [documents, setDocuments] = useState<any[]>([]);
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Whether the first document fetch has completed. The mount fetch is debounced
|
||||
// (see the load effect), so without this the empty state ("No documents found")
|
||||
// flashes for the initial paint + debounce window before `loading` ever flips.
|
||||
// Gate the empty state on this so we show the loader until we actually know.
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
// The UI exposes the two useful modes; both map to their *_strict variant so
|
||||
@@ -769,6 +775,7 @@ export function DocumentsView() {
|
||||
// Error toast is shown automatically by the API client interceptor
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoaded(true);
|
||||
}
|
||||
},
|
||||
[currentBank, searchQuery, selectedTags, tagsMatch]
|
||||
@@ -1371,17 +1378,29 @@ export function DocumentsView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? t("matchingDocuments", { total: displayTotal })
|
||||
: t("totalDocuments", { total: displayTotal })}
|
||||
</div>
|
||||
{/* Hide the count during the very first load so it doesn't read
|
||||
"0 total documents" above the loading spinner. */}
|
||||
{(loaded || documents.length > 0 || pendingRows.length > 0) && (
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? t("matchingDocuments", { total: displayTotal })
|
||||
: t("totalDocuments", { total: displayTotal })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents List Section */}
|
||||
{loading && documents.length === 0 && pendingRows.length === 0 ? (
|
||||
{/* Show the loader until the first fetch resolves (`!loaded`), not just while
|
||||
`loading`. Two reasons the empty state would otherwise flash first:
|
||||
(1) the mount fetch is debounced, and (2) on a hard refresh currentBank
|
||||
is null until bank-context resolves it from the URL in an effect (after
|
||||
the first paint / before hydration + theme). `!loaded` covers both — and
|
||||
it can't get stuck: on any /banks/[id] route currentBank always resolves,
|
||||
the fetch runs, and its `finally` flips `loaded` (even if the bank is
|
||||
invalid and the fetch errors). */}
|
||||
{(loading || !loaded) && documents.length === 0 && pendingRows.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">⏳</div>
|
||||
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">{t("loadingDocuments")}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1576,7 +1595,7 @@ export function DocumentsView() {
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📄</div>
|
||||
<FileText className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasActiveFilters ? t("noDocumentsMatchSearch") : t("noDocumentsFound")}
|
||||
</div>
|
||||
@@ -1605,7 +1624,7 @@ export function DocumentsView() {
|
||||
{loadingDocument ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">⏳</div>
|
||||
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">{t("loadingDocument")}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1763,7 +1782,7 @@ export function DocumentsView() {
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
{savingTags ? (
|
||||
<span className="animate-spin text-xs">⏳</span>
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
@@ -1884,7 +1903,7 @@ export function DocumentsView() {
|
||||
className="h-7 px-3 gap-1 text-xs"
|
||||
>
|
||||
{savingContent ? (
|
||||
<span className="animate-spin">⏳</span>
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
@@ -1952,7 +1971,7 @@ export function DocumentsView() {
|
||||
{loadingChunks ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">⏳</div>
|
||||
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">{t("loadingChunks")}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1965,7 +1984,7 @@ export function DocumentsView() {
|
||||
) : chunksLoaded ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📄</div>
|
||||
<FileText className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<div className="text-sm text-muted-foreground">{t("noChunksFound")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Check } from "lucide-react";
|
||||
import { EntityChip } from "@/components/ui/facet-chip";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export interface EditMemoryFields {
|
||||
text: string;
|
||||
@@ -200,7 +201,7 @@ export function EditMemoryForm({ memory, busy, onCancel, onSave }: EditMemoryFor
|
||||
}}
|
||||
>
|
||||
{busy ? (
|
||||
<span className="animate-spin mr-1.5">⏳</span>
|
||||
<Spinner size="xs" className="mr-1.5" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
ChevronsRight,
|
||||
List,
|
||||
ScatterChart,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -274,7 +276,7 @@ export function EntitiesView() {
|
||||
{graphLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">...</div>
|
||||
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">{t("loadingEntityGraph")}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,7 +318,7 @@ export function EntitiesView() {
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">...</div>
|
||||
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">{t("loadingEntities")}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -415,7 +417,7 @@ export function EntitiesView() {
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">...</div>
|
||||
<Users className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<div className="text-sm text-muted-foreground">{t("noEntitiesFound")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("noEntitiesDescription")}
|
||||
@@ -488,8 +490,11 @@ export function EntitiesView() {
|
||||
{t("linkedMemoriesLabel")}
|
||||
</div>
|
||||
{loadingMemories ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("loadingLinkedMemories")}
|
||||
<div className="py-8 text-center">
|
||||
<Spinner size="md" variant="jump" className="mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("loadingLinkedMemories")}
|
||||
</div>
|
||||
</div>
|
||||
) : entityMemories.length > 0 ? (
|
||||
<div className="-mx-4">
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import JsonView from "react18-json-view";
|
||||
import "react18-json-view/src/style.css";
|
||||
|
||||
@@ -111,7 +111,7 @@ export function ExtractDialog({
|
||||
{tCommon("close")}
|
||||
</Button>
|
||||
<Button onClick={run} disabled={loading || !content.trim() || !currentBank}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
{loading ? <Spinner size="sm" className="mr-2" /> : null}
|
||||
{t("dryRunExtractionRun")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -8,7 +8,8 @@ import { bankRoute } from "@/lib/bank-url";
|
||||
import { Constellation } from "./constellation";
|
||||
import type { GraphData, GraphNode, GraphLink } from "./graph-data";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Network, FileText, Layers, FilePlus, ArrowRight } from "lucide-react";
|
||||
import { Network, FileText, Layers, FilePlus, ArrowRight } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { formatRelativeTime, formatAbsoluteDateTime } from "@/lib/relative-time";
|
||||
import { MemoryStoreCard, MemoriesActivityChart, type BankStats } from "./bank-stats-view";
|
||||
import { TreeRow } from "./knowledge-base-view";
|
||||
@@ -174,7 +175,7 @@ export function HomeView({
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,7 +214,7 @@ export function HomeView({
|
||||
/>
|
||||
) : graphLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -42,12 +42,12 @@ import {
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Info,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { formatAbsoluteDateTime, formatRelativeTime } from "@/lib/relative-time";
|
||||
import { CompactMarkdown } from "./compact-markdown";
|
||||
import { MentalModelDetailModal } from "./mental-model-detail-modal";
|
||||
@@ -438,7 +438,7 @@ export function KnowledgeBaseView() {
|
||||
{query.trim() ? (
|
||||
searching ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-3 py-6 text-sm text-muted-foreground text-center">
|
||||
@@ -472,7 +472,7 @@ export function KnowledgeBaseView() {
|
||||
)
|
||||
) : loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="py-2">
|
||||
@@ -533,7 +533,7 @@ export function KnowledgeBaseView() {
|
||||
)}
|
||||
{loadingDetail && !selected ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
) : selected ? (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
@@ -692,7 +692,7 @@ export function KnowledgeBaseView() {
|
||||
creating || !form.name.trim() || (createKind === "page" && !form.sourceQuery.trim())
|
||||
}
|
||||
>
|
||||
{creating ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{creating ? <Spinner size="sm" className="mr-1" /> : null}
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -738,7 +738,7 @@ export function KnowledgeBaseView() {
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleUpdatePage} disabled={savingEdit || !editForm.name.trim()}>
|
||||
{savingEdit ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{savingEdit ? <Spinner size="sm" className="mr-1" /> : null}
|
||||
{t("saveChanges")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -761,7 +761,7 @@ export function KnowledgeBaseView() {
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{deleting ? <Spinner size="sm" className="mr-1" /> : null}
|
||||
{t("deleteButton")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { client } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -85,7 +86,7 @@ export function LlmHealthDialog({
|
||||
<div className="py-2 space-y-2">
|
||||
{testing && !result ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
{t("testingLlm")}
|
||||
</div>
|
||||
) : (
|
||||
@@ -115,11 +116,7 @@ export function LlmHealthDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={run} disabled={testing} className="gap-1.5">
|
||||
{testing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{testing ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{testing ? t("testingLlm") : t("llmRetest")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -471,7 +472,10 @@ export function TraceDialog({
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-[minmax(240px,5fr)_7fr] gap-4">
|
||||
<div className="overflow-y-auto md:border-r border-border md:pr-3 space-y-0.5">
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted-foreground py-4">{t("chartLoading")}</div>
|
||||
<div className="py-4 flex flex-col items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="md" />
|
||||
<span>{t("chartLoading")}</span>
|
||||
</div>
|
||||
) : (
|
||||
spans.map((s) => (
|
||||
<SpanRow
|
||||
@@ -731,8 +735,9 @@ function LLMRequestChart({ bankId }: { bankId: string }) {
|
||||
<CardContent>
|
||||
<div className="h-[140px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
{t("chartLoading")}
|
||||
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground text-sm">
|
||||
<Spinner size="md" />
|
||||
<span>{t("chartLoading")}</span>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { client } from "@/lib/api";
|
||||
|
||||
const ENTERPRISE_DEMO_URL = "https://calendly.com/d/ctw6-byb-3kg";
|
||||
@@ -191,7 +192,7 @@ export function MemoryDefenseSection({ bankId }: MemoryDefenseSectionProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -233,7 +234,7 @@ export function MemoryDefenseSection({ bankId }: MemoryDefenseSectionProps) {
|
||||
|
||||
{saving && (
|
||||
<div className="flex justify-end items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
{t("saving")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useBank } from "@/lib/bank-context";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Loader2,
|
||||
Calendar,
|
||||
Users,
|
||||
FileText,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
Pencil,
|
||||
Braces,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { TagList } from "@/components/ui/tag-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ObservationHistoryView, type HistoryEntry } from "@/components/observation-history-view";
|
||||
@@ -261,7 +261,7 @@ export function MemoryDetailModal({
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -452,7 +452,7 @@ export function MemoryDetailModal({
|
||||
<TabsContent value="history" className="mt-0">
|
||||
{loadingHistory ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
</div>
|
||||
) : history && history.length > 0 ? (
|
||||
<ObservationHistoryView
|
||||
@@ -692,7 +692,7 @@ export function MemoryDetailModal({
|
||||
<TabsContent value="chunk" className="mt-0 space-y-4">
|
||||
{loadingChunk ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
</div>
|
||||
) : chunk ? (
|
||||
<>
|
||||
@@ -747,7 +747,7 @@ export function MemoryDetailModal({
|
||||
<TabsContent value="document" className="mt-0 space-y-4">
|
||||
{loadingDocument ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
</div>
|
||||
) : document ? (
|
||||
<>
|
||||
|
||||
@@ -4,17 +4,8 @@ import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TagList } from "@/components/ui/tag-list";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Calendar,
|
||||
History,
|
||||
Activity,
|
||||
RotateCcw,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { Copy, Check, X, Calendar, History, Activity, RotateCcw, Pencil } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { InvalidateMemoryDialog } from "./invalidate-memory-dialog";
|
||||
import { EditMemoryForm, type EditMemoryFields } from "./edit-memory-form";
|
||||
import { DocumentChunkModal } from "./document-chunk-modal";
|
||||
@@ -323,7 +314,7 @@ export function MemoryDetailPanel({
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
<span className="ml-2 text-muted-foreground">{t("loadingDetails")}</span>
|
||||
</div>
|
||||
) : editingText ? (
|
||||
@@ -675,7 +666,7 @@ export function MemoryDetailPanel({
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">{t("loading")}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Loader2,
|
||||
Zap,
|
||||
FileText,
|
||||
History as HistoryIcon,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { toast } from "sonner";
|
||||
import { CompactMarkdown } from "./compact-markdown";
|
||||
import { CronSchedulePreview } from "./cron-schedule-preview";
|
||||
@@ -790,7 +790,7 @@ export function MentalModelDetailModal({
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
@@ -931,7 +931,7 @@ export function MentalModelDetailModal({
|
||||
<TabsContent value="history" className="mt-0">
|
||||
{loadingHistory ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
</div>
|
||||
) : history && history.length > 0 ? (
|
||||
<MentalModelHistoryView
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Eraser,
|
||||
RefreshCw,
|
||||
@@ -57,6 +56,7 @@ import {
|
||||
FileText,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -284,7 +284,7 @@ export function MentalModelsView() {
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
|
||||
<Spinner size="lg" variant="jump" className="mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -558,7 +558,7 @@ export function MentalModelsView() {
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
|
||||
{deleting ? <Spinner size="sm" className="mr-1" /> : null}
|
||||
{t("deleteDialogConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -582,7 +582,7 @@ export function MentalModelsView() {
|
||||
<AlertDialogFooter className="flex-row justify-end space-x-2">
|
||||
<AlertDialogCancel className="mt-0">{t("cancelButton")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClear} disabled={clearing}>
|
||||
{clearing ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
|
||||
{clearing ? <Spinner size="sm" className="mr-1" /> : null}
|
||||
{t("clearDialogConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -1135,7 +1135,7 @@ function CreateMentalModelDialog({
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
<Spinner size="sm" className="mr-1" />
|
||||
{t("creatingButton")}
|
||||
</>
|
||||
) : (
|
||||
@@ -1581,7 +1581,7 @@ function UpdateMentalModelDialog({
|
||||
>
|
||||
{updating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
<Spinner size="sm" className="mr-1" />
|
||||
{t("updatingButton")}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Tag,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import JsonView from "react18-json-view";
|
||||
import "react18-json-view/src/style.css";
|
||||
import { MemoryDetailPanel } from "./memory-detail-panel";
|
||||
@@ -294,7 +295,7 @@ export function SearchDebugView() {
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4" />
|
||||
<Spinner size="lg" variant="jump" className="mb-4" />
|
||||
<p className="text-muted-foreground">{t("searchingMemories")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -88,6 +88,8 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
|
||||
if (e.button === 0 && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
onTabChange(item.id);
|
||||
// Give the header logo a playful spin on navigation.
|
||||
window.dispatchEvent(new CustomEvent("hindsight:logo-spin"));
|
||||
}
|
||||
// Middle-click or Ctrl/Cmd+click will naturally open in new tab
|
||||
}}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Check,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import JsonView from "react18-json-view";
|
||||
import "react18-json-view/src/style.css";
|
||||
@@ -297,7 +298,7 @@ export function ThinkView() {
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4" />
|
||||
<Spinner size="lg" variant="jump" className="mb-4" />
|
||||
<p className="text-muted-foreground">{t("reflectingOnMemories")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -921,7 +922,7 @@ export function ThinkView() {
|
||||
</div>
|
||||
{loadingObservation ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<Spinner size="lg" variant="jump" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CircleCheck, Info, LoaderCircle, OctagonX, TriangleAlert } from "lucide-react";
|
||||
import { CircleCheck, Info, OctagonX, TriangleAlert } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
@@ -18,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
info: <Info className="h-4 w-4" />,
|
||||
warning: <TriangleAlert className="h-4 w-4" />,
|
||||
error: <OctagonX className="h-4 w-4" />,
|
||||
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
|
||||
loading: <Spinner size="sm" />,
|
||||
}}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withBasePath } from "@/lib/base-path";
|
||||
|
||||
// Shared loading spinner — the Hindsight mark as a tumbling mascot. This replaces
|
||||
// the old lucide Loader2 everywhere so every "please wait" state is on-brand and
|
||||
// consistent. Two motions:
|
||||
// - variant "flip" (default): an in-place 360° flip with a squash pulse and only
|
||||
// a tiny vertical bob, so it's safe inside buttons and inline next to text.
|
||||
// - variant "jump": the full "tumble" (crouch → hop + flip → land squash). Use it
|
||||
// for prominent, centered loading states that have vertical room.
|
||||
// The keyframes live in globals.css. /favicon.png is the mark alone (no wordmark),
|
||||
// which reads best at these sizes.
|
||||
const SIZES = {
|
||||
xs: "w-3.5 h-3.5",
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
xl: "w-14 h-14",
|
||||
} as const;
|
||||
|
||||
export type SpinnerSize = keyof typeof SIZES;
|
||||
|
||||
export function Spinner({
|
||||
size = "md",
|
||||
variant = "flip",
|
||||
className,
|
||||
}: {
|
||||
size?: SpinnerSize;
|
||||
variant?: "flip" | "jump";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
// Raw <img>, not next/image: next/image wraps the tag in a positioned span
|
||||
// that fights the CSS transform animation (and adds no value for a tiny
|
||||
// static, already-optimized PNG served from /public).
|
||||
<img
|
||||
src={withBasePath("/favicon.png")}
|
||||
alt=""
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
SIZES[size],
|
||||
variant === "jump" ? "animate-logo-tumble" : "animate-logo-flip",
|
||||
"inline-block select-none object-contain",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
const AVAILABLE_EVENT_TYPES = [
|
||||
"consolidation.completed",
|
||||
@@ -589,7 +589,7 @@ export function WebhooksView() {
|
||||
aria-label={t("deleteWebhookAriaLabel")}
|
||||
>
|
||||
{deletingId === webhook.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3" />
|
||||
)}
|
||||
@@ -783,7 +783,7 @@ export function WebhooksView() {
|
||||
<Button onClick={handleCreate} disabled={creating || !form.url}>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("creating")}
|
||||
</>
|
||||
) : (
|
||||
@@ -993,7 +993,7 @@ export function WebhooksView() {
|
||||
<Button onClick={handleSave} disabled={saving || !editForm.url}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("saving")}
|
||||
</>
|
||||
) : (
|
||||
@@ -1034,7 +1034,7 @@ export function WebhooksView() {
|
||||
>
|
||||
{deletingId ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
@@ -1068,7 +1068,7 @@ export function WebhooksView() {
|
||||
|
||||
{loadingDeliveries ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" variant="jump" />
|
||||
</div>
|
||||
) : deliveries.length > 0 ? (
|
||||
<>
|
||||
@@ -1105,7 +1105,7 @@ export function WebhooksView() {
|
||||
onClick={handleLoadMoreDeliveries}
|
||||
disabled={loadingMoreDeliveries}
|
||||
>
|
||||
{loadingMoreDeliveries && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{loadingMoreDeliveries && <Spinner size="sm" className="mr-2" />}
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "Keine passenden Seiten.",
|
||||
"fieldTags": "Tags",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "Kommagetrennt. Ein type:<x>-Tag legt den -Typ der Seite fest.",
|
||||
"fieldTagsHint": "Kommagetrennt. Ein type:…-Tag legt den -Typ der Seite fest.",
|
||||
"editButton": "Bearbeiten",
|
||||
"editPageTitle": "Seite bearbeiten",
|
||||
"editPageDescription": "Aktualisieren Sie den Seitennamen, die Frage, die sie neu aufbaut, und ihre Tags.",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "No matching pages.",
|
||||
"fieldTags": "Tags",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "Comma-separated. A type:<x> tag sets the page's type.",
|
||||
"fieldTagsHint": "Comma-separated. A type:… tag sets the page's type.",
|
||||
"editButton": "Edit",
|
||||
"editPageTitle": "Edit page",
|
||||
"editPageDescription": "Update the page's name, the question that rebuilds it, and its tags.",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "No hay páginas coincidentes.",
|
||||
"fieldTags": "Etiquetas",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "Separadas por comas. Una etiqueta type:<x> define el tipo de la página.",
|
||||
"fieldTagsHint": "Separadas por comas. Una etiqueta type:… define el tipo de la página.",
|
||||
"editButton": "Editar",
|
||||
"editPageTitle": "Editar página",
|
||||
"editPageDescription": "Actualiza el nombre de la página, la pregunta que la reconstruye y sus etiquetas.",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "Aucune page correspondante.",
|
||||
"fieldTags": "Étiquettes",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "Séparées par des virgules. Une étiquette type:<x> définit le type de la page.",
|
||||
"fieldTagsHint": "Séparées par des virgules. Une étiquette type:… définit le type de la page.",
|
||||
"editButton": "Modifier",
|
||||
"editPageTitle": "Modifier la page",
|
||||
"editPageDescription": "Modifiez le nom de la page, la question qui la reconstruit et ses étiquettes.",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "一致するページがありません。",
|
||||
"fieldTags": "タグ",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "カンマ区切り。type:<x> タグでページの タイプを設定します。",
|
||||
"fieldTagsHint": "カンマ区切り。type:… タグでページの タイプを設定します。",
|
||||
"editButton": "編集",
|
||||
"editPageTitle": "ページを編集",
|
||||
"editPageDescription": "ページ名、再構築の質問、タグを更新します。",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "일치하는 페이지가 없습니다.",
|
||||
"fieldTags": "태그",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "쉼표로 구분. type:<x> 태그가 페이지의 유형을 설정합니다.",
|
||||
"fieldTagsHint": "쉼표로 구분. type:… 태그가 페이지의 유형을 설정합니다.",
|
||||
"editButton": "편집",
|
||||
"editPageTitle": "페이지 편집",
|
||||
"editPageDescription": "페이지 이름, 페이지를 재생성하는 질문, 태그를 업데이트합니다.",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "Nenhuma página correspondente.",
|
||||
"fieldTags": "Etiquetas",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "Separadas por vírgulas. Uma etiqueta type:<x> define o tipo da página.",
|
||||
"fieldTagsHint": "Separadas por vírgulas. Uma etiqueta type:… define o tipo da página.",
|
||||
"editButton": "Editar",
|
||||
"editPageTitle": "Editar página",
|
||||
"editPageDescription": "Atualize o nome da página, a pergunta que a reconstrói e as suas etiquetas.",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "冇符合嘅頁面。",
|
||||
"fieldTags": "標籤",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "用逗號分隔。type:<x> 標籤用嚟設定頁面嘅 類型。",
|
||||
"fieldTagsHint": "用逗號分隔。type:… 標籤用嚟設定頁面嘅 類型。",
|
||||
"editButton": "編輯",
|
||||
"editPageTitle": "編輯頁面",
|
||||
"editPageDescription": "更新頁面名稱、重建佢嘅問題同標籤。",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "没有匹配的页面。",
|
||||
"fieldTags": "标签",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "以逗号分隔。type:<x> 标签用于设置页面的 类型。",
|
||||
"fieldTagsHint": "以逗号分隔。type:… 标签用于设置页面的 类型。",
|
||||
"editButton": "编辑",
|
||||
"editPageTitle": "编辑页面",
|
||||
"editPageDescription": "更新页面名称、重建它的问题及其标签。",
|
||||
|
||||
@@ -1762,7 +1762,7 @@
|
||||
"searchEmpty": "沒有符合的頁面。",
|
||||
"fieldTags": "標籤",
|
||||
"fieldTagsPlaceholder": "type:policy, revenue, …",
|
||||
"fieldTagsHint": "以逗號分隔。type:<x> 標籤用於設定頁面的 類型。",
|
||||
"fieldTagsHint": "以逗號分隔。type:… 標籤用於設定頁面的 類型。",
|
||||
"editButton": "編輯",
|
||||
"editPageTitle": "編輯頁面",
|
||||
"editPageDescription": "更新頁面名稱、重建它的問題及其標籤。",
|
||||
|
||||
Reference in New Issue
Block a user