@agent-native/core 0.114.13 → 0.114.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agent-native.js +35 -14
- package/bin/launcher.js +34 -0
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +21 -0
- package/corpus/core/bin/agent-native.js +35 -14
- package/corpus/core/bin/launcher.js +34 -0
- package/corpus/core/docs/content/deployment.mdx +1 -1
- package/corpus/core/docs/content/frames.mdx +1 -1
- package/corpus/core/docs/content/integrations.mdx +27 -27
- package/corpus/core/docs/content/pr-visual-recap.mdx +17 -13
- package/corpus/core/docs/content/workspace-management.mdx +1 -1
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/agent/engine/builder-engine.ts +4 -0
- package/corpus/core/src/client/AgentPanel.tsx +10 -4
- package/corpus/core/src/client/ConnectBuilderCard.tsx +5 -1
- package/corpus/core/src/client/chat/markdown-renderer.tsx +3 -5
- package/corpus/core/src/client/error-format.ts +2 -1
- package/corpus/core/src/client/index.ts +1 -0
- package/corpus/core/src/client/integrations/IntegrationsPanel.tsx +2 -18
- package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +43 -32
- package/corpus/core/src/client/resources/mcp-integration-catalog.ts +4 -2
- package/corpus/core/src/client/settings/BackgroundAgentSection.tsx +5 -1
- package/corpus/core/src/client/settings/SettingsPanel.tsx +4 -4
- package/corpus/core/src/client/settings/useBuilderStatus.ts +3 -0
- package/corpus/core/src/localization/default-messages.ts +1 -7
- package/corpus/core/src/server/builder-browser.ts +4 -0
- package/corpus/core/src/server/builder-design-systems.ts +6 -1
- package/corpus/core/src/server/fusion-app.ts +5 -1
- package/corpus/core/src/shared/builder-link-tracking.ts +41 -0
- package/corpus/core/src/shared/index.ts +1 -0
- package/corpus/templates/analytics/AGENTS.md +10 -1
- package/corpus/templates/analytics/actions/save-dashboard-report-subscription.ts +10 -2
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +5 -4
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +20 -32
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +62 -0
- package/corpus/templates/analytics/changelog/2026-07-21-chart-series-controls-stay-open-while-moving-from-the-legend.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-21-dashboard-email-captures-complete-large-dashboards-in-chunks.md +6 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +4 -4
- package/corpus/templates/analytics/server/jobs/dashboard-report.ts +1 -8
- package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +39 -7
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +341 -251
- package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +114 -0
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +84 -6
- package/corpus/templates/analytics/server/plugins/db.ts +13 -0
- package/corpus/templates/clips/app/components/library/bulk-action-toolbar.tsx +25 -8
- package/corpus/templates/clips/app/components/library/library-grid.tsx +118 -9
- package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
- package/corpus/templates/clips/app/components/library/recording-card.tsx +10 -8
- package/corpus/templates/clips/app/components/player/video-player.tsx +10 -4
- package/corpus/templates/clips/app/i18n/en-US.ts +6 -0
- package/corpus/templates/clips/app/routes/_app.trash.tsx +16 -1
- package/corpus/templates/clips/changelog/2026-07-21-choose-which-whisper-model-to-use-in-settings.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-21-fixed-organization-recording-visibility-default.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-21-meeting-notes-stop-when-calls-end.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +90 -78
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +2 -0
- package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +172 -0
- package/corpus/templates/clips/desktop/src/lib/meeting-call-app.ts +41 -0
- package/corpus/templates/clips/desktop/src/lib/silence-events.ts +1 -0
- package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
- package/corpus/templates/clips/desktop/src/styles.css +80 -7
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +32 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
- package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +268 -20
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_model.rs +285 -164
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +18 -7
- package/corpus/templates/clips/learnings.defaults.md +1 -1
- package/corpus/templates/clips/server/plugins/db.ts +11 -0
- package/corpus/templates/clips/shared/builder-credits.ts +1 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +20 -38
- package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +40 -1
- package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +9 -1
- package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +5 -1
- package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +9 -1
- package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +9 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/engine/builder-engine.js +4 -0
- package/dist/agent/engine/builder-engine.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +10 -3
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
- package/dist/client/ConnectBuilderCard.js +5 -1
- package/dist/client/ConnectBuilderCard.js.map +1 -1
- package/dist/client/chat/markdown-renderer.d.ts.map +1 -1
- package/dist/client/chat/markdown-renderer.js +3 -2
- package/dist/client/chat/markdown-renderer.js.map +1 -1
- package/dist/client/error-format.d.ts +1 -1
- package/dist/client/error-format.d.ts.map +1 -1
- package/dist/client/error-format.js +1 -1
- package/dist/client/error-format.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/integrations/IntegrationsPanel.d.ts.map +1 -1
- package/dist/client/integrations/IntegrationsPanel.js +3 -4
- package/dist/client/integrations/IntegrationsPanel.js.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.js +21 -7
- package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
- package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
- package/dist/client/resources/mcp-integration-catalog.js +2 -2
- package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
- package/dist/client/settings/BackgroundAgentSection.d.ts.map +1 -1
- package/dist/client/settings/BackgroundAgentSection.js +5 -1
- package/dist/client/settings/BackgroundAgentSection.js.map +1 -1
- package/dist/client/settings/SettingsPanel.js +1 -1
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
- package/dist/client/settings/useBuilderStatus.js +2 -0
- package/dist/client/settings/useBuilderStatus.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +0 -6
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +1 -7
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/builder-browser.d.ts.map +1 -1
- package/dist/server/builder-browser.js +4 -0
- package/dist/server/builder-browser.js.map +1 -1
- package/dist/server/builder-design-systems.d.ts.map +1 -1
- package/dist/server/builder-design-systems.js +6 -1
- package/dist/server/builder-design-systems.js.map +1 -1
- package/dist/server/fusion-app.d.ts.map +1 -1
- package/dist/server/fusion-app.js +2 -1
- package/dist/server/fusion-app.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/builder-link-tracking.d.ts +14 -0
- package/dist/shared/builder-link-tracking.d.ts.map +1 -0
- package/dist/shared/builder-link-tracking.js +26 -0
- package/dist/shared/builder-link-tracking.js.map +1 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +1 -0
- package/dist/shared/index.js.map +1 -1
- package/docs/content/deployment.mdx +1 -1
- package/docs/content/frames.mdx +1 -1
- package/docs/content/integrations.mdx +27 -27
- package/docs/content/pr-visual-recap.mdx +17 -13
- package/docs/content/workspace-management.mdx +1 -1
- package/package.json +3 -2
- package/src/agent/engine/builder-engine.ts +4 -0
- package/src/client/AgentPanel.tsx +10 -4
- package/src/client/ConnectBuilderCard.tsx +5 -1
- package/src/client/chat/markdown-renderer.tsx +3 -5
- package/src/client/error-format.ts +2 -1
- package/src/client/index.ts +1 -0
- package/src/client/integrations/IntegrationsPanel.tsx +2 -18
- package/src/client/resources/McpIntegrationDialog.tsx +43 -32
- package/src/client/resources/mcp-integration-catalog.ts +4 -2
- package/src/client/settings/BackgroundAgentSection.tsx +5 -1
- package/src/client/settings/SettingsPanel.tsx +4 -4
- package/src/client/settings/useBuilderStatus.ts +3 -0
- package/src/localization/default-messages.ts +1 -7
- package/src/server/builder-browser.ts +4 -0
- package/src/server/builder-design-systems.ts +6 -1
- package/src/server/fusion-app.ts +5 -1
- package/src/shared/builder-link-tracking.ts +41 -0
- package/src/shared/index.ts +1 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { recordChange } from "@agent-native/core/server";
|
|
4
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
import { getDb, schema } from "../db/index.js";
|
|
7
|
+
import {
|
|
8
|
+
FIRST_PARTY_DASHBOARD_ID,
|
|
9
|
+
repairFirstPartyRecurringUserPanels,
|
|
10
|
+
} from "./first-party-metric-catalog";
|
|
11
|
+
|
|
12
|
+
export async function repairPersistedFirstPartyDashboardQueries(): Promise<boolean> {
|
|
13
|
+
// guard:allow-unscoped — startup repair targets one fixed canonical dashboard
|
|
14
|
+
// and only replaces the exact shipped legacy SQL under an optimistic fence.
|
|
15
|
+
const db = getDb() as any;
|
|
16
|
+
const [row] = await db
|
|
17
|
+
.select({
|
|
18
|
+
id: schema.dashboards.id,
|
|
19
|
+
config: schema.dashboards.config,
|
|
20
|
+
kind: schema.dashboards.kind,
|
|
21
|
+
title: schema.dashboards.title,
|
|
22
|
+
updatedAt: schema.dashboards.updatedAt,
|
|
23
|
+
ownerEmail: schema.dashboards.ownerEmail,
|
|
24
|
+
orgId: schema.dashboards.orgId,
|
|
25
|
+
visibility: schema.dashboards.visibility,
|
|
26
|
+
})
|
|
27
|
+
.from(schema.dashboards)
|
|
28
|
+
.where(eq(schema.dashboards.id, FIRST_PARTY_DASHBOARD_ID));
|
|
29
|
+
if (!row || row.kind !== "sql" || typeof row.config !== "string") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let config: Record<string, unknown>;
|
|
34
|
+
try {
|
|
35
|
+
config = JSON.parse(row.config) as Record<string, unknown>;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const repaired = repairFirstPartyRecurringUserPanels(config);
|
|
40
|
+
if (!repaired.changed) return false;
|
|
41
|
+
|
|
42
|
+
const repairedAt = new Date().toISOString();
|
|
43
|
+
const revisionId = `dashrev-${Date.now()}-${randomUUID()}`;
|
|
44
|
+
const changed = await db.transaction(async (tx: any) => {
|
|
45
|
+
const updated = await tx
|
|
46
|
+
.update(schema.dashboards)
|
|
47
|
+
.set({
|
|
48
|
+
config: JSON.stringify(repaired.config),
|
|
49
|
+
updatedAt: repairedAt,
|
|
50
|
+
updatedBy: null,
|
|
51
|
+
})
|
|
52
|
+
.where(
|
|
53
|
+
and(
|
|
54
|
+
eq(schema.dashboards.id, FIRST_PARTY_DASHBOARD_ID),
|
|
55
|
+
eq(schema.dashboards.config, row.config),
|
|
56
|
+
eq(schema.dashboards.updatedAt, row.updatedAt),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
.returning({ id: schema.dashboards.id });
|
|
60
|
+
if (updated.length !== 1) return false;
|
|
61
|
+
|
|
62
|
+
await tx.insert(schema.dashboardRevisions).values({
|
|
63
|
+
id: revisionId,
|
|
64
|
+
dashboardId: row.id,
|
|
65
|
+
kind: row.kind,
|
|
66
|
+
title: row.title,
|
|
67
|
+
config: row.config,
|
|
68
|
+
createdAt: repairedAt,
|
|
69
|
+
createdBy: null,
|
|
70
|
+
ownerEmail: row.ownerEmail,
|
|
71
|
+
orgId: row.orgId,
|
|
72
|
+
});
|
|
73
|
+
const revisions = await tx
|
|
74
|
+
.select({ id: schema.dashboardRevisions.id })
|
|
75
|
+
.from(schema.dashboardRevisions)
|
|
76
|
+
.where(eq(schema.dashboardRevisions.dashboardId, row.id))
|
|
77
|
+
.orderBy(
|
|
78
|
+
desc(schema.dashboardRevisions.createdAt),
|
|
79
|
+
desc(schema.dashboardRevisions.id),
|
|
80
|
+
);
|
|
81
|
+
const retainedRevisionIds = new Set([revisionId]);
|
|
82
|
+
for (const revision of revisions) {
|
|
83
|
+
if (retainedRevisionIds.size >= 50) break;
|
|
84
|
+
retainedRevisionIds.add(revision.id);
|
|
85
|
+
}
|
|
86
|
+
for (const revision of revisions) {
|
|
87
|
+
if (retainedRevisionIds.has(revision.id)) continue;
|
|
88
|
+
await tx
|
|
89
|
+
.delete(schema.dashboardRevisions)
|
|
90
|
+
.where(eq(schema.dashboardRevisions.id, revision.id));
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
if (!changed) return false;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
recordChange({
|
|
98
|
+
source: "dashboards",
|
|
99
|
+
type: "change",
|
|
100
|
+
key: row.id,
|
|
101
|
+
...(row.visibility === "public"
|
|
102
|
+
? {}
|
|
103
|
+
: row.visibility === "org" && row.orgId
|
|
104
|
+
? { orgId: row.orgId }
|
|
105
|
+
: { owner: row.ownerEmail }),
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.warn(
|
|
109
|
+
"[db] Canonical dashboard repair committed without a live change event:",
|
|
110
|
+
err instanceof Error ? err.message : err,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
export type MetricWindow = "30d" | "90d" | "all";
|
|
28
28
|
|
|
29
|
+
export const FIRST_PARTY_DASHBOARD_ID = "agent-native-templates-first-party";
|
|
30
|
+
|
|
29
31
|
export interface FirstPartyMetric {
|
|
30
32
|
/** Stable catalog key, also used as the default panel id. */
|
|
31
33
|
key: string;
|
|
@@ -122,6 +124,7 @@ const USER_KEY_SQL = "NULLIF(user_key, '')";
|
|
|
122
124
|
const RETENTION_ROLLING_DAYS = 7;
|
|
123
125
|
const RETENTION_MIN_COHORT_SIZE = 5;
|
|
124
126
|
const PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE = 20;
|
|
127
|
+
const RECURRING_ACTIVITY_LOOKBACK_DAYS = 365;
|
|
125
128
|
|
|
126
129
|
function daysAgoSql(days: number): string {
|
|
127
130
|
const unit = days === 1 ? "day" : "days";
|
|
@@ -223,8 +226,85 @@ const RETENTION_OVER_TIME_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_S
|
|
|
223
226
|
const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER}), ranked_first_seen AS (SELECT user_key, template, event_date AS cohort_date, ROW_NUMBER() OVER (PARTITION BY user_key ORDER BY event_date, template) AS rn FROM base), first_seen AS (SELECT user_key, template, cohort_date FROM ranked_first_seen WHERE rn = 1), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= ${daysAgoSql(7)} AND ${dashboardTimeRangeFilter("cohort_date")}), cohort_sizes AS (SELECT template, COUNT(DISTINCT user_key) AS users FROM cohorts GROUP BY template), retained AS (SELECT c.template, COUNT(DISTINCT c.user_key) AS retained FROM cohorts c JOIN base b ON b.user_key = c.user_key AND b.event_date > c.cohort_date AND b.event_date <= to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') GROUP BY c.template) SELECT cs.template, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs LEFT JOIN retained r ON r.template = cs.template WHERE cs.users >= ${PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE} ORDER BY rate DESC, cs.users DESC, cs.template`;
|
|
224
227
|
const SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER}), ranked_first_seen AS (SELECT user_key, template, event_date AS cohort_date, ROW_NUMBER() OVER (PARTITION BY user_key ORDER BY event_date, template) AS rn FROM base), first_seen AS (SELECT user_key, template, cohort_date FROM ranked_first_seen WHERE rn = 1), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= ${daysAgoSql(14)} AND ${dashboardTimeRangeFilter("cohort_date")}), cohort_sizes AS (SELECT template, COUNT(DISTINCT user_key) AS users FROM cohorts GROUP BY template), retained AS (SELECT c.template, COUNT(DISTINCT c.user_key) AS retained FROM cohorts c JOIN base b ON b.user_key = c.user_key AND b.event_date >= to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') AND b.event_date <= to_char(c.cohort_date::date + INTERVAL '14 days', 'YYYY-MM-DD') GROUP BY c.template) SELECT cs.template, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs LEFT JOIN retained r ON r.template = cs.template WHERE cs.users >= ${PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE} ORDER BY rate DESC, cs.users DESC, cs.template`;
|
|
225
228
|
const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${TEMPLATE_EXPR} AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${TEMPLATE_EXPR} ORDER BY count DESC`;
|
|
226
|
-
const
|
|
227
|
-
const
|
|
229
|
+
export const LEGACY_RECURRING_USERS_BY_TEMPLATE_SQL = `WITH all_users AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL}, user_id, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT a.event_date AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND ${dashboardTimeRangeFilter("a.event_date")} GROUP BY 1, 2 ORDER BY date, template`;
|
|
230
|
+
export const LEGACY_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL = `WITH all_users AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL}, user_id, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT to_char(date_trunc('week', a.event_date::date), 'YYYY-MM-DD') AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND ${dashboardTimeRangeFilter("a.event_date")} GROUP BY 1, 2 ORDER BY date, template`;
|
|
231
|
+
const RECURRING_ACTIVITY_LOOKBACK_FILTER = `${EVENT_DATE_SQL} >= ${daysAgoSql(RECURRING_ACTIVITY_LOOKBACK_DAYS)}`;
|
|
232
|
+
const RECURRING_USERS_BY_TEMPLATE_SQL =
|
|
233
|
+
LEGACY_RECURRING_USERS_BY_TEMPLATE_SQL.replace(
|
|
234
|
+
`${DASHBOARD_EMAIL_FILTER}), first_seen`,
|
|
235
|
+
`${DASHBOARD_EMAIL_FILTER} AND ${RECURRING_ACTIVITY_LOOKBACK_FILTER}), first_seen`,
|
|
236
|
+
);
|
|
237
|
+
const RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL =
|
|
238
|
+
LEGACY_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL.replace(
|
|
239
|
+
`${DASHBOARD_EMAIL_FILTER}), first_seen`,
|
|
240
|
+
`${DASHBOARD_EMAIL_FILTER} AND ${RECURRING_ACTIVITY_LOOKBACK_FILTER}), first_seen`,
|
|
241
|
+
);
|
|
242
|
+
const LEGACY_RECURRING_USERS_DESCRIPTION =
|
|
243
|
+
"Daily signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that day. Docs traffic and unknown template are excluded.";
|
|
244
|
+
const LEGACY_RECURRING_USERS_WEEKLY_DESCRIPTION =
|
|
245
|
+
"Weekly distinct signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that week. Weeks start Monday; docs traffic and unknown template are excluded.";
|
|
246
|
+
const RECURRING_USERS_DESCRIPTION =
|
|
247
|
+
"Daily signed-in visitors who are not on their first active day observed in the previous 365 days, stacked by inferred template/app used that day. Docs traffic and unknown template are excluded.";
|
|
248
|
+
const RECURRING_USERS_WEEKLY_DESCRIPTION =
|
|
249
|
+
"Weekly distinct signed-in visitors who are not on their first active day observed in the previous 365 days, stacked by inferred template/app used that week. Weeks start Monday; docs traffic and unknown template are excluded.";
|
|
250
|
+
|
|
251
|
+
export function repairFirstPartyRecurringUserPanels(
|
|
252
|
+
config: Record<string, unknown>,
|
|
253
|
+
): { config: Record<string, unknown>; changed: boolean } {
|
|
254
|
+
if (!Array.isArray(config.panels)) return { config, changed: false };
|
|
255
|
+
|
|
256
|
+
const replacements = new Map([
|
|
257
|
+
[
|
|
258
|
+
"recurring-users-by-template",
|
|
259
|
+
{
|
|
260
|
+
legacySql: LEGACY_RECURRING_USERS_BY_TEMPLATE_SQL,
|
|
261
|
+
sql: RECURRING_USERS_BY_TEMPLATE_SQL,
|
|
262
|
+
legacyDescription: LEGACY_RECURRING_USERS_DESCRIPTION,
|
|
263
|
+
description: RECURRING_USERS_DESCRIPTION,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
[
|
|
267
|
+
"recurring-users-by-template-bar",
|
|
268
|
+
{
|
|
269
|
+
legacySql: LEGACY_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL,
|
|
270
|
+
sql: RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL,
|
|
271
|
+
legacyDescription: LEGACY_RECURRING_USERS_WEEKLY_DESCRIPTION,
|
|
272
|
+
description: RECURRING_USERS_WEEKLY_DESCRIPTION,
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
]);
|
|
276
|
+
let changed = false;
|
|
277
|
+
const panels = config.panels.map((rawPanel) => {
|
|
278
|
+
if (!rawPanel || typeof rawPanel !== "object") return rawPanel;
|
|
279
|
+
const panel = rawPanel as Record<string, unknown>;
|
|
280
|
+
const replacement =
|
|
281
|
+
typeof panel.id === "string" ? replacements.get(panel.id) : undefined;
|
|
282
|
+
if (!replacement || panel.sql !== replacement.legacySql) return rawPanel;
|
|
283
|
+
|
|
284
|
+
changed = true;
|
|
285
|
+
const panelConfig =
|
|
286
|
+
panel.config && typeof panel.config === "object"
|
|
287
|
+
? (panel.config as Record<string, unknown>)
|
|
288
|
+
: null;
|
|
289
|
+
return {
|
|
290
|
+
...panel,
|
|
291
|
+
sql: replacement.sql,
|
|
292
|
+
...(panelConfig
|
|
293
|
+
? {
|
|
294
|
+
config: {
|
|
295
|
+
...panelConfig,
|
|
296
|
+
...(panelConfig.description === replacement.legacyDescription
|
|
297
|
+
? { description: replacement.description }
|
|
298
|
+
: {}),
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
: {}),
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
return changed
|
|
305
|
+
? { config: { ...config, panels }, changed }
|
|
306
|
+
: { config, changed };
|
|
307
|
+
}
|
|
228
308
|
const DAU_BY_TEMPLATE_SQL = `SELECT ${EVENT_DATE_SQL} AS date, ${TEMPLATE_EXPR} AS template, COUNT(DISTINCT ${SIGNED_IN_ACTIVITY_KEY_SQL}) AS visitors FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, ${TEMPLATE_EXPR} ORDER BY date, template`;
|
|
229
309
|
const WAU_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS visitor_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${DASHBOARD_WAU_BASE_RANGE_FILTER}), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ${DASHBOARD_EVENT_DATE_RANGE_FILTER}) SELECT d.date, b.template, COUNT(DISTINCT b.visitor_key) AS visitors FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template`;
|
|
230
310
|
|
|
@@ -684,8 +764,7 @@ const ENTRIES: FirstPartyMetric[] = [
|
|
|
684
764
|
valueKey: "users",
|
|
685
765
|
},
|
|
686
766
|
stacked: true,
|
|
687
|
-
description:
|
|
688
|
-
"Daily signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that day. Docs traffic and unknown template are excluded.",
|
|
767
|
+
description: RECURRING_USERS_DESCRIPTION,
|
|
689
768
|
},
|
|
690
769
|
},
|
|
691
770
|
{
|
|
@@ -706,8 +785,7 @@ const ENTRIES: FirstPartyMetric[] = [
|
|
|
706
785
|
valueKey: "users",
|
|
707
786
|
},
|
|
708
787
|
stacked: true,
|
|
709
|
-
description:
|
|
710
|
-
"Weekly distinct signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that week. Weeks start Monday; docs traffic and unknown template are excluded.",
|
|
788
|
+
description: RECURRING_USERS_WEEKLY_DESCRIPTION,
|
|
711
789
|
},
|
|
712
790
|
},
|
|
713
791
|
{
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
// startup so the dashboard / analysis share actions know where to dispatch.
|
|
9
9
|
import "../db/index.js";
|
|
10
10
|
import * as schema from "../db/schema.js";
|
|
11
|
+
import { repairPersistedFirstPartyDashboardQueries } from "../lib/first-party-dashboard-repair.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Every Drizzle table exported from schema.ts. Filters out type-only and
|
|
@@ -1280,6 +1281,18 @@ const runAnalyticsMigrations = runMigrations(
|
|
|
1280
1281
|
*/
|
|
1281
1282
|
export default async (nitroApp: any): Promise<void> => {
|
|
1282
1283
|
await runAnalyticsMigrations(nitroApp);
|
|
1284
|
+
try {
|
|
1285
|
+
if (await repairPersistedFirstPartyDashboardQueries()) {
|
|
1286
|
+
console.info(
|
|
1287
|
+
"[db] Repaired bounded recurring-user queries on the canonical first-party dashboard.",
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
} catch (err) {
|
|
1291
|
+
console.warn(
|
|
1292
|
+
"[db] Failed to repair canonical first-party dashboard queries (non-fatal):",
|
|
1293
|
+
err instanceof Error ? err.message : err,
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1283
1296
|
try {
|
|
1284
1297
|
const summary = await ensureAdditiveColumns({
|
|
1285
1298
|
db: getDbExec(),
|
|
@@ -27,6 +27,8 @@ export interface BulkMoveTarget {
|
|
|
27
27
|
|
|
28
28
|
interface BulkActionToolbarProps {
|
|
29
29
|
count: number;
|
|
30
|
+
allSelected?: boolean;
|
|
31
|
+
onSelectAll?: () => void;
|
|
30
32
|
onArchive?: () => void;
|
|
31
33
|
onMove?: (folderId: string | null) => void;
|
|
32
34
|
onTrash?: () => void;
|
|
@@ -38,6 +40,8 @@ interface BulkActionToolbarProps {
|
|
|
38
40
|
|
|
39
41
|
export function BulkActionToolbar({
|
|
40
42
|
count,
|
|
43
|
+
allSelected = false,
|
|
44
|
+
onSelectAll,
|
|
41
45
|
onArchive,
|
|
42
46
|
onMove,
|
|
43
47
|
onTrash,
|
|
@@ -53,17 +57,30 @@ export function BulkActionToolbar({
|
|
|
53
57
|
return (
|
|
54
58
|
<div
|
|
55
59
|
className={cn(
|
|
56
|
-
"flex w-fit max-w-full items-center gap-1 rounded-xl
|
|
60
|
+
"flex w-fit max-w-full items-center gap-1 rounded-xl bg-foreground px-3 py-2 text-background shadow-2xl ring-1 ring-black/10 dark:ring-white/10",
|
|
57
61
|
)}
|
|
58
62
|
>
|
|
59
|
-
<span className="pe-2 text-xs font-medium
|
|
63
|
+
<span className="pe-2 text-xs font-medium">
|
|
60
64
|
{t("clipsFinalRaw.selectedCount", { count })}
|
|
61
65
|
</span>
|
|
62
|
-
|
|
66
|
+
{onSelectAll && (
|
|
67
|
+
<Button
|
|
68
|
+
variant="ghost"
|
|
69
|
+
size="sm"
|
|
70
|
+
className="h-8 gap-1.5 text-background hover:bg-background/15 hover:text-background"
|
|
71
|
+
onClick={onSelectAll}
|
|
72
|
+
disabled={isPending}
|
|
73
|
+
>
|
|
74
|
+
{allSelected
|
|
75
|
+
? t("clipsFinalRaw.deselectAll")
|
|
76
|
+
: t("clipsFinalRaw.selectAll")}
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
<div className="h-4 w-px bg-background/20" />
|
|
63
80
|
<Button
|
|
64
81
|
variant="ghost"
|
|
65
82
|
size="sm"
|
|
66
|
-
className="h-8 gap-1.5"
|
|
83
|
+
className="h-8 gap-1.5 text-background hover:bg-background/15 hover:text-background"
|
|
67
84
|
onClick={onArchive}
|
|
68
85
|
disabled={isPending}
|
|
69
86
|
>
|
|
@@ -75,7 +92,7 @@ export function BulkActionToolbar({
|
|
|
75
92
|
<Button
|
|
76
93
|
variant="ghost"
|
|
77
94
|
size="sm"
|
|
78
|
-
className="h-8 gap-1.5"
|
|
95
|
+
className="h-8 gap-1.5 text-background hover:bg-background/15 hover:text-background"
|
|
79
96
|
disabled={isPending}
|
|
80
97
|
>
|
|
81
98
|
<IconFolder className="h-3.5 w-3.5" /> {t("clipsFinalRaw.move")}
|
|
@@ -121,17 +138,17 @@ export function BulkActionToolbar({
|
|
|
121
138
|
<Button
|
|
122
139
|
variant="ghost"
|
|
123
140
|
size="sm"
|
|
124
|
-
className="h-8 gap-1.5 text-
|
|
141
|
+
className="h-8 gap-1.5 text-red-400 hover:bg-background/15 hover:text-red-400"
|
|
125
142
|
onClick={onTrash}
|
|
126
143
|
disabled={isPending}
|
|
127
144
|
>
|
|
128
145
|
<IconTrash className="h-3.5 w-3.5" /> {t("navigation.trash")}
|
|
129
146
|
</Button>
|
|
130
|
-
<div className="h-4 w-px bg-
|
|
147
|
+
<div className="mx-1 h-4 w-px bg-background/20" />
|
|
131
148
|
<button
|
|
132
149
|
type="button"
|
|
133
150
|
onClick={onClear}
|
|
134
|
-
className="rounded p-1 text-
|
|
151
|
+
className="rounded p-1 text-background/70 hover:bg-background/15 hover:text-background"
|
|
135
152
|
aria-label={t("clipsFinalRaw.clearSelection")}
|
|
136
153
|
>
|
|
137
154
|
<IconX className="h-3.5 w-3.5" />
|
|
@@ -3,8 +3,12 @@ import {
|
|
|
3
3
|
setClientAppState,
|
|
4
4
|
} from "@agent-native/core/client/hooks";
|
|
5
5
|
import { useT } from "@agent-native/core/client/i18n";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
IconAlertTriangle,
|
|
8
|
+
IconChevronLeft,
|
|
9
|
+
IconChevronRight,
|
|
10
|
+
} from "@tabler/icons-react";
|
|
11
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
8
12
|
import { toast } from "sonner";
|
|
9
13
|
|
|
10
14
|
import { CreateFolderDialog } from "@/components/library/create-folder-dialog";
|
|
@@ -13,6 +17,7 @@ import { Button } from "@/components/ui/button";
|
|
|
13
17
|
import {
|
|
14
18
|
useFolders,
|
|
15
19
|
useRecordings,
|
|
20
|
+
useRecordingsCount,
|
|
16
21
|
useTrashRecording,
|
|
17
22
|
useArchiveRecording,
|
|
18
23
|
useRestoreRecording,
|
|
@@ -54,6 +59,8 @@ function Skeleton() {
|
|
|
54
59
|
);
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
const PAGE_SIZE = 100;
|
|
63
|
+
|
|
57
64
|
interface FolderTargetRow {
|
|
58
65
|
id: string;
|
|
59
66
|
parentId: string | null;
|
|
@@ -105,13 +112,38 @@ export function LibraryGrid({
|
|
|
105
112
|
const t = useT();
|
|
106
113
|
const [sort, setSort] = useState<SortKey>("recent");
|
|
107
114
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
115
|
+
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
|
108
116
|
const selectionMode = selected.size > 0;
|
|
109
117
|
const [sharingRec, setSharingRec] = useState<RecordingSummary | null>(null);
|
|
110
118
|
const [createFolderTarget, setCreateFolderTarget] =
|
|
111
119
|
useState<CreateFolderTarget | null>(null);
|
|
112
120
|
const [isBulkPending, setIsBulkPending] = useState(false);
|
|
121
|
+
const [page, setPage] = useState(1);
|
|
113
122
|
const selectionStateKey = useMemo(() => `selection:${getBrowserTabId()}`, []);
|
|
114
123
|
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
setPage(1);
|
|
126
|
+
setSelected(new Set());
|
|
127
|
+
setLastSelectedId(null);
|
|
128
|
+
}, [view, folderId, spaceId, tagFilter, sort]);
|
|
129
|
+
|
|
130
|
+
const countArgs = useMemo(
|
|
131
|
+
() => ({
|
|
132
|
+
view,
|
|
133
|
+
folderId: folderId ?? null,
|
|
134
|
+
spaceId: spaceId ?? null,
|
|
135
|
+
tag: tagFilter ?? null,
|
|
136
|
+
}),
|
|
137
|
+
[view, folderId, spaceId, tagFilter],
|
|
138
|
+
);
|
|
139
|
+
const { data: totalCount } = useRecordingsCount(countArgs);
|
|
140
|
+
const total = totalCount ?? 0;
|
|
141
|
+
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (page > totalPages) setPage(totalPages);
|
|
145
|
+
}, [page, totalPages]);
|
|
146
|
+
|
|
115
147
|
const args: ListRecordingsArgs = useMemo(
|
|
116
148
|
() => ({
|
|
117
149
|
view,
|
|
@@ -119,8 +151,10 @@ export function LibraryGrid({
|
|
|
119
151
|
spaceId: spaceId ?? null,
|
|
120
152
|
tag: tagFilter ?? null,
|
|
121
153
|
sort,
|
|
154
|
+
limit: PAGE_SIZE,
|
|
155
|
+
offset: (page - 1) * PAGE_SIZE,
|
|
122
156
|
}),
|
|
123
|
-
[view, folderId, spaceId, tagFilter, sort],
|
|
157
|
+
[view, folderId, spaceId, tagFilter, sort, page],
|
|
124
158
|
);
|
|
125
159
|
|
|
126
160
|
const { data, isLoading, isError, refetch, isRefetching } =
|
|
@@ -189,17 +223,53 @@ export function LibraryGrid({
|
|
|
189
223
|
};
|
|
190
224
|
}, [selectionStateKey]);
|
|
191
225
|
|
|
192
|
-
const
|
|
226
|
+
const handleToggleSelect = useCallback(
|
|
227
|
+
(id: string, shiftKey = false) => {
|
|
228
|
+
setSelected((prev) => {
|
|
229
|
+
if (shiftKey && lastSelectedId && lastSelectedId !== id) {
|
|
230
|
+
const ids = recordings.map((r) => r.id);
|
|
231
|
+
const fromIndex = ids.indexOf(lastSelectedId);
|
|
232
|
+
const toIndex = ids.indexOf(id);
|
|
233
|
+
if (fromIndex !== -1 && toIndex !== -1) {
|
|
234
|
+
const [start, end] =
|
|
235
|
+
fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
|
|
236
|
+
const next = new Set(prev);
|
|
237
|
+
for (let i = start; i <= end; i++) next.add(ids[i]);
|
|
238
|
+
return next;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const next = new Set(prev);
|
|
242
|
+
if (next.has(id)) next.delete(id);
|
|
243
|
+
else next.add(id);
|
|
244
|
+
return next;
|
|
245
|
+
});
|
|
246
|
+
setLastSelectedId(id);
|
|
247
|
+
},
|
|
248
|
+
[lastSelectedId, recordings],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const allSelected =
|
|
252
|
+
recordings.length > 0 && recordings.every(({ id }) => selected.has(id));
|
|
253
|
+
|
|
254
|
+
const toggleSelectAll = useCallback(() => {
|
|
193
255
|
setSelected((prev) => {
|
|
194
256
|
const next = new Set(prev);
|
|
195
|
-
|
|
196
|
-
|
|
257
|
+
const visibleIds = recordings.map(({ id }) => id);
|
|
258
|
+
const allVisibleSelected = visibleIds.every((id) => prev.has(id));
|
|
259
|
+
|
|
260
|
+
for (const id of visibleIds) {
|
|
261
|
+
if (allVisibleSelected) next.delete(id);
|
|
262
|
+
else next.add(id);
|
|
263
|
+
}
|
|
264
|
+
|
|
197
265
|
return next;
|
|
198
266
|
});
|
|
199
|
-
|
|
267
|
+
setLastSelectedId(null);
|
|
268
|
+
}, [recordings]);
|
|
200
269
|
|
|
201
270
|
const clearSelection = () => {
|
|
202
271
|
setSelected(new Set());
|
|
272
|
+
setLastSelectedId(null);
|
|
203
273
|
};
|
|
204
274
|
|
|
205
275
|
const moveRecordings = async (
|
|
@@ -321,7 +391,7 @@ export function LibraryGrid({
|
|
|
321
391
|
)}
|
|
322
392
|
|
|
323
393
|
{/* Grid body */}
|
|
324
|
-
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
|
394
|
+
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
325
395
|
<div
|
|
326
396
|
className={cn(
|
|
327
397
|
"min-h-0 flex-1 overflow-y-auto",
|
|
@@ -371,7 +441,7 @@ export function LibraryGrid({
|
|
|
371
441
|
}
|
|
372
442
|
selectionMode={canManageRecordings && selectionMode}
|
|
373
443
|
onToggleSelect={
|
|
374
|
-
canManageRecordings ?
|
|
444
|
+
canManageRecordings ? handleToggleSelect : undefined
|
|
375
445
|
}
|
|
376
446
|
onShare={(rec) => setSharingRec(rec)}
|
|
377
447
|
moveTargets={moveTargets}
|
|
@@ -426,12 +496,51 @@ export function LibraryGrid({
|
|
|
426
496
|
</div>
|
|
427
497
|
</div>
|
|
428
498
|
|
|
499
|
+
{!isLoading && recordings.length > 0 && totalPages > 1 && (
|
|
500
|
+
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-border px-5 py-2.5">
|
|
501
|
+
<span className="text-xs text-muted-foreground">
|
|
502
|
+
{t("libraryGrid.paginationRange", {
|
|
503
|
+
start: (page - 1) * PAGE_SIZE + 1,
|
|
504
|
+
end: (page - 1) * PAGE_SIZE + recordings.length,
|
|
505
|
+
total,
|
|
506
|
+
})}
|
|
507
|
+
</span>
|
|
508
|
+
<div className="flex items-center gap-2">
|
|
509
|
+
<Button
|
|
510
|
+
variant="outline"
|
|
511
|
+
size="sm"
|
|
512
|
+
className="gap-1"
|
|
513
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
514
|
+
disabled={page <= 1}
|
|
515
|
+
>
|
|
516
|
+
<IconChevronLeft className="h-3.5 w-3.5" />
|
|
517
|
+
{t("libraryGrid.paginationPrevious")}
|
|
518
|
+
</Button>
|
|
519
|
+
<span className="text-xs text-muted-foreground">
|
|
520
|
+
{t("libraryGrid.paginationPage", { page, totalPages })}
|
|
521
|
+
</span>
|
|
522
|
+
<Button
|
|
523
|
+
variant="outline"
|
|
524
|
+
size="sm"
|
|
525
|
+
className="gap-1"
|
|
526
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
527
|
+
disabled={page >= totalPages}
|
|
528
|
+
>
|
|
529
|
+
{t("libraryGrid.paginationNext")}
|
|
530
|
+
<IconChevronRight className="h-3.5 w-3.5" />
|
|
531
|
+
</Button>
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
534
|
+
)}
|
|
535
|
+
|
|
429
536
|
{/* Keep selected-library actions visible while the recording list scrolls. */}
|
|
430
537
|
{canManageRecordings && selected.size > 0 && (
|
|
431
538
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center px-4 pb-4">
|
|
432
539
|
<div className="pointer-events-auto max-w-full overflow-x-auto">
|
|
433
540
|
<BulkActionToolbar
|
|
434
541
|
count={selected.size}
|
|
542
|
+
allSelected={allSelected}
|
|
543
|
+
onSelectAll={toggleSelectAll}
|
|
435
544
|
moveTargets={moveTargets}
|
|
436
545
|
onArchive={async () => {
|
|
437
546
|
setIsBulkPending(true);
|
|
@@ -566,7 +566,7 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
|
|
|
566
566
|
browserTabId={getBrowserTabId()}
|
|
567
567
|
>
|
|
568
568
|
{/* Main content area */}
|
|
569
|
-
<div className="flex min-w-0 flex-1 flex-col">
|
|
569
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
570
570
|
{!pageOwnsToolbar && (
|
|
571
571
|
<header className="flex shrink-0 items-center gap-3 border-b border-border px-5 py-3">
|
|
572
572
|
<button
|
|
@@ -65,7 +65,7 @@ interface RecordingCardProps {
|
|
|
65
65
|
recording: RecordingSummary;
|
|
66
66
|
selected?: boolean;
|
|
67
67
|
selectionMode?: boolean;
|
|
68
|
-
onToggleSelect?: (id: string) => void;
|
|
68
|
+
onToggleSelect?: (id: string, shiftKey: boolean) => void;
|
|
69
69
|
onShare?: (rec: RecordingSummary) => void;
|
|
70
70
|
onMove?: (rec: RecordingSummary, folderId: string | null) => void;
|
|
71
71
|
moveTargets?: BulkMoveTarget[];
|
|
@@ -147,18 +147,18 @@ export function RecordingCard({
|
|
|
147
147
|
const handleLinkClick = useCallback(
|
|
148
148
|
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
149
149
|
if (
|
|
150
|
-
!
|
|
150
|
+
!onToggleSelect ||
|
|
151
151
|
event.button !== 0 ||
|
|
152
152
|
event.metaKey ||
|
|
153
153
|
event.ctrlKey ||
|
|
154
|
-
event.
|
|
155
|
-
event.
|
|
154
|
+
event.altKey ||
|
|
155
|
+
(!selectionMode && !event.shiftKey)
|
|
156
156
|
) {
|
|
157
157
|
return;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
event.preventDefault();
|
|
161
|
-
onToggleSelect
|
|
161
|
+
onToggleSelect(recording.id, event.shiftKey);
|
|
162
162
|
},
|
|
163
163
|
[onToggleSelect, recording.id, selectionMode],
|
|
164
164
|
);
|
|
@@ -166,7 +166,7 @@ export function RecordingCard({
|
|
|
166
166
|
const handleCheckbox = useCallback(
|
|
167
167
|
(e: React.MouseEvent) => {
|
|
168
168
|
e.stopPropagation();
|
|
169
|
-
onToggleSelect?.(recording.id);
|
|
169
|
+
onToggleSelect?.(recording.id, e.shiftKey);
|
|
170
170
|
},
|
|
171
171
|
[onToggleSelect, recording.id],
|
|
172
172
|
);
|
|
@@ -243,8 +243,10 @@ export function RecordingCard({
|
|
|
243
243
|
>
|
|
244
244
|
<Checkbox
|
|
245
245
|
checked={selected}
|
|
246
|
-
|
|
247
|
-
|
|
246
|
+
onClick={(e) => {
|
|
247
|
+
e.stopPropagation();
|
|
248
|
+
onToggleSelect?.(recording.id, e.shiftKey);
|
|
249
|
+
}}
|
|
248
250
|
className="h-3.5 w-3.5"
|
|
249
251
|
/>
|
|
250
252
|
</div>
|
|
@@ -646,10 +646,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
646
646
|
const togglePlayback = useCallback(() => {
|
|
647
647
|
const v = videoRef.current;
|
|
648
648
|
if (!v) return;
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
649
|
+
// A finished clip must always replay from the start, even when the browser
|
|
650
|
+
// left `paused` false at end of stream (MSE end-of-stream / DB-duration
|
|
651
|
+
// mismatch) or `isPlaying` is stale — otherwise the toggle below would
|
|
652
|
+
// pause an already-ended element and the play button appears to do nothing.
|
|
653
653
|
if (v.ended) {
|
|
654
654
|
try {
|
|
655
655
|
v.currentTime = 0;
|
|
@@ -657,6 +657,12 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
657
657
|
} catch {
|
|
658
658
|
// Let the normal play attempt report a media error if the seek fails.
|
|
659
659
|
}
|
|
660
|
+
requestPlay();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (!v.paused || isPlaying) {
|
|
664
|
+
pauseVideo();
|
|
665
|
+
return;
|
|
660
666
|
}
|
|
661
667
|
requestPlay();
|
|
662
668
|
}, [isPlaying, pauseVideo, requestPlay]);
|
|
@@ -833,6 +833,10 @@ All notable user-facing changes to Clips are documented here. Open it any time f
|
|
|
833
833
|
loadFailedBody:
|
|
834
834
|
"Something went wrong while loading this list. Your recordings are safe — try again.",
|
|
835
835
|
retry: "Retry",
|
|
836
|
+
paginationRange: "{{start}}–{{end}} of {{total}}",
|
|
837
|
+
paginationPrevious: "Previous",
|
|
838
|
+
paginationNext: "Next",
|
|
839
|
+
paginationPage: "Page {{page}} of {{totalPages}}",
|
|
836
840
|
},
|
|
837
841
|
notificationsRoute: {
|
|
838
842
|
pageTitle: "Notifications · Clips",
|
|
@@ -1370,6 +1374,8 @@ All notable user-facing changes to Clips are documented here. Open it any time f
|
|
|
1370
1374
|
clipsFinalRaw: {
|
|
1371
1375
|
splitAtPlayhead: "Split at playhead (S)",
|
|
1372
1376
|
selectedCount: "{{count}} selected",
|
|
1377
|
+
selectAll: "Select all",
|
|
1378
|
+
deselectAll: "Deselect all",
|
|
1373
1379
|
move: "Move",
|
|
1374
1380
|
moveSelected: "Move {{count}} selected",
|
|
1375
1381
|
current: "Current",
|