infra: add linting and formatting for js projects (#230)
* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
This commit was merged in pull request #230.
This commit is contained in:
@@ -4,6 +4,21 @@
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
@@ -11,7 +26,8 @@
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/electron",
|
||||
"plugin:import/typescript",
|
||||
"plugin:react-hooks/recommended"
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"jsxSingleQuote": false
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import type { SignToolOptions } from '@electron/windows-sign';
|
||||
|
||||
// Windows code signing via Azure Trusted Signing. Dormant unless the workflow
|
||||
// provisions the dlib + metadata file and exports these env vars, so local
|
||||
@@ -16,7 +17,7 @@ import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
const windowsSign = process.env.AZURE_METADATA_JSON_PATH ? {
|
||||
debug: true,
|
||||
signToolPath: process.env.SIGNTOOL_PATH,
|
||||
hashes: ['sha256' as any],
|
||||
hashes: ['sha256'] as SignToolOptions['hashes'],
|
||||
timestampServer: 'http://timestamp.acs.microsoft.com',
|
||||
signWithParams: [
|
||||
'/v',
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64",
|
||||
"invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES",
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||
"compile": "npx tsc --noEmit 2>&1 | grep '^src/'",
|
||||
"web:dev": "cross-env APP_ENV=dev vite --config vite.web.config.mts",
|
||||
"web:build": "cross-env APP_ENV=prod vite build --config vite.web.config.mts",
|
||||
@@ -44,15 +46,17 @@
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "40.6.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+42
-25
@@ -1,27 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { Routes, Route, useNavigate } from "react-router-dom";
|
||||
import { RouterShell } from "@/lib/router-shell";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { LoginPage } from "@/features/auth/login-page";
|
||||
import { useEffect } from 'react';
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import { RouterShell } from '@/lib/router-shell';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { LoginPage } from '@/features/auth/login-page';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import SettingsPage from "@/features/settings-page";
|
||||
import AudioVideoSettingsPage from "@/features/settings/audio-video-settings-page";
|
||||
import NetworkSelector from "@/features/network-selector";
|
||||
import NetworkRoot from "@/features/network-root";
|
||||
import ParticleViewResolver from "@/features/particles/particle-view-resolver";
|
||||
import Layout from "@/features/layout";
|
||||
import NetworkSettingsPage from "@/features/network-settings";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { PusherProvider } from "@/lib/pusher-provider";
|
||||
import { createQueryClient } from "@/lib/query-client";
|
||||
import SettingsPage from '@/features/settings-page';
|
||||
import AudioVideoSettingsPage from '@/features/settings/audio-video-settings-page';
|
||||
import NetworkSelector from '@/features/network-selector';
|
||||
import NetworkRoot from '@/features/network-root';
|
||||
import ParticleViewResolver from '@/features/particles/particle-view-resolver';
|
||||
import Layout from '@/features/layout';
|
||||
import NetworkSettingsPage from '@/features/network-settings';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { PusherProvider } from '@/lib/pusher-provider';
|
||||
import { createQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
RouteErrorBoundary,
|
||||
TopLevelErrorBoundary,
|
||||
} from "@/components/app-error-boundary";
|
||||
import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { InAppAutoplayCard } from "@/components/in-app-autoplay-card";
|
||||
} from '@/components/app-error-boundary';
|
||||
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
@@ -33,7 +33,7 @@ const App = () => {
|
||||
restoreSession();
|
||||
}, [restoreSession]);
|
||||
|
||||
if (status === "idle" || status === "restoring") {
|
||||
if (status === 'idle' || status === 'restoring') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||
@@ -41,7 +41,7 @@ const App = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== "authenticated") {
|
||||
if (status !== 'authenticated') {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
@@ -72,12 +72,29 @@ function AuthenticatedApp() {
|
||||
<RouteErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="settings/audio-video" element={<AudioVideoSettingsPage />} />
|
||||
<Route
|
||||
path="settings/audio-video"
|
||||
element={<AudioVideoSettingsPage />}
|
||||
/>
|
||||
|
||||
<Route path="/">
|
||||
<Route index element={<Layout><NetworkSelector /></Layout>} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<Layout>
|
||||
<NetworkSelector />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
<Route path=":networkId">
|
||||
<Route index element={<Layout><NetworkRoot /></Layout>} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<Layout>
|
||||
<NetworkRoot />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<NetworkSettingsPage />} />
|
||||
<Route path="*" element={<ParticleViewResolver />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { appConfig } from "@/config/env";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import type { z } from "zod";
|
||||
import { appConfig } from '@/config/env';
|
||||
import { useSessionStore } from '@/stores/session-store';
|
||||
import { ApiError } from '@/lib/errors';
|
||||
import type { z } from 'zod';
|
||||
import {
|
||||
BillingStatusSchema,
|
||||
CheckoutSessionResponseSchema,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
PortalSessionResponseSchema,
|
||||
PrepareUploadResponseSchema,
|
||||
SignInResponseSchema,
|
||||
} from "./types";
|
||||
} from './types';
|
||||
import type {
|
||||
AcceptInvitationRequest,
|
||||
AddMembersRequest,
|
||||
@@ -26,8 +26,8 @@ import type {
|
||||
RequestCodeRequest,
|
||||
RevokeInvitationRequest,
|
||||
SignInRequest,
|
||||
} from "./types";
|
||||
import type { LinkMetadata } from "@/lib/link-metadata";
|
||||
} from './types';
|
||||
import type { LinkMetadata } from '@/lib/link-metadata';
|
||||
|
||||
interface ApiClientConfig {
|
||||
baseUrl: string;
|
||||
@@ -50,12 +50,12 @@ class ApiClient {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const token = this.config.getToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
@@ -66,11 +66,11 @@ class ApiClient {
|
||||
|
||||
if (response.status === 401) {
|
||||
this.config.onUnauthorized();
|
||||
throw new ApiError(401, "Unauthorized");
|
||||
throw new ApiError(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "Unknown error");
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
throw new ApiError(response.status, text);
|
||||
}
|
||||
|
||||
@@ -99,43 +99,42 @@ class ApiClient {
|
||||
// --- Auth ---
|
||||
|
||||
async requestCode(data: RequestCodeRequest): Promise<void> {
|
||||
await this.requestVoid("POST", "/auth/request-code", data);
|
||||
await this.requestVoid('POST', '/auth/request-code', data);
|
||||
}
|
||||
|
||||
async signIn(data: SignInRequest) {
|
||||
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
|
||||
return this.request(SignInResponseSchema, 'POST', '/auth/sign-in', data);
|
||||
}
|
||||
|
||||
async me() {
|
||||
return this.request(HumanSchema, "GET", "/auth/me");
|
||||
return this.request(HumanSchema, 'GET', '/auth/me');
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
await this.requestVoid("POST", "/auth/sign-out");
|
||||
await this.requestVoid('POST', '/auth/sign-out');
|
||||
}
|
||||
|
||||
async getFirebaseToken() {
|
||||
return this.request(
|
||||
FirebaseTokenResponseSchema,
|
||||
"POST",
|
||||
"/auth/firebase-token",
|
||||
'POST',
|
||||
'/auth/firebase-token',
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: security: require passing in the particle id once api deprecates this
|
||||
async getParticleDownloadUrl(objectId: string): Promise<string> {
|
||||
const response = await this.fetch(
|
||||
"GET",
|
||||
`/particles/${objectId}/download`,
|
||||
);
|
||||
const response = await this.fetch('GET', `/particles/${objectId}/download`);
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
async updateSettings(data: { email_notifications_enabled?: boolean }): Promise<void> {
|
||||
await this.requestVoid("PATCH", "/humans/me/settings", data);
|
||||
async updateSettings(data: {
|
||||
email_notifications_enabled?: boolean;
|
||||
}): Promise<void> {
|
||||
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
||||
}
|
||||
|
||||
// --- Depot ---
|
||||
@@ -143,8 +142,8 @@ class ApiClient {
|
||||
async prepareUpload(data: PrepareUploadRequest) {
|
||||
return this.request(
|
||||
PrepareUploadResponseSchema,
|
||||
"POST",
|
||||
"/depot/upload",
|
||||
'POST',
|
||||
'/depot/upload',
|
||||
data,
|
||||
);
|
||||
}
|
||||
@@ -152,41 +151,32 @@ class ApiClient {
|
||||
async confirmUpload(objectId: string) {
|
||||
return this.request(
|
||||
DepotObjectSchema,
|
||||
"POST",
|
||||
'POST',
|
||||
`/depot/objects/${objectId}/confirm`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// --- Networks ---
|
||||
|
||||
async listNetworks() {
|
||||
return this.request(
|
||||
ListNetworksResponseSchema,
|
||||
"GET",
|
||||
"/networks",
|
||||
);
|
||||
return this.request(ListNetworksResponseSchema, 'GET', '/networks');
|
||||
}
|
||||
|
||||
async createNetwork(data: CreateNetworkRequest) {
|
||||
return this.request(NetworkSchema, "POST", "/networks", data);
|
||||
return this.request(NetworkSchema, 'POST', '/networks', data);
|
||||
}
|
||||
|
||||
async getNetwork(id: string) {
|
||||
return this.request(NetworkSchema, "GET", `/networks/${id}`);
|
||||
return this.request(NetworkSchema, 'GET', `/networks/${id}`);
|
||||
}
|
||||
|
||||
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
|
||||
await this.requestVoid(
|
||||
"POST",
|
||||
`/networks/${networkId}/members`,
|
||||
data,
|
||||
);
|
||||
await this.requestVoid('POST', `/networks/${networkId}/members`, data);
|
||||
}
|
||||
|
||||
async removeMember(networkId: string, humanId: string): Promise<void> {
|
||||
await this.requestVoid(
|
||||
"DELETE",
|
||||
'DELETE',
|
||||
`/networks/${networkId}/members/${humanId}`,
|
||||
);
|
||||
}
|
||||
@@ -196,31 +186,39 @@ class ApiClient {
|
||||
async listNetworkInvitations(networkId: string) {
|
||||
return this.request(
|
||||
ListInvitationsResponseSchema,
|
||||
"GET",
|
||||
'GET',
|
||||
`/networks/${networkId}/invitations`,
|
||||
);
|
||||
}
|
||||
|
||||
async listMyInvitations() {
|
||||
return this.request(
|
||||
ListInvitationsResponseSchema,
|
||||
"GET",
|
||||
"/invitations",
|
||||
);
|
||||
return this.request(ListInvitationsResponseSchema, 'GET', '/invitations');
|
||||
}
|
||||
|
||||
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
|
||||
await this.requestVoid("POST", "/invitations/accept", data);
|
||||
await this.requestVoid('POST', '/invitations/accept', data);
|
||||
}
|
||||
|
||||
async revokeInvitation(networkId: string, data: RevokeInvitationRequest): Promise<void> {
|
||||
await this.requestVoid("DELETE", `/networks/${networkId}/invitations`, data);
|
||||
async revokeInvitation(
|
||||
networkId: string,
|
||||
data: RevokeInvitationRequest,
|
||||
): Promise<void> {
|
||||
await this.requestVoid(
|
||||
'DELETE',
|
||||
`/networks/${networkId}/invitations`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// --- LiveKit ---
|
||||
|
||||
async getLivekitToken(networkId: string, streamId: string) {
|
||||
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
|
||||
return this.request(
|
||||
GetLivekitTokenResponseSchema,
|
||||
'POST',
|
||||
'/livekit/token',
|
||||
{ network_id: networkId, stream_id: streamId },
|
||||
);
|
||||
}
|
||||
|
||||
// --- Billing (network admin only) ---
|
||||
@@ -228,7 +226,7 @@ class ApiClient {
|
||||
async getNetworkBilling(networkId: string) {
|
||||
return this.request(
|
||||
BillingStatusSchema,
|
||||
"GET",
|
||||
'GET',
|
||||
`/networks/${networkId}/billing`,
|
||||
);
|
||||
}
|
||||
@@ -236,7 +234,7 @@ class ApiClient {
|
||||
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
|
||||
return this.request(
|
||||
CheckoutSessionResponseSchema,
|
||||
"POST",
|
||||
'POST',
|
||||
`/networks/${networkId}/billing/checkout-session`,
|
||||
{ cadence },
|
||||
);
|
||||
@@ -245,7 +243,7 @@ class ApiClient {
|
||||
async createPortalSession(networkId: string) {
|
||||
return this.request(
|
||||
PortalSessionResponseSchema,
|
||||
"POST",
|
||||
'POST',
|
||||
`/networks/${networkId}/billing/portal-session`,
|
||||
);
|
||||
}
|
||||
@@ -253,7 +251,7 @@ class ApiClient {
|
||||
async getNetworkUsage(networkId: string) {
|
||||
return this.request(
|
||||
NetworkUsageSchema,
|
||||
"GET",
|
||||
'GET',
|
||||
`/networks/${networkId}/usage`,
|
||||
);
|
||||
}
|
||||
@@ -262,7 +260,7 @@ class ApiClient {
|
||||
|
||||
async getLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
||||
const response = await this.fetch(
|
||||
"GET",
|
||||
'GET',
|
||||
`/metadata?url=${encodeURIComponent(url)}`,
|
||||
);
|
||||
return (await response.json()) as LinkMetadata | null;
|
||||
|
||||
+72
-32
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { z } from 'zod';
|
||||
|
||||
export const HumanSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -25,12 +25,12 @@ export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
|
||||
|
||||
// --- Network request/response types ---
|
||||
|
||||
const CreateNetworkRequestSchema = z.object({
|
||||
export const CreateNetworkRequestSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
||||
|
||||
const AddMembersRequestSchema = z.object({
|
||||
export const AddMembersRequestSchema = z.object({
|
||||
email_addresses: z.array(z.string().email()),
|
||||
});
|
||||
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
||||
@@ -52,7 +52,7 @@ export type RevokeInvitationRequest = { email: string };
|
||||
|
||||
// --- Depot types ---
|
||||
|
||||
const PrepareUploadRequestSchema = z.object({
|
||||
export const PrepareUploadRequestSchema = z.object({
|
||||
network_id: z.string(),
|
||||
name: z.string(),
|
||||
content_type: z.string(),
|
||||
@@ -122,7 +122,7 @@ export const MediaPropertiesSchema = z.object({
|
||||
duration_ms: z.number(),
|
||||
size_bytes: z.number(),
|
||||
transcript: TranscriptSchema.optional(),
|
||||
source: z.enum(["camera", "screen"]).optional(),
|
||||
source: z.enum(['camera', 'screen']).optional(),
|
||||
// Set by the particle processor worker once an iOS-playable MP4/m4a variant
|
||||
// has been produced from a non-iOS-playable original (e.g. WebM from desktop).
|
||||
// When present, clients should prefer these over object_id/mime_type for playback.
|
||||
@@ -162,7 +162,9 @@ export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
|
||||
|
||||
// --- Reactions ---
|
||||
|
||||
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional();
|
||||
export const ReactionsSchema = z
|
||||
.record(z.string(), z.array(z.string()))
|
||||
.optional();
|
||||
export type Reactions = z.infer<typeof ReactionsSchema>;
|
||||
|
||||
// --- Tombstone (soft-delete) ---
|
||||
@@ -175,7 +177,15 @@ const TombstoneFields = {
|
||||
deleted_by_human_id: z.string().optional(),
|
||||
};
|
||||
|
||||
export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}", "\u{1F602}"] as const;
|
||||
export const REACTION_EMOJIS = [
|
||||
'\u{1F44D}',
|
||||
'\u{2764}\u{FE0F}',
|
||||
'\u{1F525}',
|
||||
'\u{1F440}',
|
||||
'\u{2705}',
|
||||
'\u{2753}',
|
||||
'\u{1F602}',
|
||||
] as const;
|
||||
|
||||
export interface ParticlePropertiesMap {
|
||||
stream: StreamProperties;
|
||||
@@ -196,9 +206,9 @@ const ParticleBaseSchema = z.object({
|
||||
updated_at: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const ParticleSchema = z.discriminatedUnion("type", [
|
||||
export const ParticleSchema = z.discriminatedUnion('type', [
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal("stream"),
|
||||
type: z.literal('stream'),
|
||||
properties: StreamPropertiesSchema,
|
||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||
// e.g. ["network:xywx"] - visible to everyone in the network
|
||||
@@ -210,27 +220,53 @@ export const ParticleSchema = z.discriminatedUnion("type", [
|
||||
last_child_created_at: z.coerce.date().optional(),
|
||||
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
||||
huddle_active_participants: z.array(z.string()).optional(),
|
||||
status: z.enum(["open", "closed"]).optional(),
|
||||
status: z.enum(['open', 'closed']).optional(),
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
||||
type: z.literal('folder'),
|
||||
properties: FolderPropertiesSchema,
|
||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||
// e.g. ["network:123"] - visible to everyone in the network
|
||||
visible_to: z.array(z.string()),
|
||||
}),
|
||||
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('media'),
|
||||
properties: MediaPropertiesSchema,
|
||||
reactions: ReactionsSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('file'),
|
||||
properties: FilePropertiesSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('text'),
|
||||
properties: TextPropertiesSchema,
|
||||
reactions: ReactionsSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('quest'),
|
||||
properties: QuestPropertiesSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('paper'),
|
||||
properties: PaperPropertiesSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type Particle = z.infer<typeof ParticleSchema>;
|
||||
|
||||
export type ParticleType = Particle["type"];
|
||||
export type ParticleType = Particle['type'];
|
||||
|
||||
/** Container types can have children subcollections */
|
||||
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
|
||||
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([
|
||||
'stream',
|
||||
'folder',
|
||||
]);
|
||||
|
||||
export function isContainerType(type: ParticleType): boolean {
|
||||
return CONTAINER_TYPES.has(type);
|
||||
@@ -238,7 +274,7 @@ export function isContainerType(type: ParticleType): boolean {
|
||||
|
||||
/** True when a non-container particle has been soft-deleted (tombstoned). */
|
||||
export function isParticleDeleted(particle: Particle): boolean {
|
||||
return "deleted_at" in particle && particle.deleted_at != null;
|
||||
return 'deleted_at' in particle && particle.deleted_at != null;
|
||||
}
|
||||
|
||||
// --- LiveKit types ---
|
||||
@@ -247,16 +283,18 @@ export const GetLivekitTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
server_url: z.string(),
|
||||
});
|
||||
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
|
||||
export type GetLivekitTokenResponse = z.infer<
|
||||
typeof GetLivekitTokenResponseSchema
|
||||
>;
|
||||
|
||||
// --- Auth types ---
|
||||
|
||||
const RequestCodeRequestSchema = z.object({
|
||||
export const RequestCodeRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
||||
|
||||
const SignInRequestSchema = z.object({
|
||||
export const SignInRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
code: z.string(),
|
||||
});
|
||||
@@ -275,21 +313,21 @@ export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
|
||||
|
||||
// --- Billing types ---
|
||||
|
||||
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
||||
export const BillingCadenceSchema = z.enum(['monthly', 'annual']);
|
||||
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
||||
|
||||
export const NetworkPlanSchema = z.enum(["free", "pro"]);
|
||||
export const NetworkPlanSchema = z.enum(['free', 'pro']);
|
||||
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
||||
|
||||
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
||||
export const BillingPlanStatusSchema = z.enum([
|
||||
"active",
|
||||
"trialing",
|
||||
"past_due",
|
||||
"canceled",
|
||||
"incomplete",
|
||||
"incomplete_expired",
|
||||
"unpaid",
|
||||
'active',
|
||||
'trialing',
|
||||
'past_due',
|
||||
'canceled',
|
||||
'incomplete',
|
||||
'incomplete_expired',
|
||||
'unpaid',
|
||||
]);
|
||||
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
||||
|
||||
@@ -308,7 +346,9 @@ export type BillingStatus = z.infer<typeof BillingStatusSchema>;
|
||||
export const CheckoutSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
|
||||
export type CheckoutSessionResponse = z.infer<
|
||||
typeof CheckoutSessionResponseSchema
|
||||
>;
|
||||
|
||||
export const PortalSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
|
||||
Vendored
+2
-2
@@ -1,9 +1,9 @@
|
||||
declare module "*.wav" {
|
||||
declare module '*.wav' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.mp3" {
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
|
||||
import { AutoplayCardContent } from "@/components/autoplay-card-content";
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { AutoplayPayload } from '@/lib/autoplay-ipc';
|
||||
import { AutoplayCardContent } from '@/components/autoplay-card-content';
|
||||
|
||||
export function AutoplayApp() {
|
||||
const [payload, setPayload] = useState<AutoplayPayload | null>(null);
|
||||
|
||||
@@ -5,5 +5,7 @@ import '@/styles/globals.css';
|
||||
|
||||
initSentryRenderer();
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Root element #root not found');
|
||||
const root = createRoot(container);
|
||||
root.render(<AutoplayApp />);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { reportError } from "@/lib/errors";
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
|
||||
import { reportError } from '@/lib/errors';
|
||||
import {
|
||||
RouteErrorFallback,
|
||||
TopLevelErrorFallback,
|
||||
} from "@/components/error-fallback";
|
||||
} from '@/components/error-fallback';
|
||||
|
||||
/** Catches render crashes OUTSIDE the router so bootstrap failures still recover. */
|
||||
export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
|
||||
@@ -15,7 +15,7 @@ export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
|
||||
FallbackComponent={TopLevelErrorFallback}
|
||||
onError={(error, info) =>
|
||||
reportError(error, {
|
||||
boundary: "top",
|
||||
boundary: 'top',
|
||||
componentStack: info.componentStack,
|
||||
})
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function RouteErrorBoundary({ children }: PropsWithChildren) {
|
||||
FallbackComponent={RouteErrorFallback}
|
||||
onError={(error, info) =>
|
||||
reportError(error, {
|
||||
boundary: "route",
|
||||
boundary: 'route',
|
||||
pathname: location.pathname,
|
||||
componentStack: info.componentStack,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface AudioLevelBarsProps {
|
||||
sourceNode: AudioNode;
|
||||
@@ -83,7 +83,8 @@ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) {
|
||||
smoothedLevel <= threshold
|
||||
? 0
|
||||
: Math.min(1, (smoothedLevel - threshold) / (1 - threshold));
|
||||
const height = MIN_HEIGHT_PX + barLevel * (MAX_HEIGHT_PX - MIN_HEIGHT_PX);
|
||||
const height =
|
||||
MIN_HEIGHT_PX + barLevel * (MAX_HEIGHT_PX - MIN_HEIGHT_PX);
|
||||
el.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface AudioSource {
|
||||
sourceNode: AudioNode;
|
||||
@@ -17,46 +17,52 @@ export function useAudioSource(
|
||||
): AudioSource | null {
|
||||
const [audioSource, setAudioSource] = useState<AudioSource | null>(null);
|
||||
const elementSourceCache = useRef<
|
||||
WeakMap<HTMLAudioElement, { sourceNode: MediaElementAudioSourceNode; ctx: AudioContext }>
|
||||
WeakMap<
|
||||
HTMLAudioElement,
|
||||
{ sourceNode: MediaElementAudioSourceNode; ctx: AudioContext }
|
||||
>
|
||||
>(new WeakMap());
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setAudioSource(null);
|
||||
return;
|
||||
}
|
||||
// Build the source node (creating an AudioContext as needed) plus optional
|
||||
// teardown for contexts we own; the branches converge on one setState.
|
||||
let result: AudioSource | null = null;
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
if (source instanceof MediaStream) {
|
||||
const ctx = new AudioContext();
|
||||
ctx.resume();
|
||||
const sourceNode = ctx.createMediaStreamSource(source);
|
||||
setAudioSource({ sourceNode, ctx });
|
||||
|
||||
return () => {
|
||||
ctx.close();
|
||||
};
|
||||
result = { sourceNode, ctx };
|
||||
cleanup = () => ctx.close();
|
||||
} else if (source) {
|
||||
// HTMLAudioElement — createMediaElementSource can only be called once per
|
||||
// element, so reuse a cached context/node when we have one.
|
||||
const cached = elementSourceCache.current.get(source);
|
||||
if (cached) {
|
||||
cached.ctx.resume();
|
||||
result = cached;
|
||||
} else {
|
||||
const ctx = new AudioContext();
|
||||
ctx.resume();
|
||||
const sourceNode = ctx.createMediaElementSource(source);
|
||||
// Connect element source to destination so audio is still audible
|
||||
sourceNode.connect(ctx.destination);
|
||||
elementSourceCache.current.set(source, { sourceNode, ctx });
|
||||
result = { sourceNode, ctx };
|
||||
cleanup = () => {
|
||||
ctx.close();
|
||||
elementSourceCache.current.delete(source);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// HTMLAudioElement — createMediaElementSource can only be called once per element
|
||||
const cached = elementSourceCache.current.get(source);
|
||||
if (cached) {
|
||||
cached.ctx.resume();
|
||||
setAudioSource(cached);
|
||||
return;
|
||||
}
|
||||
// Publishing an imperatively-created Web Audio node — external-resource
|
||||
// sync, not a re-render cascade.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAudioSource(result);
|
||||
|
||||
const ctx = new AudioContext();
|
||||
ctx.resume();
|
||||
const sourceNode = ctx.createMediaElementSource(source);
|
||||
// Connect element source to destination so audio is still audible
|
||||
sourceNode.connect(ctx.destination);
|
||||
elementSourceCache.current.set(source, { sourceNode, ctx });
|
||||
setAudioSource({ sourceNode, ctx });
|
||||
|
||||
return () => {
|
||||
ctx.close();
|
||||
elementSourceCache.current.delete(source);
|
||||
};
|
||||
return cleanup;
|
||||
}, [source]);
|
||||
|
||||
return audioSource;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
|
||||
import { useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { AutoplayPayload } from '@/lib/autoplay-ipc';
|
||||
|
||||
interface AutoplayCardContentProps {
|
||||
payload: AutoplayPayload;
|
||||
@@ -17,7 +17,7 @@ export function AutoplayCardContent({
|
||||
}: AutoplayCardContentProps) {
|
||||
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
|
||||
const isVideo = payload.mimeType.startsWith("video/");
|
||||
const isVideo = payload.mimeType.startsWith('video/');
|
||||
|
||||
const handleClick = () => {
|
||||
mediaRef.current?.pause();
|
||||
@@ -56,7 +56,9 @@ export function AutoplayCardContent({
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white">
|
||||
{payload.senderInitials}
|
||||
</div>
|
||||
<p className="truncate text-xs text-white/80">{payload.senderName}</p>
|
||||
<p className="truncate text-xs text-white/80">
|
||||
{payload.senderName}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -72,7 +74,9 @@ export function AutoplayCardContent({
|
||||
{payload.senderInitials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-card-foreground">{payload.senderName}</p>
|
||||
<p className="truncate text-sm font-medium text-card-foreground">
|
||||
{payload.senderName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Playing audio...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Human } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import type { ComposingUser } from "@/features/particles/stream-presence-context";
|
||||
import type { Human } from '@/api/types';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import type { ComposingUser } from '@/features/particles/stream-presence-context';
|
||||
|
||||
interface ComposingIndicatorProps {
|
||||
users: ComposingUser[];
|
||||
@@ -20,11 +20,11 @@ export function ComposingIndicator({
|
||||
return (
|
||||
<div
|
||||
className="z-100 absolute left-2 top-1/2 z-20 flex -translate-y-1/2 flex-col gap-1.5 animate-in fade-in duration-200"
|
||||
style={{ writingMode: "vertical-rl" }}
|
||||
style={{ writingMode: 'vertical-rl' }}
|
||||
>
|
||||
{users.map((u) => {
|
||||
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
|
||||
const modeLabel = u.mode === "typing" ? "typing" : "recording";
|
||||
const modeLabel = u.mode === 'typing' ? 'typing' : 'recording';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ConfirmDestructiveOverlayProps {
|
||||
title: string;
|
||||
@@ -16,21 +16,22 @@ export function ConfirmDestructiveOverlay({
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
pendingLabel = "Working…",
|
||||
pendingLabel = 'Working…',
|
||||
isPending,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmDestructiveOverlayProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handler, { capture: true });
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () =>
|
||||
window.removeEventListener('keydown', handler, { capture: true });
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
@@ -45,7 +46,7 @@ export function ConfirmDestructiveOverlay({
|
||||
<span className="text-xs text-white/30">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to close
|
||||
</span>
|
||||
</div>
|
||||
@@ -53,7 +54,12 @@ export function ConfirmDestructiveOverlay({
|
||||
<div className="text-sm text-white/60">{description}</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose} disabled={isPending}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CopyableEmailProps {
|
||||
email: string;
|
||||
@@ -22,9 +22,9 @@ export function CopyableEmail({ email, className }: CopyableEmailProps) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
setCopied(true);
|
||||
toast.success("Email copied");
|
||||
toast.success('Email copied');
|
||||
} catch {
|
||||
toast.error("Failed to copy email");
|
||||
toast.error('Failed to copy email');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CopyableEmail({ email, className }: CopyableEmailProps) {
|
||||
onClick={handleCopy}
|
||||
aria-label={`Copy ${email}`}
|
||||
className={cn(
|
||||
"hover:bg-accent inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-sm transition-colors",
|
||||
'hover:bg-accent inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-sm transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { FallbackProps } from 'react-error-boundary';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { appEnv } from "@/config/env";
|
||||
} from '@/components/ui/card';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import { appEnv } from '@/config/env';
|
||||
|
||||
function ErrorCard({
|
||||
error,
|
||||
@@ -32,7 +32,7 @@ function ErrorCard({
|
||||
</div>
|
||||
<CardDescription>{toUserMessage(error)}</CardDescription>
|
||||
</CardHeader>
|
||||
{appEnv === "dev" && error instanceof Error ? (
|
||||
{appEnv === 'dev' && error instanceof Error ? (
|
||||
<CardContent>
|
||||
<details className="text-muted-foreground text-xs">
|
||||
<summary className="cursor-pointer select-none">
|
||||
@@ -63,15 +63,11 @@ export function TopLevelErrorFallback({
|
||||
resetErrorBoundary,
|
||||
}: FallbackProps) {
|
||||
const goHome = () => {
|
||||
window.location.hash = "#/";
|
||||
window.location.hash = '#/';
|
||||
resetErrorBoundary();
|
||||
};
|
||||
return (
|
||||
<ErrorCard
|
||||
error={error}
|
||||
onGoHome={goHome}
|
||||
onRetry={resetErrorBoundary}
|
||||
/>
|
||||
<ErrorCard error={error} onGoHome={goHome} onRetry={resetErrorBoundary} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,14 +77,10 @@ export function RouteErrorFallback({
|
||||
}: FallbackProps) {
|
||||
const navigate = useNavigate();
|
||||
const goHome = () => {
|
||||
navigate("/");
|
||||
navigate('/');
|
||||
resetErrorBoundary();
|
||||
};
|
||||
return (
|
||||
<ErrorCard
|
||||
error={error}
|
||||
onGoHome={goHome}
|
||||
onRetry={resetErrorBoundary}
|
||||
/>
|
||||
<ErrorCard error={error} onGoHome={goHome} onRetry={resetErrorBoundary} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { useAutoplayPayloadStore } from "@/stores/autoplay-payload-store";
|
||||
import { AutoplayCardContent } from "@/components/autoplay-card-content";
|
||||
import { useCallback } from 'react';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { useAutoplayPayloadStore } from '@/stores/autoplay-payload-store';
|
||||
import { AutoplayCardContent } from '@/components/autoplay-card-content';
|
||||
|
||||
/**
|
||||
* Bottom-right floating autoplay card used on the web client. The desktop
|
||||
@@ -21,10 +21,10 @@ export function InAppAutoplayCard() {
|
||||
setPendingNav({ networkId: payload.networkId, streamId: payload.streamId });
|
||||
}, [payload, setPayload, setPendingNav]);
|
||||
|
||||
if (platform.kind !== "web") return null;
|
||||
if (platform.kind !== 'web') return null;
|
||||
if (!payload) return null;
|
||||
|
||||
const isVideo = payload.mimeType.startsWith("video/");
|
||||
const isVideo = payload.mimeType.startsWith('video/');
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export interface KeybindingEntry {
|
||||
keys: string[];
|
||||
@@ -23,21 +23,22 @@ export function KeybindingsOverlay({
|
||||
open,
|
||||
onClose,
|
||||
groups,
|
||||
title = "Keyboard Shortcuts",
|
||||
title = 'Keyboard Shortcuts',
|
||||
}: KeybindingsOverlayProps) {
|
||||
useSuspendPlayback(open, "keybindings");
|
||||
useSuspendPlayback(open, 'keybindings');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "?") {
|
||||
if (e.key === 'Escape' || e.key === '?') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handler, { capture: true });
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () =>
|
||||
window.removeEventListener('keydown', handler, { capture: true });
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -56,11 +57,11 @@ export function KeybindingsOverlay({
|
||||
<span className="text-xs text-white/30">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
or{" "}
|
||||
</kbd>{' '}
|
||||
or{' '}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
?
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to close
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Copy, ExternalLink, Globe } from "lucide-react";
|
||||
import type { LinkMetadata } from "@/lib/link-metadata";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { Copy, ExternalLink, Globe } from 'lucide-react';
|
||||
import type { LinkMetadata } from '@/lib/link-metadata';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
interface LinkPreviewCardProps {
|
||||
metadata: LinkMetadata;
|
||||
@@ -35,7 +34,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
alt=""
|
||||
className="h-32 w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -48,7 +47,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
className="size-4 rounded-sm"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).replaceWith(
|
||||
document.createElement("span"),
|
||||
document.createElement('span'),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatDistanceToNow } from "@/lib/time-utils";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { formatDistanceToNow } from '@/lib/time-utils';
|
||||
|
||||
const TICK_MS = 30_000;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ScreenSourcePickerProps {
|
||||
title?: string;
|
||||
@@ -9,8 +9,8 @@ interface ScreenSourcePickerProps {
|
||||
}
|
||||
|
||||
export function ScreenSourcePicker({
|
||||
title = "Select a screen",
|
||||
confirmLabel = "Select",
|
||||
title = 'Select a screen',
|
||||
confirmLabel = 'Select',
|
||||
getSources,
|
||||
onSelect,
|
||||
onCancel,
|
||||
@@ -23,18 +23,15 @@ export function ScreenSourcePicker({
|
||||
getSources().then((result) => {
|
||||
setSources(result);
|
||||
setLoading(false);
|
||||
// Auto-select if there's only one source.
|
||||
if (result.length === 1) {
|
||||
setSelectedId(result[0].id);
|
||||
}
|
||||
});
|
||||
}, [getSources]);
|
||||
|
||||
// Auto-select if there's only one source
|
||||
useEffect(() => {
|
||||
if (!loading && sources.length === 1) {
|
||||
setSelectedId(sources[0].id);
|
||||
}
|
||||
}, [loading, sources]);
|
||||
|
||||
const screens = sources.filter((s) => s.id.startsWith("screen:"));
|
||||
const windows = sources.filter((s) => s.id.startsWith("window:"));
|
||||
const screens = sources.filter((s) => s.id.startsWith('screen:'));
|
||||
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||
@@ -119,8 +116,8 @@ function SourceSection({
|
||||
onClick={() => onSelect(source.id)}
|
||||
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
||||
selectedId === source.id
|
||||
? "border-blue-500 bg-zinc-800"
|
||||
: "border-transparent bg-zinc-800/50 hover:border-zinc-600"
|
||||
? 'border-blue-500 bg-zinc-800'
|
||||
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Avatar as AvatarPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "xs" | "sm" | "lg"
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg';
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 data-[size=xs]:size-4 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||
className
|
||||
'size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 data-[size=xs]:size-4 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
@@ -31,12 +31,12 @@ function AvatarImage({
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"rounded-full aspect-square size-full object-cover",
|
||||
className
|
||||
'rounded-full aspect-square size-full object-cover',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
@@ -47,55 +47,58 @@ function AvatarFallback({
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs group-data-[size=xs]/avatar:text-[8px]",
|
||||
className
|
||||
'bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs group-data-[size=xs]/avatar:text-[8px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||
"group-data-[size=xs]/avatar:size-1.5 group-data-[size=xs]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
|
||||
'group-data-[size=xs]/avatar:size-1.5 group-data-[size=xs]/avatar:[&>svg]:hidden',
|
||||
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||
className
|
||||
'*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=xs]/avatar-group:size-4 group-has-data-[size=xs]/avatar-group:text-[8px] [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=xs]/avatar-group:size-4 group-has-data-[size=xs]/avatar-group:text-[8px] [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -105,4 +108,4 @@ export {
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
|
||||
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive:
|
||||
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||
outline:
|
||||
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
const Comp = asChild ? Slot.Root : 'span';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -39,7 +43,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
@@ -12,103 +12,100 @@ function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||
className
|
||||
'flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
className={cn('inline-flex items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
const Comp = asChild ? Slot.Root : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<ChevronRightIcon />
|
||||
)}
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||
className
|
||||
'flex size-5 items-center justify-center [&>svg]:size-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon
|
||||
/>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -119,4 +116,4 @@ export {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,50 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
destructive:
|
||||
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
default:
|
||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
|
||||
icon: 'size-8',
|
||||
'icon-xs':
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm':
|
||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const Comp = asChild ? Slot.Root : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -54,7 +61,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,86 +1,95 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||
className={cn(
|
||||
'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
className
|
||||
'gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
className={cn(
|
||||
'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
className={cn('px-4 group-data-[size=sm]/card:px-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||
className={cn(
|
||||
'bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -91,4 +100,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -12,8 +12,8 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -21,11 +21,10 @@ function Checkbox({
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon, CheckIcon } from 'lucide-react';
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
@@ -17,10 +17,10 @@ function ContextMenuTrigger({
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
className={cn('select-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
@@ -28,7 +28,7 @@ function ContextMenuGroup({
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
@@ -36,13 +36,13 @@ function ContextMenuPortal({
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
@@ -53,34 +53,37 @@ function ContextMenuRadioGroup({
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
@@ -89,11 +92,11 @@ function ContextMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
@@ -102,7 +105,7 @@ function ContextMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
@@ -110,14 +113,14 @@ function ContextMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
@@ -127,10 +130,13 @@ function ContextMenuSubContent({
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn(
|
||||
'z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
@@ -140,7 +146,7 @@ function ContextMenuCheckboxItem({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
@@ -148,20 +154,19 @@ function ContextMenuCheckboxItem({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
@@ -170,7 +175,7 @@ function ContextMenuRadioItem({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
@@ -178,19 +183,18 @@ function ContextMenuRadioItem({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
@@ -198,19 +202,19 @@ function ContextMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
@@ -220,26 +224,26 @@ function ContextMenuSeparator({
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||
className
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -258,4 +262,4 @@ export {
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@@ -38,10 +38,13 @@ function DialogOverlay({
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
|
||||
className={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -50,7 +53,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
@@ -58,34 +61,37 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className
|
||||
'bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
|
||||
<XIcon
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("gap-2 flex flex-col", className)}
|
||||
className={cn('gap-2 flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -93,15 +99,15 @@ function DialogFooter({
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
'bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -112,7 +118,7 @@ function DialogFooter({
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
@@ -122,10 +128,10 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
className={cn('text-base leading-none font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@@ -135,10 +141,13 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||
className={cn(
|
||||
'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -152,4 +161,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@@ -26,12 +26,12 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
align = 'start',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
@@ -41,11 +41,14 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className )}
|
||||
className={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@@ -53,17 +56,17 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -72,11 +75,11 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -86,7 +89,7 @@ function DropdownMenuCheckboxItem({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
@@ -94,7 +97,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -104,13 +107,12 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@@ -121,7 +123,7 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -130,7 +132,7 @@ function DropdownMenuRadioItem({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
@@ -138,7 +140,7 @@ function DropdownMenuRadioItem({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -147,13 +149,12 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -161,16 +162,19 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7", className)}
|
||||
className={cn(
|
||||
'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -180,29 +184,32 @@ function DropdownMenuSeparator({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
|
||||
className={cn(
|
||||
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -211,7 +218,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -219,14 +226,14 @@ function DropdownMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -236,10 +243,13 @@ function DropdownMenuSubContent({
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden", className )}
|
||||
className={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -258,4 +268,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Label as LabelPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({
|
||||
className,
|
||||
@@ -11,12 +11,12 @@ function Label({
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||
className
|
||||
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Progress as ProgressPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
@@ -14,8 +14,8 @@ function Progress({
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden",
|
||||
className
|
||||
'bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -25,7 +25,7 @@ function Progress({
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
@@ -10,10 +10,10 @@ function RadioGroup({
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid w-full gap-2", className)}
|
||||
className={cn('grid w-full gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
@@ -24,8 +24,8 @@ function RadioGroupItem({
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
'group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -36,7 +36,7 @@ function RadioGroupItem({
|
||||
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
@@ -11,7 +11,7 @@ function ScrollArea({
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
@@ -23,12 +23,12 @@ function ScrollArea({
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
@@ -37,8 +37,8 @@ function ScrollBar({
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
|
||||
className
|
||||
'data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -47,7 +47,7 @@ function ScrollBar({
|
||||
className="rounded-full bg-border relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Select as SelectPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react';
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
@@ -19,25 +19,25 @@ function SelectGroup({
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
className={cn('scroll-my-1 p-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
size?: 'sm' | 'default';
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -45,7 +45,7 @@ function SelectTrigger({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -54,22 +54,27 @@ function SelectTrigger({
|
||||
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
position = 'item-aligned',
|
||||
align = 'center',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
data-align-trigger={position === 'item-aligned'}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
@@ -78,8 +83,8 @@ function SelectContent({
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
'data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)',
|
||||
position === 'popper' && '',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -87,7 +92,7 @@ function SelectContent({
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
@@ -97,10 +102,10 @@ function SelectLabel({
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
|
||||
className={cn('text-muted-foreground px-1.5 py-1 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
@@ -113,7 +118,7 @@ function SelectItem({
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -124,7 +129,7 @@ function SelectItem({
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
@@ -134,10 +139,10 @@ function SelectSeparator({
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px pointer-events-none", className)}
|
||||
className={cn('bg-border -mx-1 my-1 h-px pointer-events-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
@@ -147,13 +152,15 @@ function SelectScrollUpButton({
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
@@ -163,13 +170,15 @@ function SelectScrollDownButton({
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -183,4 +192,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Separator as SeparatorPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
@@ -15,12 +15,12 @@ function Separator({
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
'bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-muted rounded-md animate-pulse", className)}
|
||||
className={cn('bg-muted rounded-md animate-pulse', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Slider as SliderPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Slider as SliderPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
@@ -18,8 +18,8 @@ function Slider({
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
@@ -29,8 +29,8 @@ function Slider({
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
|
||||
className
|
||||
'relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -51,7 +51,7 @@ function Slider({
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
export { Slider };
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner';
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
TriangleAlertIcon,
|
||||
OctagonXIcon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
@@ -7,38 +13,28 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
theme="dark"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
toast: 'cn-toast',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Switch as SwitchPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
size?: 'sm' | 'default';
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -25,7 +25,7 @@ function Switch({
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
export { Switch };
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
orientation = 'horizontal',
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
@@ -16,32 +16,32 @@ function Tabs({
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
'group/tabs flex gap-2 data-horizontal:flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
default: 'bg-muted',
|
||||
line: 'gap-1 bg-transparent',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
@@ -52,7 +52,7 @@ function TabsList({
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
@@ -64,14 +64,14 @@ function TabsTrigger({
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
|
||||
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
|
||||
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
@@ -81,10 +81,10 @@ function TabsContent({
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
className={cn('flex-1 text-sm outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toggleVariants } from '@/components/ui/toggle';
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
spacing?: number;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
spacing: 0,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
orientation = "horizontal",
|
||||
orientation = 'horizontal',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
spacing?: number;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
@@ -37,10 +37,10 @@ function ToggleGroup({
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
style={{ '--gap': spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
'group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -50,18 +50,18 @@ function ToggleGroup({
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
@@ -70,18 +70,18 @@ function ToggleGroupItem({
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
'shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t',
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Toggle as TogglePrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
default: 'bg-transparent',
|
||||
outline: 'border border-input bg-transparent hover:bg-muted',
|
||||
},
|
||||
size: {
|
||||
default: "h-8 min-w-8 px-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]",
|
||||
lg: "h-9 min-w-9 px-2.5",
|
||||
default: 'h-8 min-w-8 px-2',
|
||||
sm: 'h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]',
|
||||
lg: 'h-9 min-w-9 px-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
@@ -38,7 +38,7 @@ function Toggle({
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
export { Toggle, toggleVariants };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
@@ -13,19 +13,19 @@ function TooltipProvider({
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
@@ -40,8 +40,8 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
|
||||
className
|
||||
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -49,7 +49,7 @@ function TooltipContent({
|
||||
<TooltipPrimitive.Arrow className="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
|
||||
@@ -1,147 +1,131 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function H1({ className, ...props }: React.ComponentProps<"h1">) {
|
||||
function H1({ className, ...props }: React.ComponentProps<'h1'>) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
"scroll-m-20 text-4xl font-extrabold tracking-tight text-balance",
|
||||
className
|
||||
'scroll-m-20 text-4xl font-extrabold tracking-tight text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function H2({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
function H2({ className, ...props }: React.ComponentProps<'h2'>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
"scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0",
|
||||
className
|
||||
'scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function H3({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
function H3({ className, ...props }: React.ComponentProps<'h3'>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"scroll-m-20 text-2xl font-semibold tracking-tight",
|
||||
className
|
||||
'scroll-m-20 text-2xl font-semibold tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function H4({ className, ...props }: React.ComponentProps<"h4">) {
|
||||
function H4({ className, ...props }: React.ComponentProps<'h4'>) {
|
||||
return (
|
||||
<h4
|
||||
className={cn(
|
||||
"scroll-m-20 text-xl font-semibold tracking-tight",
|
||||
className
|
||||
'scroll-m-20 text-xl font-semibold tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function P({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function P({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"leading-7 [&:not(:first-child)]:mt-6",
|
||||
className
|
||||
)}
|
||||
className={cn('leading-7 [&:not(:first-child)]:mt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Blockquote({ className, ...props }: React.ComponentProps<"blockquote">) {
|
||||
function Blockquote({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'blockquote'>) {
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"mt-6 border-l-2 pl-6 italic",
|
||||
className
|
||||
)}
|
||||
className={cn('mt-6 border-l-2 pl-6 italic', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function List({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
function List({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
"my-6 ml-6 list-disc [&>li]:mt-2",
|
||||
className
|
||||
)}
|
||||
className={cn('my-6 ml-6 list-disc [&>li]:mt-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InlineCode({ className, ...props }: React.ComponentProps<"code">) {
|
||||
function InlineCode({ className, ...props }: React.ComponentProps<'code'>) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
|
||||
className
|
||||
'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Lead({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function Lead({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground text-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
<p className={cn('text-muted-foreground text-xl', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function Large({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-lg font-semibold",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function Large({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('text-lg font-semibold', className)} {...props} />;
|
||||
}
|
||||
|
||||
function Small({ className, ...props }: React.ComponentProps<"small">) {
|
||||
function Small({ className, ...props }: React.ComponentProps<'small'>) {
|
||||
return (
|
||||
<small
|
||||
className={cn(
|
||||
"text-sm leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
className={cn('text-sm leading-none font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Muted({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function Muted({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
<p className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { H1, H2, H3, H4, P, Blockquote, List, InlineCode, Lead, Large, Small, Muted }
|
||||
export {
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
H4,
|
||||
P,
|
||||
Blockquote,
|
||||
List,
|
||||
InlineCode,
|
||||
Lead,
|
||||
Large,
|
||||
Small,
|
||||
Muted,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Video, Mic } from "lucide-react";
|
||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||
import { Video, Mic } from 'lucide-react';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
|
||||
export function VideoAudioToggle() {
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
@@ -9,17 +9,19 @@ export function VideoAudioToggle() {
|
||||
<span
|
||||
role="button"
|
||||
onClick={() =>
|
||||
setRecordingMode(recordingMode === "video" ? "audio" : "video")
|
||||
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video')
|
||||
}
|
||||
title={
|
||||
recordingMode === "video" ? "Switch to audio-only (V)" : "Switch to video (V)"
|
||||
recordingMode === 'video'
|
||||
? 'Switch to audio-only (V)'
|
||||
: 'Switch to video (V)'
|
||||
}
|
||||
className="cursor-pointer transition-colors hover:text-white/80"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
V
|
||||
</kbd>{" "}
|
||||
{recordingMode === "video" ? (
|
||||
</kbd>{' '}
|
||||
{recordingMode === 'video' ? (
|
||||
<>
|
||||
<Video className="inline size-3" /> video
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Minus, Square, X } from "lucide-react";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, Minus, Square, X } from 'lucide-react';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
export function WindowControls() {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
@@ -12,7 +12,7 @@ export function WindowControls() {
|
||||
return platform.window.onMaximizeChange(setIsMaximized);
|
||||
}, []);
|
||||
|
||||
if (platform.kind !== "electron") return null;
|
||||
if (platform.kind !== 'electron') return null;
|
||||
|
||||
const minimize = (
|
||||
<Button
|
||||
@@ -33,7 +33,7 @@ export function WindowControls() {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => platform.window.maximize()}
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||
>
|
||||
{isMaximized ? <Copy /> : <Square />}
|
||||
@@ -56,12 +56,6 @@ export function WindowControls() {
|
||||
const buttons = [close, maximize, minimize];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"no-drag flex items-center gap-0.5"
|
||||
)}
|
||||
>
|
||||
{buttons}
|
||||
</div>
|
||||
<div className={cn('no-drag flex items-center gap-0.5')}>{buttons}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// rules + App Check), so both configs live in source. To refresh, run:
|
||||
// cd infra/gcp/{dev,prod} && terraform output -json firebase_config
|
||||
|
||||
declare const __APP_ENV__: "dev" | "prod";
|
||||
declare const __APP_ENV__: 'dev' | 'prod';
|
||||
|
||||
type FirebaseConfig = {
|
||||
apiKey: string;
|
||||
@@ -27,34 +27,36 @@ type AppConfig = {
|
||||
sentryDsn: string;
|
||||
};
|
||||
|
||||
const configs: Record<"dev" | "prod", AppConfig> = {
|
||||
const configs: Record<'dev' | 'prod', AppConfig> = {
|
||||
dev: {
|
||||
orionUrl: "https://orion.dev.flowy.live",
|
||||
pusherUrl: "wss://pusher.dev.flowy.live/ws",
|
||||
orionUrl: 'https://orion.dev.flowy.live',
|
||||
pusherUrl: 'wss://pusher.dev.flowy.live/ws',
|
||||
firebase: {
|
||||
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
|
||||
appId: "1:1006580076785:web:e2a0736d60a78e02b15950",
|
||||
authDomain: "flowy-dev-440017.firebaseapp.com",
|
||||
messagingSenderId: "1006580076785",
|
||||
projectId: "flowy-dev-440017",
|
||||
storageBucket: "flowy-dev-440017.firebasestorage.app",
|
||||
apiKey: 'AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk',
|
||||
appId: '1:1006580076785:web:e2a0736d60a78e02b15950',
|
||||
authDomain: 'flowy-dev-440017.firebaseapp.com',
|
||||
messagingSenderId: '1006580076785',
|
||||
projectId: 'flowy-dev-440017',
|
||||
storageBucket: 'flowy-dev-440017.firebasestorage.app',
|
||||
},
|
||||
sentryDsn: "https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
|
||||
sentryDsn:
|
||||
'https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528',
|
||||
},
|
||||
prod: {
|
||||
orionUrl: "https://orion.flowy.live",
|
||||
pusherUrl: "wss://pusher.flowy.live/ws",
|
||||
orionUrl: 'https://orion.flowy.live',
|
||||
pusherUrl: 'wss://pusher.flowy.live/ws',
|
||||
firebase: {
|
||||
apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg",
|
||||
appId: "1:68063426854:web:5054f16f50898f5706e9e7",
|
||||
authDomain: "flowy-prod-440017.firebaseapp.com",
|
||||
messagingSenderId: "68063426854",
|
||||
projectId: "flowy-prod-440017",
|
||||
storageBucket: "flowy-prod-440017.firebasestorage.app",
|
||||
apiKey: 'AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg',
|
||||
appId: '1:68063426854:web:5054f16f50898f5706e9e7',
|
||||
authDomain: 'flowy-prod-440017.firebaseapp.com',
|
||||
messagingSenderId: '68063426854',
|
||||
projectId: 'flowy-prod-440017',
|
||||
storageBucket: 'flowy-prod-440017.firebasestorage.app',
|
||||
},
|
||||
sentryDsn: "https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
|
||||
sentryDsn:
|
||||
'https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528',
|
||||
},
|
||||
};
|
||||
|
||||
export const appConfig: AppConfig = configs[__APP_ENV__];
|
||||
export const appEnv: "dev" | "prod" = __APP_ENV__;
|
||||
export const appEnv: 'dev' | 'prod' = __APP_ENV__;
|
||||
|
||||
Vendored
+6
-2
@@ -17,7 +17,9 @@ declare global {
|
||||
openHuddle: (data: { token: string; serverUrl: string }) => void;
|
||||
closeHuddle: () => void;
|
||||
platform: NodeJS.Platform;
|
||||
onMaximizeChange: (callback: (isMaximized: boolean) => void) => () => void;
|
||||
onMaximizeChange: (
|
||||
callback: (isMaximized: boolean) => void,
|
||||
) => () => void;
|
||||
};
|
||||
electronHuddle: {
|
||||
getScreenSources: () => Promise<ScreenSource[]>;
|
||||
@@ -28,7 +30,9 @@ declare global {
|
||||
navigate: (data: { networkId: string; streamId: string }) => void;
|
||||
onPlay: (callback: (payload: AutoplayPayload) => void) => () => void;
|
||||
onStop: (callback: () => void) => () => void;
|
||||
onNavigate: (callback: (data: { networkId: string; streamId: string }) => void) => () => void;
|
||||
onNavigate: (
|
||||
callback: (data: { networkId: string; streamId: string }) => void,
|
||||
) => () => void;
|
||||
};
|
||||
electronScreen: {
|
||||
getScreenSources: () => Promise<ScreenSource[]>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -8,20 +8,19 @@ import {
|
||||
Loader2,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { platform } from "@/lib/platform";
|
||||
} from 'lucide-react';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { useObjectUrl } from '@/hooks/use-object-url';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
export interface AttachmentItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
sizeBytes?: number;
|
||||
source:
|
||||
| { kind: "remote"; objectId: string }
|
||||
| { kind: "local"; file: File };
|
||||
source: { kind: 'remote'; objectId: string } | { kind: 'local'; file: File };
|
||||
}
|
||||
|
||||
interface AttachmentLightboxProps {
|
||||
@@ -36,11 +35,13 @@ interface AttachmentLightboxProps {
|
||||
* Return `"lightbox"` for mime types that preview in-app, `"external"` otherwise.
|
||||
* Callers use this to decide whether to open the lightbox or hand off to the OS.
|
||||
*/
|
||||
export function getAttachmentHandler(mimeType: string): "lightbox" | "external" {
|
||||
if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) {
|
||||
return "lightbox";
|
||||
export function getAttachmentHandler(
|
||||
mimeType: string,
|
||||
): 'lightbox' | 'external' {
|
||||
if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) {
|
||||
return 'lightbox';
|
||||
}
|
||||
return "external";
|
||||
return 'external';
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string | null {
|
||||
@@ -62,44 +63,39 @@ export function AttachmentLightbox({
|
||||
: null;
|
||||
const isOpen = current !== null;
|
||||
const hasMultiple = items.length > 1;
|
||||
const positionIndicator = openIndex !== null ? openIndex + 1 : null;
|
||||
|
||||
// Remote items resolve through the signed-URL cache; disabled when not remote.
|
||||
const remoteObjectId =
|
||||
current?.source.kind === "remote" ? current.source.objectId : undefined;
|
||||
current?.source.kind === 'remote' ? current.source.objectId : undefined;
|
||||
const { data: remoteUrl, isLoading: isRemoteLoading } =
|
||||
useDownloadUrl(remoteObjectId);
|
||||
|
||||
// Local items get a fresh blob URL per item, revoked on change/close.
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (current?.source.kind !== "local") {
|
||||
setLocalUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(current.source.file);
|
||||
setLocalUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [current?.id, current?.source.kind]);
|
||||
// Local items resolve to a blob URL; remote items use the signed-URL cache.
|
||||
const localFile =
|
||||
current?.source.kind === 'local' ? current.source.file : null;
|
||||
const localUrl = useObjectUrl(localFile);
|
||||
|
||||
const url =
|
||||
current?.source.kind === "remote"
|
||||
? remoteUrl ?? null
|
||||
: localUrl;
|
||||
current?.source.kind === 'remote' ? (remoteUrl ?? null) : localUrl;
|
||||
|
||||
const canDownload = current?.source.kind === "remote" && !!url;
|
||||
const canDownload = current?.source.kind === 'remote' && !!url;
|
||||
|
||||
const goTo = (delta: number) => {
|
||||
if (openIndex === null || items.length === 0) return;
|
||||
const next = (openIndex + delta + items.length) % items.length;
|
||||
onOpenChange(next);
|
||||
};
|
||||
const goTo = useCallback(
|
||||
(delta: number) => {
|
||||
if (openIndex === null || items.length === 0) return;
|
||||
const next = (openIndex + delta + items.length) % items.length;
|
||||
onOpenChange(next);
|
||||
},
|
||||
[openIndex, items.length, onOpenChange],
|
||||
);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!current || !url || current.source.kind !== "remote") return;
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!current || !url || current.source.kind !== 'remote') return;
|
||||
platform.attachment.download(url, current.filename);
|
||||
};
|
||||
}, [current, url]);
|
||||
|
||||
const handleRemove = () => {
|
||||
const handleRemove = useCallback(() => {
|
||||
if (!current || !onRemove) return;
|
||||
const wasLast = items.length <= 1;
|
||||
const wasAtEnd = openIndex === items.length - 1;
|
||||
@@ -107,12 +103,12 @@ export function AttachmentLightbox({
|
||||
if (wasLast) {
|
||||
onOpenChange(null);
|
||||
} else if (wasAtEnd) {
|
||||
onOpenChange(openIndex! - 1);
|
||||
onOpenChange(items.length - 2);
|
||||
}
|
||||
// Otherwise openIndex stays — the next item shifts into its place.
|
||||
};
|
||||
}, [current, onRemove, items.length, openIndex, onOpenChange]);
|
||||
|
||||
useSuspendPlayback(isOpen, "attachment-lightbox");
|
||||
useSuspendPlayback(isOpen, 'attachment-lightbox');
|
||||
|
||||
// Keyboard handling — only listens while open. Registered in the capture
|
||||
// phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫)
|
||||
@@ -123,8 +119,8 @@ export function AttachmentLightbox({
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
@@ -133,29 +129,38 @@ export function AttachmentLightbox({
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
consume();
|
||||
onOpenChange(null);
|
||||
} else if (e.key === "ArrowLeft" && hasMultiple) {
|
||||
} else if (e.key === 'ArrowLeft' && hasMultiple) {
|
||||
consume();
|
||||
goTo(-1);
|
||||
} else if (e.key === "ArrowRight" && hasMultiple) {
|
||||
} else if (e.key === 'ArrowRight' && hasMultiple) {
|
||||
consume();
|
||||
goTo(1);
|
||||
} else if ((e.key === "d" || e.key === "D") && canDownload) {
|
||||
} else if ((e.key === 'd' || e.key === 'D') && canDownload) {
|
||||
consume();
|
||||
handleDownload();
|
||||
} else if ((e.key === "Backspace" || e.key === "Delete") && onRemove) {
|
||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && onRemove) {
|
||||
consume();
|
||||
handleRemove();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handle, true);
|
||||
return () => window.removeEventListener("keydown", handle, true);
|
||||
}, [isOpen, openIndex, items, url, onOpenChange, onRemove, hasMultiple, canDownload]);
|
||||
window.addEventListener('keydown', handle, true);
|
||||
return () => window.removeEventListener('keydown', handle, true);
|
||||
}, [
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onRemove,
|
||||
hasMultiple,
|
||||
canDownload,
|
||||
goTo,
|
||||
handleDownload,
|
||||
handleRemove,
|
||||
]);
|
||||
|
||||
const isImage = current?.mimeType.startsWith("image/");
|
||||
const isVideo = current?.mimeType.startsWith("video/");
|
||||
const isImage = current?.mimeType.startsWith('image/');
|
||||
const isVideo = current?.mimeType.startsWith('video/');
|
||||
const sizeLabel = formatSize(current?.sizeBytes);
|
||||
|
||||
return (
|
||||
@@ -187,7 +192,7 @@ export function AttachmentLightbox({
|
||||
)}
|
||||
{hasMultiple && (
|
||||
<span className="border-l border-white/10 pl-2 text-xs text-white/40">
|
||||
{openIndex! + 1} / {items.length}
|
||||
{positionIndicator} / {items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,7 +283,9 @@ export function AttachmentLightbox({
|
||||
{url && !isImage && !isVideo && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-8 py-6 backdrop-blur-xl">
|
||||
<FileIcon className="size-12 text-white/50" />
|
||||
<span className="text-sm text-white/80">{current.filename}</span>
|
||||
<span className="text-sm text-white/80">
|
||||
{current.filename}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { H3, Muted } from "@/components/ui/typography";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { H3, Muted } from '@/components/ui/typography';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
interface CodeStepProps {
|
||||
email: string;
|
||||
@@ -11,7 +11,7 @@ interface CodeStepProps {
|
||||
}
|
||||
|
||||
export function CodeStep({ email, onBack }: CodeStepProps) {
|
||||
const [code, setCode] = useState("");
|
||||
const [code, setCode] = useState('');
|
||||
const isSigningIn = useAuthStore((s) => s.isSigningIn);
|
||||
const error = useAuthStore((s) => s.error);
|
||||
const signIn = useAuthStore((s) => s.signIn);
|
||||
@@ -31,7 +31,8 @@ export function CodeStep({ email, onBack }: CodeStepProps) {
|
||||
<div className="flex flex-col gap-2">
|
||||
<H3>Check your email</H3>
|
||||
<Muted>
|
||||
We sent a code to <strong className="text-foreground">{email}</strong>.
|
||||
We sent a code to <strong className="text-foreground">{email}</strong>
|
||||
.
|
||||
</Muted>
|
||||
</div>
|
||||
|
||||
@@ -52,13 +53,11 @@ export function CodeStep({ email, onBack }: CodeStepProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" disabled={isSigningIn || !code}>
|
||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||
{isSigningIn ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onBack}>
|
||||
Back
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { H3, Muted } from "@/components/ui/typography";
|
||||
import { PRIVACY_URL, TERMS_URL } from "@/lib/constants";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { H3, Muted } from '@/components/ui/typography';
|
||||
import { PRIVACY_URL, TERMS_URL } from '@/lib/constants';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
interface EmailStepProps {
|
||||
onCodeSent: (email: string) => void;
|
||||
}
|
||||
|
||||
export function EmailStep({ onCodeSent }: EmailStepProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [email, setEmail] = useState('');
|
||||
const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
|
||||
const error = useAuthStore((s) => s.error);
|
||||
const requestCode = useAuthStore((s) => s.requestCode);
|
||||
@@ -51,24 +51,22 @@ export function EmailStep({ onCodeSent }: EmailStepProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Button type="submit" disabled={isRequestingCode || !email}>
|
||||
{isRequestingCode ? "Sending..." : "Continue"}
|
||||
{isRequestingCode ? 'Sending...' : 'Continue'}
|
||||
</Button>
|
||||
|
||||
<Muted className="text-center text-xs">
|
||||
By continuing, you agree to our{" "}
|
||||
By continuing, you agree to our{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => platform.link.openExternal(TERMS_URL)}
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Terms of Service
|
||||
</button>{" "}
|
||||
and{" "}
|
||||
</button>{' '}
|
||||
and{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => platform.link.openExternal(PRIVACY_URL)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { EmailStep } from "./email-step";
|
||||
import { CodeStep } from "./code-step";
|
||||
import { useState } from 'react';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { EmailStep } from './email-step';
|
||||
import { CodeStep } from './code-step';
|
||||
|
||||
type Step = "email" | "code";
|
||||
type Step = 'email' | 'code';
|
||||
|
||||
export function LoginPage() {
|
||||
const [step, setStep] = useState<Step>("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [step, setStep] = useState<Step>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -16,18 +16,15 @@ export function LoginPage() {
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{step === "email" ? (
|
||||
{step === 'email' ? (
|
||||
<EmailStep
|
||||
onCodeSent={(submittedEmail) => {
|
||||
setEmail(submittedEmail);
|
||||
setStep("code");
|
||||
setStep('code');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CodeStep
|
||||
email={email}
|
||||
onBack={() => setStep("email")}
|
||||
/>
|
||||
<CodeStep email={email} onBack={() => setStep('email')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LinkPreviewEntry } from "@/hooks/use-link-metadata";
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FileIcon, Globe, Loader2, Plus, X } from 'lucide-react';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LinkPreviewEntry } from '@/hooks/use-link-metadata';
|
||||
import {
|
||||
AttachmentLightbox,
|
||||
getAttachmentHandler,
|
||||
type AttachmentItem,
|
||||
} from "@/features/attachments/attachment-lightbox";
|
||||
import { platform } from "@/lib/platform";
|
||||
} from '@/features/attachments/attachment-lightbox';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
file: File;
|
||||
thumbnailUrl?: string;
|
||||
status: "pending" | "uploading" | "uploaded" | "error";
|
||||
status: 'pending' | 'uploading' | 'uploaded' | 'error';
|
||||
}
|
||||
|
||||
interface AttachmentStripProps {
|
||||
@@ -29,9 +29,9 @@ function pendingToItem(p: PendingAttachment): AttachmentItem {
|
||||
return {
|
||||
id: p.id,
|
||||
filename: p.file.name,
|
||||
mimeType: p.file.type || "application/octet-stream",
|
||||
mimeType: p.file.type || 'application/octet-stream',
|
||||
sizeBytes: p.file.size,
|
||||
source: { kind: "local", file: p.file },
|
||||
source: { kind: 'local', file: p.file },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,20 +50,20 @@ function AttachmentThumbnail({
|
||||
onRemove: () => void;
|
||||
onPreview?: () => void;
|
||||
}) {
|
||||
const isImage = attachment.file.type.startsWith("image/");
|
||||
const isUploading = attachment.status === "uploading";
|
||||
const isError = attachment.status === "error";
|
||||
const previewable = getAttachmentHandler(attachment.file.type) === "lightbox";
|
||||
const isImage = attachment.file.type.startsWith('image/');
|
||||
const isUploading = attachment.status === 'uploading';
|
||||
const isError = attachment.status === 'error';
|
||||
const previewable = getAttachmentHandler(attachment.file.type) === 'lightbox';
|
||||
|
||||
return (
|
||||
<div
|
||||
role={previewable ? "button" : undefined}
|
||||
role={previewable ? 'button' : undefined}
|
||||
tabIndex={previewable ? 0 : undefined}
|
||||
onClick={previewable && onPreview ? onPreview : undefined}
|
||||
className={cn(
|
||||
"group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10",
|
||||
previewable && "cursor-pointer",
|
||||
isError && "ring-1 ring-red-400/50",
|
||||
'group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10',
|
||||
previewable && 'cursor-pointer',
|
||||
isError && 'ring-1 ring-red-400/50',
|
||||
)}
|
||||
>
|
||||
{isImage && attachment.thumbnailUrl ? (
|
||||
@@ -131,7 +131,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||
alt=""
|
||||
className="size-3 rounded-sm"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -158,7 +158,10 @@ export function AttachmentStrip({
|
||||
|
||||
// Lightbox state — only previewable attachments go in.
|
||||
const previewable = useMemo(
|
||||
() => attachments.filter((a) => getAttachmentHandler(a.file.type) === "lightbox"),
|
||||
() =>
|
||||
attachments.filter(
|
||||
(a) => getAttachmentHandler(a.file.type) === 'lightbox',
|
||||
),
|
||||
[attachments],
|
||||
);
|
||||
const items = useMemo(() => previewable.map(pendingToItem), [previewable]);
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||
import { QuotaExceededError } from "@/lib/errors";
|
||||
import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage } from "@/hooks/use-network-usage";
|
||||
import { useRecorder } from "@/features/compose/use-recorder";
|
||||
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
|
||||
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
||||
import { ScreenSourcePicker } from "@/components/screen-source-picker";
|
||||
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
||||
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||
import { useMediaDevicesStore } from "@/stores/media-devices-store";
|
||||
import { useMediaDevices } from "@/hooks/use-media-devices";
|
||||
import { resolveEffectiveDeviceId } from "@/hooks/use-effective-device-id";
|
||||
import { useFileInput } from "@/hooks/use-file-input";
|
||||
import { createImageThumbnail } from "@/lib/image-thumbnail";
|
||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import {
|
||||
useCreateParticle,
|
||||
useCreateStreamParticle,
|
||||
} from '@/hooks/use-create-particle';
|
||||
import { QuotaExceededError } from '@/lib/errors';
|
||||
import {
|
||||
isUsageExhausted,
|
||||
useInvalidateNetworkUsage,
|
||||
useNetworkUsage,
|
||||
} from '@/hooks/use-network-usage';
|
||||
import { useRecorder } from '@/features/compose/use-recorder';
|
||||
import { useScreenRecorder } from '@/features/compose/use-screen-recorder';
|
||||
import { particlePath, parseParticlePath } from '@/lib/particle-path';
|
||||
import type { ParticlePath } from '@/lib/particle-path';
|
||||
import { RecordingOverlay } from '@/features/compose/recording-overlay';
|
||||
import { ScreenSourcePicker } from '@/components/screen-source-picker';
|
||||
import { TextComposeStep } from '@/features/compose/text-compose-step';
|
||||
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
||||
import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
|
||||
import { useFileInput } from '@/hooks/use-file-input';
|
||||
import { createImageThumbnail } from '@/lib/image-thumbnail';
|
||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants';
|
||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { requireDesktop } from '@/lib/platform/desktop-only';
|
||||
|
||||
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
||||
export type ComposeStep =
|
||||
| 'idle'
|
||||
| 'picking'
|
||||
| 'recording'
|
||||
| 'reviewing'
|
||||
| 'typing'
|
||||
| 'configuring'
|
||||
| 'submitting';
|
||||
|
||||
type RecordingSource = "media" | "screen";
|
||||
type RecordingSource = 'media' | 'screen';
|
||||
|
||||
interface ComposeOverlayProps {
|
||||
networkId: string;
|
||||
@@ -41,7 +55,6 @@ interface ComposeOverlayProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
const HOLD_THRESHOLD_MS = 250;
|
||||
|
||||
/**
|
||||
@@ -56,16 +69,17 @@ export function ComposeOverlay({
|
||||
onParticleCreated,
|
||||
disabled,
|
||||
}: ComposeOverlayProps) {
|
||||
const [step, setStep] = useState<ComposeStep>("idle");
|
||||
const [step, setStep] = useState<ComposeStep>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [textContent, setTextContent] = useState("");
|
||||
const [textContent, setTextContent] = useState('');
|
||||
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
|
||||
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
|
||||
const [reviewDurationMs, setReviewDurationMs] = useState(0);
|
||||
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
|
||||
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media");
|
||||
const [recordingSource, setRecordingSource] =
|
||||
useState<RecordingSource>('media');
|
||||
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const savedMic = useMediaDevicesStore((s) => s.mic);
|
||||
@@ -80,87 +94,95 @@ export function ComposeOverlay({
|
||||
const invalidateUsage = useInvalidateNetworkUsage();
|
||||
const quotaExhausted = isUsageExhausted(usage);
|
||||
|
||||
// Refs for synchronous reads in keyboard handlers
|
||||
// Latest props/state for synchronous reads in keyboard handlers.
|
||||
const stepRef = useRef(step);
|
||||
const recordStartRef = useRef(0);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||
quotaExhaustedRef.current = quotaExhausted;
|
||||
const recordingSourceRef = useRef(recordingSource);
|
||||
recordingSourceRef.current = recordingSource;
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled;
|
||||
quotaExhaustedRef.current = quotaExhausted;
|
||||
recordingSourceRef.current = recordingSource;
|
||||
}, [disabled, quotaExhausted, recordingSource]);
|
||||
|
||||
const setStepSync = useCallback((next: ComposeStep) => {
|
||||
stepRef.current = next;
|
||||
setStep(next);
|
||||
}, []);
|
||||
|
||||
useSuspendPlayback(step !== "idle", "compose");
|
||||
useSuspendPlayback(step !== 'idle', 'compose');
|
||||
|
||||
// Notify parent when active state changes
|
||||
useEffect(() => {
|
||||
onActiveChange?.(step !== "idle");
|
||||
onActiveChange?.(step !== 'idle');
|
||||
onStepChange?.(step);
|
||||
// Refresh quota when the overlay activates — user is about to send, so
|
||||
// we want the most accurate count before the client-side gate kicks in.
|
||||
if (step !== "idle") {
|
||||
if (step !== 'idle') {
|
||||
void invalidateUsage(networkId);
|
||||
}
|
||||
}, [step, onActiveChange, onStepChange, invalidateUsage, networkId]);
|
||||
|
||||
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
||||
for (const a of items) {
|
||||
if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl);
|
||||
}
|
||||
}, []);
|
||||
const revokeAttachmentThumbnails = useCallback(
|
||||
(items: PendingAttachment[]) => {
|
||||
for (const a of items) {
|
||||
if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setStepSync("idle");
|
||||
setStepSync('idle');
|
||||
setError(null);
|
||||
setTextContent("");
|
||||
setMediaStream(null);
|
||||
setReviewBlob(null);
|
||||
setReviewDurationMs(0);
|
||||
setReviewMimeType(null);
|
||||
setRecordingSource("media");
|
||||
setRecordingSource('media');
|
||||
setAttachments((prev) => {
|
||||
revokeAttachmentThumbnails(prev);
|
||||
return [];
|
||||
});
|
||||
}, [setStepSync, revokeAttachmentThumbnails]);
|
||||
|
||||
const addAttachments = useCallback(async (files: File[]) => {
|
||||
const currentCount = attachments.length;
|
||||
const available = MAX_ATTACHMENTS - currentCount;
|
||||
if (available <= 0) {
|
||||
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`);
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = files.slice(0, available);
|
||||
if (accepted.length < files.length) {
|
||||
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments — ${files.length - accepted.length} skipped`);
|
||||
}
|
||||
|
||||
const newAttachments: PendingAttachment[] = [];
|
||||
for (const file of accepted) {
|
||||
if (file.size > MAX_ATTACHMENT_SIZE_BYTES) {
|
||||
toast.error(`${file.name} is too large (max 25 MB)`);
|
||||
continue;
|
||||
const addAttachments = useCallback(
|
||||
async (files: File[]) => {
|
||||
const available = MAX_ATTACHMENTS - attachments.length;
|
||||
if (available <= 0) {
|
||||
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`);
|
||||
return;
|
||||
}
|
||||
const thumbnailUrl = await createImageThumbnail(file);
|
||||
newAttachments.push({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
thumbnailUrl,
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
if (newAttachments.length > 0) {
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
}
|
||||
}, [attachments.length]);
|
||||
const accepted = files.slice(0, available);
|
||||
if (accepted.length < files.length) {
|
||||
toast.error(
|
||||
`Maximum ${MAX_ATTACHMENTS} attachments — ${files.length - accepted.length} skipped`,
|
||||
);
|
||||
}
|
||||
|
||||
const newAttachments: PendingAttachment[] = [];
|
||||
for (const file of accepted) {
|
||||
if (file.size > MAX_ATTACHMENT_SIZE_BYTES) {
|
||||
toast.error(`${file.name} is too large (max 25 MB)`);
|
||||
continue;
|
||||
}
|
||||
const thumbnailUrl = await createImageThumbnail(file);
|
||||
newAttachments.push({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
thumbnailUrl,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
if (newAttachments.length > 0) {
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
}
|
||||
},
|
||||
[attachments.length],
|
||||
);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => {
|
||||
@@ -172,7 +194,7 @@ export function ComposeOverlay({
|
||||
|
||||
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
|
||||
onFilesSelected: addAttachments,
|
||||
enabled: step === "typing" || step === "reviewing",
|
||||
enabled: step === 'typing' || step === 'reviewing',
|
||||
});
|
||||
|
||||
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
||||
@@ -182,7 +204,7 @@ export function ComposeOverlay({
|
||||
onStreamReady: (stream) => setMediaStream(stream),
|
||||
onStreamCleanup: () => setMediaStream(null),
|
||||
onFinish: (blob, durationMs, mimeType) => {
|
||||
setStepSync("reviewing");
|
||||
setStepSync('reviewing');
|
||||
setReviewBlob(blob);
|
||||
setReviewDurationMs(durationMs);
|
||||
setReviewMimeType(mimeType);
|
||||
@@ -197,7 +219,7 @@ export function ComposeOverlay({
|
||||
} = useScreenRecorder({
|
||||
micDeviceId,
|
||||
onFinish: (blob, durationMs, mimeType) => {
|
||||
setStepSync("reviewing");
|
||||
setStepSync('reviewing');
|
||||
setReviewBlob(blob);
|
||||
setReviewDurationMs(durationMs);
|
||||
setReviewMimeType(mimeType);
|
||||
@@ -212,7 +234,7 @@ export function ComposeOverlay({
|
||||
|
||||
const uploadMedia = useCallback(
|
||||
async (blob: Blob, mimeType: string) => {
|
||||
const ext = "webm";
|
||||
const ext = 'webm';
|
||||
const fileName = `recording-${Date.now()}.${ext}`;
|
||||
|
||||
const { object_id, upload_url, upload_headers } =
|
||||
@@ -224,7 +246,7 @@ export function ComposeOverlay({
|
||||
});
|
||||
|
||||
await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
headers: upload_headers,
|
||||
body: blob,
|
||||
});
|
||||
@@ -242,12 +264,12 @@ export function ComposeOverlay({
|
||||
await apiClient.prepareUpload({
|
||||
network_id: networkId,
|
||||
name: file.name,
|
||||
content_type: file.type || "application/octet-stream",
|
||||
content_type: file.type || 'application/octet-stream',
|
||||
content_length: file.size,
|
||||
});
|
||||
|
||||
await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
headers: upload_headers,
|
||||
body: file,
|
||||
});
|
||||
@@ -270,7 +292,9 @@ export function ComposeOverlay({
|
||||
attachments.map(async (attachment) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === attachment.id ? { ...a, status: "uploading" as const } : a,
|
||||
a.id === attachment.id
|
||||
? { ...a, status: 'uploading' as const }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -278,11 +302,11 @@ export function ComposeOverlay({
|
||||
|
||||
await createParticle.mutateAsync({
|
||||
path: childrenPath,
|
||||
type: "file",
|
||||
type: 'file',
|
||||
properties: {
|
||||
object_id,
|
||||
filename: attachment.file.name,
|
||||
mime_type: attachment.file.type || "application/octet-stream",
|
||||
mime_type: attachment.file.type || 'application/octet-stream',
|
||||
size_bytes: attachment.file.size,
|
||||
},
|
||||
createdByHumanId: userId,
|
||||
@@ -290,9 +314,11 @@ export function ComposeOverlay({
|
||||
}),
|
||||
);
|
||||
|
||||
const failed = results.filter((r) => r.status === "rejected");
|
||||
const failed = results.filter((r) => r.status === 'rejected');
|
||||
if (failed.length > 0) {
|
||||
toast.error(`${failed.length} attachment${failed.length > 1 ? "s" : ""} failed to upload`);
|
||||
toast.error(
|
||||
`${failed.length} attachment${failed.length > 1 ? 's' : ''} failed to upload`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[attachments, userId, uploadFile, createParticle],
|
||||
@@ -306,7 +332,7 @@ export function ComposeOverlay({
|
||||
if (textContent.trim()) {
|
||||
particleId = await createParticle.mutateAsync({
|
||||
path,
|
||||
type: "text",
|
||||
type: 'text',
|
||||
properties: { content: textContent },
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
@@ -316,17 +342,20 @@ export function ComposeOverlay({
|
||||
reviewMimeType,
|
||||
);
|
||||
|
||||
const isAudioOnly = reviewMimeType.startsWith("audio/");
|
||||
const isAudioOnly = reviewMimeType.startsWith('audio/');
|
||||
particleId = await createParticle.mutateAsync({
|
||||
path,
|
||||
type: "media",
|
||||
type: 'media',
|
||||
properties: {
|
||||
object_id,
|
||||
mime_type: reviewMimeType,
|
||||
duration_ms: reviewDurationMs,
|
||||
size_bytes,
|
||||
...(!isAudioOnly && {
|
||||
source: recordingSource === "screen" ? "screen" as const : "camera" as const,
|
||||
source:
|
||||
recordingSource === 'screen'
|
||||
? ('screen' as const)
|
||||
: ('camera' as const),
|
||||
}),
|
||||
},
|
||||
createdByHumanId: userId,
|
||||
@@ -348,36 +377,49 @@ export function ComposeOverlay({
|
||||
createParticle,
|
||||
uploadMedia,
|
||||
uploadAttachments,
|
||||
onParticleCreated
|
||||
onParticleCreated,
|
||||
],
|
||||
);
|
||||
|
||||
const handleQuotaError = useCallback((err: unknown): boolean => {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
toast.error("Daily message limit reached. Upgrade to Pro to keep sending.");
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [cancel]);
|
||||
const handleQuotaError = useCallback(
|
||||
(err: unknown): boolean => {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
toast.error(
|
||||
'Daily message limit reached. Upgrade to Pro to keep sending.',
|
||||
);
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[cancel],
|
||||
);
|
||||
|
||||
// Reply mode: create particle directly under targetPath
|
||||
const onSubmitReply = useEffectEvent(async () => {
|
||||
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
||||
setStepSync("submitting");
|
||||
// Reply mode: create particle directly under targetPath.
|
||||
const onSubmitReply = useCallback(async () => {
|
||||
if (!targetPath || !userId || stepRef.current === 'submitting') return;
|
||||
setStepSync('submitting');
|
||||
try {
|
||||
await createChildParticle(targetPath);
|
||||
cancel();
|
||||
setTextContent('');
|
||||
} catch (err) {
|
||||
if (!handleQuotaError(err)) throw err;
|
||||
}
|
||||
});
|
||||
}, [
|
||||
targetPath,
|
||||
userId,
|
||||
setStepSync,
|
||||
createChildParticle,
|
||||
cancel,
|
||||
handleQuotaError
|
||||
]);
|
||||
|
||||
// New stream mode: create stream + first child
|
||||
const handleStreamSubmit = useCallback(
|
||||
async (streamName: string, visibleTo: string[]) => {
|
||||
if (!userId || stepRef.current === "submitting") return;
|
||||
setStepSync("submitting");
|
||||
if (!userId || stepRef.current === 'submitting') return;
|
||||
setStepSync('submitting');
|
||||
|
||||
try {
|
||||
const streamId = await createStream.mutateAsync({
|
||||
@@ -391,13 +433,21 @@ export function ComposeOverlay({
|
||||
|
||||
const streamChildrenPath = particlePath(networkId, [streamId]);
|
||||
await createChildParticle(streamChildrenPath);
|
||||
|
||||
cancel();
|
||||
setTextContent('');
|
||||
} catch (err) {
|
||||
if (!handleQuotaError(err)) throw err;
|
||||
}
|
||||
},
|
||||
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
|
||||
[
|
||||
networkId,
|
||||
userId,
|
||||
createStream,
|
||||
createChildParticle,
|
||||
cancel,
|
||||
handleQuotaError,
|
||||
setStepSync,
|
||||
],
|
||||
);
|
||||
|
||||
// --- Compose intent handlers ---
|
||||
@@ -406,13 +456,15 @@ export function ComposeOverlay({
|
||||
// guards (disabled, quota) and screen-vs-media branching live in one place.
|
||||
|
||||
const guardIdle = useCallback((): boolean => {
|
||||
if (stepRef.current !== "idle") return false;
|
||||
if (stepRef.current !== 'idle') return false;
|
||||
if (disabledRef.current) {
|
||||
toast.info("This stream is closed");
|
||||
toast.info('This stream is closed');
|
||||
return false;
|
||||
}
|
||||
if (quotaExhaustedRef.current) {
|
||||
toast.info("Daily message limit reached. Upgrade to Pro to keep sending.");
|
||||
toast.info(
|
||||
'Daily message limit reached. Upgrade to Pro to keep sending.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -421,19 +473,19 @@ export function ComposeOverlay({
|
||||
const handleRecordIntent = useCallback(() => {
|
||||
if (!guardIdle()) return;
|
||||
recordStartRef.current = Date.now();
|
||||
setRecordingSource("media");
|
||||
setStepSync("recording");
|
||||
setRecordingSource('media');
|
||||
setStepSync('recording');
|
||||
startRecording();
|
||||
}, [guardIdle, setStepSync, startRecording]);
|
||||
|
||||
const handleTextIntent = useCallback(() => {
|
||||
if (!guardIdle()) return;
|
||||
setStepSync("typing");
|
||||
setStepSync('typing');
|
||||
}, [guardIdle, setStepSync]);
|
||||
|
||||
const handleStopIntent = useCallback(() => {
|
||||
if (stepRef.current !== "recording") return;
|
||||
if (recordingSourceRef.current === "screen") {
|
||||
if (stepRef.current !== 'recording') return;
|
||||
if (recordingSourceRef.current === 'screen') {
|
||||
stopScreenRecording();
|
||||
} else {
|
||||
stopRecording();
|
||||
@@ -442,24 +494,24 @@ export function ComposeOverlay({
|
||||
|
||||
const handleCancelIntent = useCallback(() => {
|
||||
const s = stepRef.current;
|
||||
if (s === "recording" || s === "reviewing") {
|
||||
if (recordingSourceRef.current === "screen") {
|
||||
if (s === 'recording' || s === 'reviewing') {
|
||||
if (recordingSourceRef.current === 'screen') {
|
||||
cancelScreenRecording();
|
||||
} else {
|
||||
cancelRecording();
|
||||
}
|
||||
cancel();
|
||||
} else if (s === "typing" || s === "configuring" || s === "picking") {
|
||||
} else if (s === 'typing' || s === 'configuring' || s === 'picking') {
|
||||
cancel();
|
||||
}
|
||||
}, [cancel, cancelRecording, cancelScreenRecording]);
|
||||
|
||||
const handleSendIntent = useCallback(() => {
|
||||
if (stepRef.current !== "reviewing") return;
|
||||
if (stepRef.current !== 'reviewing') return;
|
||||
if (targetPath) {
|
||||
onSubmitReply();
|
||||
} else {
|
||||
setStepSync("configuring");
|
||||
setStepSync('configuring');
|
||||
}
|
||||
}, [targetPath, onSubmitReply, setStepSync]);
|
||||
|
||||
@@ -468,20 +520,42 @@ export function ComposeOverlay({
|
||||
// executes the matching handler and clears the intent. Keyboard handlers
|
||||
// call the same handlers directly without a store round-trip.
|
||||
|
||||
const intent = useComposeIntentStore((s) => s.intent);
|
||||
const clearIntent = useComposeIntentStore((s) => s.clear);
|
||||
|
||||
// Consume fire-and-forget intents from the external store. Reacting in the
|
||||
// store subscription (not an effect body) keeps these state-updating handlers
|
||||
// off the render path and avoids an extra dispatch→render bounce.
|
||||
useEffect(() => {
|
||||
if (!intent) return;
|
||||
switch (intent.kind) {
|
||||
case "record": handleRecordIntent(); break;
|
||||
case "text": handleTextIntent(); break;
|
||||
case "stop": handleStopIntent(); break;
|
||||
case "cancel": handleCancelIntent(); break;
|
||||
case "send": handleSendIntent(); break;
|
||||
}
|
||||
clearIntent();
|
||||
}, [intent, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
|
||||
return useComposeIntentStore.subscribe((state, prev) => {
|
||||
const intent = state.intent;
|
||||
if (!intent || intent === prev.intent) return;
|
||||
switch (intent.kind) {
|
||||
case 'record':
|
||||
handleRecordIntent();
|
||||
break;
|
||||
case 'text':
|
||||
handleTextIntent();
|
||||
break;
|
||||
case 'stop':
|
||||
handleStopIntent();
|
||||
break;
|
||||
case 'cancel':
|
||||
handleCancelIntent();
|
||||
break;
|
||||
case 'send':
|
||||
handleSendIntent();
|
||||
break;
|
||||
}
|
||||
clearIntent();
|
||||
});
|
||||
}, [
|
||||
handleRecordIntent,
|
||||
handleTextIntent,
|
||||
handleStopIntent,
|
||||
handleCancelIntent,
|
||||
handleSendIntent,
|
||||
clearIntent,
|
||||
]);
|
||||
|
||||
// --- Keyboard handling ---
|
||||
|
||||
@@ -489,8 +563,12 @@ export function ComposeOverlay({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const currentStep = stepRef.current;
|
||||
|
||||
if (currentStep === "typing" || currentStep === "configuring" || currentStep === "picking") {
|
||||
if (e.key === "Escape") {
|
||||
if (
|
||||
currentStep === 'typing' ||
|
||||
currentStep === 'configuring' ||
|
||||
currentStep === 'picking'
|
||||
) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
@@ -499,52 +577,55 @@ export function ComposeOverlay({
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case "idle": {
|
||||
if (e.key === "`" && !e.repeat) {
|
||||
case 'idle': {
|
||||
if (e.key === '`' && !e.repeat) {
|
||||
e.preventDefault();
|
||||
handleRecordIntent();
|
||||
} else if (e.key === "s" || e.key === "S") {
|
||||
} else if (e.key === 's' || e.key === 'S') {
|
||||
e.preventDefault();
|
||||
if (!guardIdle()) break;
|
||||
if (!requireDesktop("Screen recording")) break;
|
||||
setRecordingSource("screen");
|
||||
setStepSync("picking");
|
||||
} else if (e.key === "t" || e.key === "T") {
|
||||
if (!requireDesktop('Screen recording')) break;
|
||||
setRecordingSource('screen');
|
||||
setStepSync('picking');
|
||||
} else if (e.key === 't' || e.key === 'T') {
|
||||
e.preventDefault();
|
||||
handleTextIntent();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "recording": {
|
||||
if (e.key === "`" && !e.repeat) {
|
||||
case 'recording': {
|
||||
if (e.key === '`' && !e.repeat) {
|
||||
// Second tap stops media recording (toggle mode)
|
||||
e.preventDefault();
|
||||
handleStopIntent();
|
||||
} else if ((e.key === "s" || e.key === "S") && recordingSourceRef.current === "screen") {
|
||||
} else if (
|
||||
(e.key === 's' || e.key === 'S') &&
|
||||
recordingSourceRef.current === 'screen'
|
||||
) {
|
||||
// S stops screen recording when main window is focused
|
||||
e.preventDefault();
|
||||
handleStopIntent();
|
||||
} else if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelIntent();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "reviewing": {
|
||||
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||
case 'reviewing': {
|
||||
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelIntent();
|
||||
} else if (e.key === "Enter") {
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSendIntent();
|
||||
}
|
||||
@@ -554,30 +635,46 @@ export function ComposeOverlay({
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (stepRef.current === "recording" && e.key === "`" && recordingSourceRef.current === "media") {
|
||||
if (
|
||||
stepRef.current === 'recording' &&
|
||||
e.key === '`' &&
|
||||
recordingSourceRef.current === 'media'
|
||||
) {
|
||||
e.preventDefault();
|
||||
// Only stop on release if held long enough (hold-to-record mode).
|
||||
// Quick taps are handled by the second keydown (toggle mode).
|
||||
if (recordStartRef.current > 0 && Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS) {
|
||||
if (
|
||||
recordStartRef.current > 0 &&
|
||||
Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS
|
||||
) {
|
||||
handleStopIntent();
|
||||
recordStartRef.current = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [cancel, setStepSync, guardIdle, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent]);
|
||||
}, [
|
||||
cancel,
|
||||
setStepSync,
|
||||
guardIdle,
|
||||
handleRecordIntent,
|
||||
handleTextIntent,
|
||||
handleStopIntent,
|
||||
handleCancelIntent,
|
||||
handleSendIntent,
|
||||
]);
|
||||
|
||||
// --- Screen source selection handler ---
|
||||
|
||||
const handleScreenSourceSelected = useCallback(
|
||||
(sourceId: string) => {
|
||||
setStepSync("recording");
|
||||
setStepSync('recording');
|
||||
startScreenRecording(sourceId);
|
||||
},
|
||||
[setStepSync, startScreenRecording],
|
||||
@@ -585,15 +682,15 @@ export function ComposeOverlay({
|
||||
|
||||
// --- Render ---
|
||||
|
||||
if (step === "idle") return null;
|
||||
if (step === 'idle') return null;
|
||||
|
||||
const handleTextAdvance = targetPath
|
||||
? onSubmitReply
|
||||
: () => setStepSync("configuring");
|
||||
: () => setStepSync('configuring');
|
||||
|
||||
return (
|
||||
<>
|
||||
{step === "picking" && (
|
||||
{step === 'picking' && (
|
||||
<ScreenSourcePicker
|
||||
title="Record your screen"
|
||||
confirmLabel="Record"
|
||||
@@ -602,24 +699,25 @@ export function ComposeOverlay({
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
{(step === "recording" || step === "reviewing") && recordingSource === "media" && (
|
||||
<RecordingOverlay
|
||||
step={step}
|
||||
mediaStream={mediaStream}
|
||||
recordingMode={recordingMode}
|
||||
reviewBlob={reviewBlob}
|
||||
error={error}
|
||||
onClose={cancel}
|
||||
attachments={attachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onAddFiles={openFilePicker}
|
||||
isDragging={isDragging}
|
||||
dropZoneProps={dropZoneProps}
|
||||
mirror={true}
|
||||
objectFit="cover"
|
||||
/>
|
||||
)}
|
||||
{step === "recording" && recordingSource === "screen" && (
|
||||
{(step === 'recording' || step === 'reviewing') &&
|
||||
recordingSource === 'media' && (
|
||||
<RecordingOverlay
|
||||
step={step}
|
||||
mediaStream={mediaStream}
|
||||
recordingMode={recordingMode}
|
||||
reviewBlob={reviewBlob}
|
||||
error={error}
|
||||
onClose={cancel}
|
||||
attachments={attachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onAddFiles={openFilePicker}
|
||||
isDragging={isDragging}
|
||||
dropZoneProps={dropZoneProps}
|
||||
mirror={true}
|
||||
objectFit="cover"
|
||||
/>
|
||||
)}
|
||||
{step === 'recording' && recordingSource === 'screen' && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
||||
<div className="absolute top-8 z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -636,7 +734,7 @@ export function ComposeOverlay({
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
S
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
stop
|
||||
</button>
|
||||
<button
|
||||
@@ -647,13 +745,13 @@ export function ComposeOverlay({
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Q
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === "reviewing" && recordingSource === "screen" && reviewBlob && (
|
||||
{step === 'reviewing' && recordingSource === 'screen' && reviewBlob && (
|
||||
<RecordingOverlay
|
||||
step="reviewing"
|
||||
mediaStream={null}
|
||||
@@ -670,7 +768,7 @@ export function ComposeOverlay({
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
{step === "typing" && (
|
||||
{step === 'typing' && (
|
||||
<TextComposeStep
|
||||
textContent={textContent}
|
||||
onTextChange={setTextContent}
|
||||
@@ -683,16 +781,18 @@ export function ComposeOverlay({
|
||||
dropZoneProps={dropZoneProps}
|
||||
/>
|
||||
)}
|
||||
{!targetPath && step === "configuring" && (
|
||||
{!targetPath && step === 'configuring' && (
|
||||
<ConfigureStreamStep
|
||||
networkId={networkId}
|
||||
onCancel={cancel}
|
||||
onSubmit={handleStreamSubmit}
|
||||
/>
|
||||
)}
|
||||
{step === "submitting" && (
|
||||
{step === 'submitting' && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
||||
<span className="animate-pulse text-sm text-white/60">Sending...</span>
|
||||
<span className="animate-pulse text-sm text-white/60">
|
||||
Sending...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
||||
import { useIsNetworkAdmin, useNetwork } from "@/hooks/use-networks";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNetworkUsage } from '@/hooks/use-network-usage';
|
||||
import { useIsNetworkAdmin, useNetwork } from '@/hooks/use-networks';
|
||||
|
||||
interface ComposeQuotaIndicatorProps {
|
||||
networkId: string;
|
||||
@@ -18,7 +18,9 @@ const SHOW_PROGRESS_AT_FRACTION = 0.7;
|
||||
*
|
||||
* Pro networks and any network still loading usage render nothing.
|
||||
*/
|
||||
export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) {
|
||||
export function ComposeQuotaIndicator({
|
||||
networkId,
|
||||
}: ComposeQuotaIndicatorProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: usage } = useNetworkUsage(networkId);
|
||||
const isAdmin = useIsNetworkAdmin(networkId);
|
||||
@@ -38,7 +40,8 @@ export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps)
|
||||
: `This network reached today's ${usage.limit}-message limit`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Resets {formatResetRelative(usage.reset_at)} ({formatResetAbsolute(usage.reset_at)})
|
||||
Resets {formatResetRelative(usage.reset_at)} (
|
||||
{formatResetAbsolute(usage.reset_at)})
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<Button
|
||||
@@ -49,10 +52,10 @@ export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps)
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Ask{" "}
|
||||
Ask{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{network?.admin_human.email_prefix ?? "your admin"}
|
||||
</span>{" "}
|
||||
{network?.admin_human.email_prefix ?? 'your admin'}
|
||||
</span>{' '}
|
||||
to upgrade to Pro
|
||||
</div>
|
||||
)}
|
||||
@@ -79,8 +82,8 @@ function formatResetRelative(resetAt: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = resetAt.getTime() - now.getTime();
|
||||
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
|
||||
if (hours < 1) return "soon";
|
||||
if (hours === 1) return "in 1 hour";
|
||||
if (hours < 1) return 'soon';
|
||||
if (hours === 1) return 'in 1 hour';
|
||||
return `in ${hours} hours`;
|
||||
}
|
||||
|
||||
@@ -88,7 +91,7 @@ function formatResetAbsolute(resetAt: Date): string {
|
||||
// Shows the user their local wall-clock time for the UTC-midnight reset,
|
||||
// so a user in UTC-8 sees "4:00 PM" instead of a relative hint alone.
|
||||
return resetAt.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { cn, removeDuplicates } from "@/lib/utils";
|
||||
import { metaKey } from "@/lib/platform";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { generateRandomName } from "@/lib/random-name";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNetworks } from '@/hooks/use-networks';
|
||||
import { cn, removeDuplicates } from '@/lib/utils';
|
||||
import { metaKey } from '@/lib/platform';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { generateRandomName } from '@/lib/random-name';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
interface ConfigureStreamStepProps {
|
||||
networkId: string | null;
|
||||
@@ -42,7 +42,9 @@ export function ConfigureStreamStep({
|
||||
const buildVisibleTo = useCallback((): string[] => {
|
||||
if (everyone && networkId) return [`network:${networkId}`];
|
||||
|
||||
return Array.from(removeDuplicates([...selectedIds, userId].filter(Boolean) as string[])).map((id) => `human:${id}`);
|
||||
return Array.from(
|
||||
removeDuplicates([...selectedIds, userId].filter(Boolean) as string[]),
|
||||
).map((id) => `human:${id}`);
|
||||
}, [everyone, networkId, selectedIds, userId]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
@@ -53,12 +55,12 @@ export function ConfigureStreamStep({
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
|
||||
case "Enter":
|
||||
case 'Enter':
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
@@ -100,8 +102,8 @@ export function ConfigureStreamStep({
|
||||
role="button"
|
||||
onClick={() => setEveryone((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
|
||||
"text-white/70 hover:bg-white/5",
|
||||
'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
||||
'text-white/70 hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
@@ -117,7 +119,7 @@ export function ConfigureStreamStep({
|
||||
{!everyone && members.length > 0 && (
|
||||
<ScrollArea className="max-h-48">
|
||||
<div className="space-y-0.5 p-1">
|
||||
{members.map((member, index) => {
|
||||
{members.map((member) => {
|
||||
const isSelected = selectedIds.has(member.id);
|
||||
const initials = member.email_prefix
|
||||
.slice(0, 2)
|
||||
@@ -129,8 +131,8 @@ export function ConfigureStreamStep({
|
||||
role="button"
|
||||
onClick={() => toggleMember(member.id)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors",
|
||||
"text-white/70 hover:bg-white/5",
|
||||
'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors',
|
||||
'text-white/70 hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
@@ -159,16 +161,16 @@ export function ConfigureStreamStep({
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
cancel
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
{metaKey}+Enter
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
create
|
||||
</span>
|
||||
</div>
|
||||
</div >
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@
|
||||
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
|
||||
--crepe-font-default: inherit;
|
||||
--crepe-font-title: inherit;
|
||||
--crepe-font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--crepe-font-code:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Glassy floating menus over content. */
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Crepe } from "@milkdown/crepe";
|
||||
import "@milkdown/crepe/theme/common/style.css";
|
||||
import "@milkdown/crepe/theme/frame-dark.css";
|
||||
import "./markdown-editor.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Crepe } from '@milkdown/crepe';
|
||||
import '@milkdown/crepe/theme/common/style.css';
|
||||
import '@milkdown/crepe/theme/frame-dark.css';
|
||||
import './markdown-editor.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
/** Initial markdown. The editor owns its content after mount; edits flow out
|
||||
@@ -61,7 +61,7 @@ export function MarkdownEditor({
|
||||
[Crepe.Feature.AI]: false,
|
||||
},
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: placeholder ?? "" },
|
||||
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export function MarkdownEditor({
|
||||
}
|
||||
created = crepe;
|
||||
if (autoFocus && !readOnly) {
|
||||
root.querySelector<HTMLElement>(".ProseMirror")?.focus();
|
||||
root.querySelector<HTMLElement>('.ProseMirror')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ export function MarkdownEditor({
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn("llink-crepe", !readOnly && "llink-crepe--fill", className)}
|
||||
className={cn('llink-crepe', !readOnly && 'llink-crepe--fill', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Paperclip } from 'lucide-react';
|
||||
import type { RecordingMode } from '@/hooks/use-recording-mode';
|
||||
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
|
||||
import { useAudioSource } from '@/components/audio/use-audio-source';
|
||||
import { useObjectUrl } from '@/hooks/use-object-url';
|
||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
|
||||
interface RecordingOverlayProps {
|
||||
step: "recording" | "reviewing";
|
||||
step: 'recording' | 'reviewing';
|
||||
mediaStream: MediaStream | null;
|
||||
recordingMode: RecordingMode;
|
||||
reviewBlob: Blob | null;
|
||||
@@ -29,7 +30,7 @@ interface RecordingOverlayProps {
|
||||
/** Mirror the video horizontally. Defaults to true (selfie-view for webcam). */
|
||||
mirror?: boolean;
|
||||
/** How video fills its container. Defaults to "cover". Use "contain" for screen recordings. */
|
||||
objectFit?: "cover" | "contain";
|
||||
objectFit?: 'cover' | 'contain';
|
||||
}
|
||||
|
||||
function RecordingTimer() {
|
||||
@@ -44,7 +45,7 @@ function RecordingTimer() {
|
||||
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
const seconds = elapsed % 60;
|
||||
const display = `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -58,30 +59,18 @@ function ReviewPlayback({
|
||||
blob,
|
||||
isVideo,
|
||||
mirror = true,
|
||||
objectFit = "cover",
|
||||
objectFit = 'cover',
|
||||
}: {
|
||||
blob: Blob;
|
||||
isVideo: boolean;
|
||||
mirror?: boolean;
|
||||
objectFit?: "cover" | "contain";
|
||||
objectFit?: 'cover' | 'contain';
|
||||
}) {
|
||||
const urlRef = useRef<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const objectUrl = useObjectUrl(blob);
|
||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
urlRef.current = url;
|
||||
setObjectUrl(url);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
urlRef.current = null;
|
||||
};
|
||||
}, [blob]);
|
||||
|
||||
if (!objectUrl) return null;
|
||||
|
||||
if (isVideo) {
|
||||
@@ -91,7 +80,7 @@ function ReviewPlayback({
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
className={`absolute inset-0 h-full w-full ${objectFit === "contain" ? "object-contain" : "object-cover"}${mirror ? " -scale-x-100" : ""}`}
|
||||
className={`absolute inset-0 h-full w-full ${objectFit === 'contain' ? 'object-contain' : 'object-cover'}${mirror ? ' -scale-x-100' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -129,14 +118,14 @@ export function RecordingOverlay({
|
||||
isDragging,
|
||||
dropZoneProps,
|
||||
mirror = true,
|
||||
objectFit = "cover",
|
||||
objectFit = 'cover',
|
||||
}: RecordingOverlayProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
||||
|
||||
// Set video srcObject for live preview
|
||||
useEffect(() => {
|
||||
if (videoRef.current && mediaStream && recordingMode === "video") {
|
||||
if (videoRef.current && mediaStream && recordingMode === 'video') {
|
||||
videoRef.current.srcObject = mediaStream;
|
||||
}
|
||||
}, [mediaStream, recordingMode]);
|
||||
@@ -148,16 +137,16 @@ export function RecordingOverlay({
|
||||
return () => clearTimeout(timeout);
|
||||
}, [error, onClose]);
|
||||
|
||||
const isReviewing = step === "reviewing";
|
||||
const isRecording = step === "recording";
|
||||
const isReviewing = step === 'reviewing';
|
||||
const isRecording = step === 'recording';
|
||||
const isLoading = isRecording && !mediaStream;
|
||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90",
|
||||
isReviewing && isDragging && "ring-2 ring-inset ring-white/30",
|
||||
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
|
||||
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
|
||||
)}
|
||||
{...(isReviewing ? dropZoneProps : {})}
|
||||
>
|
||||
@@ -165,15 +154,15 @@ export function RecordingOverlay({
|
||||
{isLoading && (
|
||||
<div className="z-10 flex flex-col items-center gap-2">
|
||||
<span className="animate-pulse text-sm text-white/60">
|
||||
{recordingMode === "video"
|
||||
? "Starting camera..."
|
||||
: "Starting mic..."}
|
||||
{recordingMode === 'video'
|
||||
? 'Starting camera...'
|
||||
: 'Starting mic...'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera preview (video mode, recording) */}
|
||||
{isRecording && recordingMode === "video" && mediaStream && (
|
||||
{isRecording && recordingMode === 'video' && mediaStream && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
@@ -187,7 +176,7 @@ export function RecordingOverlay({
|
||||
{isReviewing && reviewBlob && (
|
||||
<ReviewPlayback
|
||||
blob={reviewBlob}
|
||||
isVideo={recordingMode === "video"}
|
||||
isVideo={recordingMode === 'video'}
|
||||
mirror={mirror}
|
||||
objectFit={objectFit}
|
||||
/>
|
||||
@@ -221,28 +210,29 @@ export function RecordingOverlay({
|
||||
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("stop")}
|
||||
onClick={() => requestIntent('stop')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Finish recording (or release `)"
|
||||
>
|
||||
Release{" "}
|
||||
Release{' '}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
`
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to review
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("cancel")}
|
||||
onClick={() => requestIntent('cancel')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Discard recording (or press Esc / Q)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" or "}
|
||||
</kbd>
|
||||
{' or '}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Q
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -262,27 +252,28 @@ export function RecordingOverlay({
|
||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("send")}
|
||||
onClick={() => requestIntent('send')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Send (or press Enter)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
next
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("cancel")}
|
||||
onClick={() => requestIntent('cancel')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Discard (or press Esc / Q)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" or "}
|
||||
</kbd>
|
||||
{' or '}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Q
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to cancel
|
||||
</button>
|
||||
<span>
|
||||
@@ -299,17 +290,12 @@ export function RecordingOverlay({
|
||||
attach
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="z-10 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="z-10 text-sm text-red-400">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TextEditor } from "@/features/compose/text-editor";
|
||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||
import { TextEditor } from '@/features/compose/text-editor';
|
||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||
|
||||
interface TextComposeStepProps {
|
||||
textContent: string;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { metaKey } from "@/lib/platform";
|
||||
import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
|
||||
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownEditor } from "@/features/compose/markdown-editor";
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Paperclip } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { metaKey } from '@/lib/platform';
|
||||
import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
|
||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MarkdownEditor } from '@/features/compose/markdown-editor';
|
||||
|
||||
export interface TextEditorAttachmentProps {
|
||||
attachments: PendingAttachment[];
|
||||
@@ -35,9 +35,9 @@ interface TextEditorProps {
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function getImmersiveTextStyle(length: number) {
|
||||
if (length < 70) return { size: "text-5xl", weight: "font-semibold" };
|
||||
if (length < 130) return { size: "text-3xl", weight: "font-semibold" };
|
||||
return { size: "text-2xl", weight: "font-normal" };
|
||||
if (length < 70) return { size: 'text-5xl', weight: 'font-semibold' };
|
||||
if (length < 130) return { size: 'text-3xl', weight: 'font-semibold' };
|
||||
return { size: 'text-2xl', weight: 'font-normal' };
|
||||
}
|
||||
|
||||
export function TextEditor({
|
||||
@@ -45,7 +45,7 @@ export function TextEditor({
|
||||
onTextChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitHint = "next",
|
||||
submitHint = 'next',
|
||||
attachmentProps,
|
||||
}: TextEditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -61,7 +61,9 @@ export function TextEditor({
|
||||
const attachmentCount = attachmentProps?.attachments.length ?? 0;
|
||||
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
|
||||
const immersive =
|
||||
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
|
||||
textContent.length < IMMERSIVE_CHAR_LIMIT &&
|
||||
!hasEnrichments &&
|
||||
!forceCardMode;
|
||||
|
||||
// Keep the immersive textarea focused with the caret at the end when we
|
||||
// (re)enter it. The card-mode editor manages its own focus.
|
||||
@@ -81,15 +83,15 @@ export function TextEditor({
|
||||
// editor's own key handling (e.g. ⌘+Enter must submit, not insert a break).
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (textContent.trim()) onSubmit();
|
||||
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
|
||||
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setForceCardMode(true);
|
||||
@@ -112,20 +114,20 @@ export function TextEditor({
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
cancel
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
{metaKey}+Enter
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
{submitHint}
|
||||
</span>
|
||||
{immersive && (
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
{metaKey}+M
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
markdown
|
||||
</span>
|
||||
)}
|
||||
@@ -156,8 +158,8 @@ export function TextEditor({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
>
|
||||
@@ -169,7 +171,7 @@ export function TextEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
className={cn(
|
||||
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
||||
'w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none',
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
@@ -184,8 +186,8 @@ export function TextEditor({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { RecordingMode } from '@/hooks/use-recording-mode';
|
||||
|
||||
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
|
||||
const AUDIO_FALLBACK_MIME = "audio/webm";
|
||||
const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
|
||||
const VIDEO_FALLBACK_MIME = 'video/webm';
|
||||
const AUDIO_PREFERRED_MIME = 'audio/webm;codecs=opus';
|
||||
const AUDIO_FALLBACK_MIME = 'audio/webm';
|
||||
|
||||
function getMediaMime(mode: "video" | "audio"): string {
|
||||
if (mode === "audio") {
|
||||
function getMediaMime(mode: 'video' | 'audio'): string {
|
||||
if (mode === 'audio') {
|
||||
return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
|
||||
? AUDIO_PREFERRED_MIME
|
||||
: AUDIO_FALLBACK_MIME;
|
||||
@@ -36,7 +36,7 @@ function buildConstraints(
|
||||
? { deviceId: { exact: micDeviceId } }
|
||||
: true;
|
||||
|
||||
if (mode === "audio") return { audio };
|
||||
if (mode === 'audio') return { audio };
|
||||
|
||||
const video: MediaTrackConstraints = cameraDeviceId
|
||||
? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } }
|
||||
@@ -57,13 +57,13 @@ async function getStreamWithFallback(
|
||||
if (
|
||||
hasDeviceId &&
|
||||
err instanceof Error &&
|
||||
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
|
||||
(err.name === 'OverconstrainedError' || err.name === 'NotFoundError')
|
||||
) {
|
||||
const relaxed: MediaStreamConstraints = {
|
||||
audio: typeof constraints.audio === "object" ? true : constraints.audio,
|
||||
audio: typeof constraints.audio === 'object' ? true : constraints.audio,
|
||||
...(constraints.video !== undefined && {
|
||||
video:
|
||||
typeof constraints.video === "object"
|
||||
typeof constraints.video === 'object'
|
||||
? { aspectRatio: { ideal: 4 / 3 } }
|
||||
: constraints.video,
|
||||
}),
|
||||
@@ -143,13 +143,13 @@ export function useRecorder({
|
||||
} catch (err) {
|
||||
stopTracks();
|
||||
onErrorRef.current(
|
||||
err instanceof Error ? err.message : "Failed to start recording",
|
||||
err instanceof Error ? err.message : 'Failed to start recording',
|
||||
);
|
||||
}
|
||||
}, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
if (recorderRef.current?.state === 'recording') {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
@@ -158,7 +158,7 @@ export function useRecorder({
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.ondataavailable = null;
|
||||
recorderRef.current.onstop = null;
|
||||
if (recorderRef.current.state === "recording") {
|
||||
if (recorderRef.current.state === 'recording') {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { requireDesktop } from '@/lib/platform/desktop-only';
|
||||
|
||||
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||
const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
|
||||
const VIDEO_FALLBACK_MIME = 'video/webm';
|
||||
|
||||
function getScreenMime(): string {
|
||||
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
|
||||
@@ -33,7 +33,7 @@ async function getMicStream(
|
||||
if (
|
||||
micDeviceId &&
|
||||
err instanceof Error &&
|
||||
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
|
||||
(err.name === 'OverconstrainedError' || err.name === 'NotFoundError')
|
||||
) {
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
}
|
||||
@@ -77,14 +77,14 @@ export function useScreenRecorder({
|
||||
|
||||
const startRecording = useCallback(
|
||||
async (sourceId: string) => {
|
||||
if (!requireDesktop("Screen recording")) return;
|
||||
if (!requireDesktop('Screen recording')) return;
|
||||
try {
|
||||
// 1. Screen video
|
||||
const screenStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: sourceId,
|
||||
},
|
||||
} as MediaTrackConstraints,
|
||||
@@ -130,7 +130,7 @@ export function useScreenRecorder({
|
||||
|
||||
// 5. Listen for stop from floating window
|
||||
cleanupIpcRef.current = platform.screenRecord.onStopRequested(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
if (recorderRef.current?.state === 'recording') {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
});
|
||||
@@ -138,7 +138,9 @@ export function useScreenRecorder({
|
||||
stopAllTracks();
|
||||
platform.screenRecord.cancel();
|
||||
onErrorRef.current(
|
||||
err instanceof Error ? err.message : "Failed to start screen recording",
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to start screen recording',
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -146,7 +148,7 @@ export function useScreenRecorder({
|
||||
);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
if (recorderRef.current?.state === 'recording') {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
@@ -155,7 +157,7 @@ export function useScreenRecorder({
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.ondataavailable = null;
|
||||
recorderRef.current.onstop = null;
|
||||
if (recorderRef.current.state === "recording") {
|
||||
if (recorderRef.current.state === 'recording') {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Home, Settings, Users, Volume2, VolumeOff } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAutoplayStore } from "@/stores/autoplay-store";
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Home, Settings, Users, Volume2, VolumeOff } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAutoplayStore } from '@/stores/autoplay-store';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -11,31 +11,31 @@ import {
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { useParticle } from "@/hooks/use-particle";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { PropsWithChildren, useCallback } from "react";
|
||||
import { useDockBadge } from "@/hooks/use-dock-badge";
|
||||
import { toast } from "sonner";
|
||||
import { RouteErrorBoundary } from "@/components/app-error-boundary";
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useNetworks } from '@/hooks/use-networks';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { useParticle } from '@/hooks/use-particle';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { PropsWithChildren, useCallback } from 'react';
|
||||
import { useDockBadge } from '@/hooks/use-dock-badge';
|
||||
import { toast } from 'sonner';
|
||||
import { RouteErrorBoundary } from '@/components/app-error-boundary';
|
||||
|
||||
function getParticleDisplayName(particle: Particle): string {
|
||||
switch (particle.type) {
|
||||
case "stream":
|
||||
case "folder":
|
||||
case 'stream':
|
||||
case 'folder':
|
||||
return particle.properties.name;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
case 'file':
|
||||
return particle.properties.filename;
|
||||
case "text":
|
||||
case 'text':
|
||||
return particle.properties.content.slice(0, 30);
|
||||
case "media":
|
||||
case 'media':
|
||||
return particle.type;
|
||||
}
|
||||
}
|
||||
@@ -65,19 +65,23 @@ function AutoplayToggle() {
|
||||
const handleToggle = useCallback(() => {
|
||||
toggleMuted();
|
||||
if (muted) {
|
||||
toast.success("Auto-play enabled");
|
||||
toast.success('Auto-play enabled');
|
||||
} else {
|
||||
toast.info("Auto-play disabled");
|
||||
toast.info('Auto-play disabled');
|
||||
}
|
||||
}, [toggleMuted, muted]);
|
||||
|
||||
return (
|
||||
<div className="no-drag flex items-center gap-1.5">
|
||||
{muted ? <VolumeOff className="size-3.5 text-muted-foreground" /> : <Volume2 className="size-3.5" />}
|
||||
{muted ? (
|
||||
<VolumeOff className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<Volume2 className="size-3.5" />
|
||||
)}
|
||||
<Switch
|
||||
checked={!muted}
|
||||
onCheckedChange={handleToggle}
|
||||
aria-label={muted ? "Unmute autoplay" : "Mute autoplay"}
|
||||
aria-label={muted ? 'Unmute autoplay' : 'Mute autoplay'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -85,10 +89,13 @@ function AutoplayToggle() {
|
||||
|
||||
function TopBar() {
|
||||
const navigate = useNavigate();
|
||||
const { networkId, "*": rest } = useParams();
|
||||
const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean);
|
||||
const { networkId, '*': rest } = useParams();
|
||||
const segments = [networkId, ...(rest?.split('/') ?? [])].filter(Boolean);
|
||||
|
||||
const path = rest && networkId ? particlePath(networkId, rest.split("/").filter(Boolean)) : undefined;
|
||||
const path =
|
||||
rest && networkId
|
||||
? particlePath(networkId, rest.split('/').filter(Boolean))
|
||||
: undefined;
|
||||
|
||||
const { data: particle } = useParticle(path);
|
||||
|
||||
@@ -106,7 +113,7 @@ function TopBar() {
|
||||
) : (
|
||||
<BreadcrumbLink
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
onClick={() => navigate("/")}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<Home className="size-3.5" />
|
||||
</BreadcrumbLink>
|
||||
@@ -137,7 +144,9 @@ function TopBar() {
|
||||
<span key={path} className="contents">
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="text-xs">
|
||||
<BreadcrumbPage>{getParticleDisplayName(particle)}</BreadcrumbPage>
|
||||
<BreadcrumbPage>
|
||||
{getParticleDisplayName(particle)}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
)}
|
||||
@@ -163,7 +172,7 @@ function TopBar() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="no-drag text-muted-foreground"
|
||||
onClick={() => navigate("/settings")}
|
||||
onClick={() => navigate('/settings')}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { CopyableEmail } from "@/components/copyable-email";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { SUPPORT_EMAIL } from "@/lib/constants";
|
||||
import { useState } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Muted } from '@/components/ui/typography';
|
||||
import { CopyableEmail } from '@/components/copyable-email';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import { SUPPORT_EMAIL } from '@/lib/constants';
|
||||
import {
|
||||
useCreateCheckoutSession,
|
||||
useCreatePortalSession,
|
||||
useNetworkBilling,
|
||||
} from "@/hooks/use-billing";
|
||||
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
||||
import { useIsNetworkAdmin } from "@/hooks/use-networks";
|
||||
import type { BillingCadence, BillingStatus } from "@/api/types";
|
||||
import { platform } from "@/lib/platform";
|
||||
} from '@/hooks/use-billing';
|
||||
import { useNetworkUsage } from '@/hooks/use-network-usage';
|
||||
import { useIsNetworkAdmin } from '@/hooks/use-networks';
|
||||
import type { BillingCadence, BillingStatus } from '@/api/types';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
if (cents % 100 === 0) return `$${cents / 100}`;
|
||||
@@ -27,17 +27,17 @@ function formatCents(cents: number): string {
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function PlanStatusBadge({ status }: { status: BillingStatus["plan_status"] }) {
|
||||
if (status === "past_due")
|
||||
function PlanStatusBadge({ status }: { status: BillingStatus['plan_status'] }) {
|
||||
if (status === 'past_due')
|
||||
return <Badge variant="destructive">Past due</Badge>;
|
||||
if (status === "canceled") return <Badge variant="secondary">Canceled</Badge>;
|
||||
if (status === "trialing") return <Badge variant="secondary">Trialing</Badge>;
|
||||
if (status === 'canceled') return <Badge variant="secondary">Canceled</Badge>;
|
||||
if (status === 'trialing') return <Badge variant="secondary">Trialing</Badge>;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,8 +76,8 @@ function CadenceOption({
|
||||
<Label
|
||||
htmlFor={`cadence-${value}`}
|
||||
className={cn(
|
||||
"hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors",
|
||||
selected && "bg-accent/50",
|
||||
'hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors',
|
||||
selected && 'bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem id={`cadence-${value}`} value={value} />
|
||||
@@ -98,15 +98,15 @@ function CadenceOption({
|
||||
|
||||
function formatResetLocal(resetAt: Date): string {
|
||||
const time = resetAt.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const now = new Date();
|
||||
const isSameDay =
|
||||
resetAt.getFullYear() === now.getFullYear() &&
|
||||
resetAt.getMonth() === now.getMonth() &&
|
||||
resetAt.getDate() === now.getDate();
|
||||
return `${isSameDay ? "today" : "tomorrow"} at ${time}`;
|
||||
return `${isSameDay ? 'today' : 'tomorrow'} at ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,7 +119,7 @@ function PlanSummary({ networkId }: { networkId: string }) {
|
||||
|
||||
if (!usage) return null;
|
||||
|
||||
const isPro = usage.plan === "pro";
|
||||
const isPro = usage.plan === 'pro';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -127,9 +127,9 @@ function PlanSummary({ networkId }: { networkId: string }) {
|
||||
label="Plan"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{isPro ? "Llink Pro" : "Llink Free"}</span>
|
||||
<Badge variant={isPro ? "default" : "secondary"}>
|
||||
{isPro ? "Pro" : "Free"}
|
||||
<span>{isPro ? 'Llink Pro' : 'Llink Free'}</span>
|
||||
<Badge variant={isPro ? 'default' : 'secondary'}>
|
||||
{isPro ? 'Pro' : 'Free'}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
@@ -168,7 +168,7 @@ function FreeBilling({
|
||||
billing: BillingStatus;
|
||||
}) {
|
||||
const createCheckout = useCreateCheckoutSession(networkId);
|
||||
const [cadence, setCadence] = useState<BillingCadence>("annual");
|
||||
const [cadence, setCadence] = useState<BillingCadence>('annual');
|
||||
|
||||
const handleUpgrade = () => {
|
||||
createCheckout.mutate(cadence, {
|
||||
@@ -194,7 +194,7 @@ function FreeBilling({
|
||||
perSeatCents={annualPerSeatMonthlyCents}
|
||||
billedNote="Billed annually"
|
||||
saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
|
||||
selected={cadence === "annual"}
|
||||
selected={cadence === 'annual'}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<CadenceOption
|
||||
@@ -202,7 +202,7 @@ function FreeBilling({
|
||||
label="Monthly"
|
||||
perSeatCents={billing.price_monthly_cents}
|
||||
billedNote="Billed monthly · cancel anytime"
|
||||
selected={cadence === "monthly"}
|
||||
selected={cadence === 'monthly'}
|
||||
/>
|
||||
</RadioGroup>
|
||||
<div className="px-4 py-3">
|
||||
@@ -211,7 +211,7 @@ function FreeBilling({
|
||||
onClick={handleUpgrade}
|
||||
disabled={createCheckout.isPending}
|
||||
>
|
||||
{createCheckout.isPending ? "Opening Stripe..." : "Upgrade to Pro"}
|
||||
{createCheckout.isPending ? 'Opening Stripe...' : 'Upgrade to Pro'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -233,9 +233,9 @@ function ProBilling({
|
||||
});
|
||||
};
|
||||
|
||||
const cadenceLabel = billing.cadence === "annual" ? "Annual" : "Monthly";
|
||||
const cadenceLabel = billing.cadence === 'annual' ? 'Annual' : 'Monthly';
|
||||
const perSeatCents =
|
||||
billing.cadence === "annual"
|
||||
billing.cadence === 'annual'
|
||||
? Math.round(billing.price_annual_cents / 12)
|
||||
: billing.price_monthly_cents;
|
||||
const renewal = billing.current_period_end
|
||||
@@ -249,7 +249,7 @@ function ProBilling({
|
||||
Your subscription is set to downgrade to Free on {renewal}.
|
||||
</div>
|
||||
)}
|
||||
{billing.plan_status === "past_due" && (
|
||||
{billing.plan_status === 'past_due' && (
|
||||
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
|
||||
Your last payment failed. Update your payment method to keep Pro
|
||||
active.
|
||||
@@ -271,7 +271,7 @@ function ProBilling({
|
||||
<>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow
|
||||
label={billing.cancel_at_period_end ? "Ends" : "Renews"}
|
||||
label={billing.cancel_at_period_end ? 'Ends' : 'Renews'}
|
||||
value={renewal}
|
||||
/>
|
||||
</>
|
||||
@@ -284,9 +284,7 @@ function ProBilling({
|
||||
disabled={createPortal.isPending}
|
||||
>
|
||||
<ExternalLink className="mr-2 size-3.5" />
|
||||
{createPortal.isPending
|
||||
? "Opening Stripe..."
|
||||
: "Manage subscription"}
|
||||
{createPortal.isPending ? 'Opening Stripe...' : 'Manage subscription'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -340,11 +338,13 @@ function AdminBillingControls({ networkId }: { networkId: string }) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<Muted className="text-sm">Couldn't load billing: {toUserMessage(error)}</Muted>
|
||||
<Muted className="text-sm">
|
||||
Couldn't load billing: {toUserMessage(error)}
|
||||
</Muted>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (billing.plan === "pro") {
|
||||
if (billing.plan === 'pro') {
|
||||
return <ProBilling networkId={networkId} billing={billing} />;
|
||||
}
|
||||
return <FreeBilling networkId={networkId} billing={billing} />;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { CircleDot, CircleCheckBig } from "lucide-react";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||
import { ComposeOverlay } from "./compose/compose-overlay";
|
||||
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { CircleDot, CircleCheckBig } from 'lucide-react';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { ParticleListView } from '@/features/particles/particle-list-view';
|
||||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||
import { ComposeOverlay } from './compose/compose-overlay';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useStreamParticles } from '@/hooks/use-stream-particles';
|
||||
import { useStreamKeyboardNav } from '@/hooks/use-stream-keyboard-nav';
|
||||
|
||||
/**
|
||||
* Route-level component for /:networkId (index).
|
||||
@@ -17,28 +17,33 @@ import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
|
||||
*/
|
||||
export default function NetworkRoot() {
|
||||
const { networkId } = useParams();
|
||||
if (!networkId)
|
||||
throw new Error('NetworkRoot requires a :networkId route param');
|
||||
const navigate = useNavigate();
|
||||
const path = particlePath(networkId!, []);
|
||||
const path = particlePath(networkId, []);
|
||||
|
||||
const [composeActive, setComposeActive] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const statusTab: "open" | "closed" =
|
||||
searchParams.get("status") === "closed" ? "closed" : "open";
|
||||
const setStatusTab = (next: "open" | "closed") => {
|
||||
const statusTab: 'open' | 'closed' =
|
||||
searchParams.get('status') === 'closed' ? 'closed' : 'open';
|
||||
const setStatusTab = (next: 'open' | 'closed') => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
if (next === "open") params.delete("status");
|
||||
else params.set("status", next);
|
||||
if (next === 'open') params.delete('status');
|
||||
else params.set('status', next);
|
||||
return params;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(path, {
|
||||
status: statusTab,
|
||||
});
|
||||
const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(
|
||||
path,
|
||||
{
|
||||
status: statusTab,
|
||||
},
|
||||
);
|
||||
|
||||
const { selectedIndex } = useStreamKeyboardNav({
|
||||
streams,
|
||||
@@ -55,11 +60,17 @@ export default function NetworkRoot() {
|
||||
<div className="flex shrink-0 items-center p-1 border-b">
|
||||
<Tabs
|
||||
value={statusTab}
|
||||
onValueChange={(v) => setStatusTab(v === "closed" ? "closed" : "open")}
|
||||
onValueChange={(v) =>
|
||||
setStatusTab(v === 'closed' ? 'closed' : 'open')
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="open"><CircleDot className="size-3 text-green-500" /> Open</TabsTrigger>
|
||||
<TabsTrigger value="closed"><CircleCheckBig className="size-3" /> Closed</TabsTrigger>
|
||||
<TabsTrigger value="open">
|
||||
<CircleDot className="size-3 text-green-500" /> Open
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="closed">
|
||||
<CircleCheckBig className="size-3" /> Closed
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -68,7 +79,7 @@ export default function NetworkRoot() {
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
|
||||
<ParticleListView
|
||||
streams={streams}
|
||||
networkId={networkId!}
|
||||
networkId={networkId}
|
||||
isLoading={isLoading}
|
||||
selectedIndex={selectedIndex}
|
||||
canLoadMore={canLoadMore}
|
||||
@@ -76,10 +87,10 @@ export default function NetworkRoot() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||
<ComposeOverlay networkId={networkId} onActiveChange={setComposeActive} />
|
||||
{!composeActive && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
|
||||
<ComposeQuotaIndicator networkId={networkId!} />
|
||||
<ComposeQuotaIndicator networkId={networkId} />
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||
@@ -98,39 +109,39 @@ function NetworkRootControls() {
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
↑↓
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
1–9
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
jump
|
||||
</span>
|
||||
<VideoAudioToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("record")}
|
||||
onClick={() => requestIntent('record')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Start recording (or hold `)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Hold `
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("text")}
|
||||
onClick={() => requestIntent('text')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Compose text (or press T)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
T
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
text
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Check, Plus, Settings, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Small } from "@/components/ui/typography";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, Plus, Settings, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Small } from '@/components/ui/typography';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,16 +14,19 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-member-management";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import type { Network, Invitation } from "@/api/types";
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useNetworks } from '@/hooks/use-networks';
|
||||
import {
|
||||
useMyInvitations,
|
||||
useAcceptInvitation,
|
||||
} from '@/hooks/use-member-management';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import type { Network, Invitation } from '@/api/types';
|
||||
|
||||
function NetworkRow({
|
||||
network,
|
||||
@@ -53,7 +56,7 @@ function NetworkRow({
|
||||
<div className="text-muted-foreground flex items-center gap-1">
|
||||
<Users className="size-3" />
|
||||
<Small className="text-muted-foreground">
|
||||
{memberCount} {memberCount === 1 ? "member" : "members"}
|
||||
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||
</Small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +69,7 @@ function NetworkRow({
|
||||
onSettingsClick();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
onSettingsClick();
|
||||
}
|
||||
@@ -109,7 +112,7 @@ function InvitationRow({ invitation }: { invitation: Invitation }) {
|
||||
disabled={acceptInvitation.isPending}
|
||||
>
|
||||
<Check className="mr-1 size-3.5" />
|
||||
{acceptInvitation.isPending ? "Joining..." : "Accept"}
|
||||
{acceptInvitation.isPending ? 'Joining...' : 'Accept'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -122,7 +125,7 @@ function CreateNetworkDialog({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [name, setName] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -130,10 +133,10 @@ function CreateNetworkDialog({
|
||||
mutationFn: (networkName: string) =>
|
||||
apiClient.createNetwork({ name: networkName }),
|
||||
onSuccess: (network) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["networks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ['networks'] });
|
||||
toast.success(`Created ${network.name}`);
|
||||
onOpenChange(false);
|
||||
setName("");
|
||||
setName('');
|
||||
navigate(`/${network.id}/settings`);
|
||||
},
|
||||
});
|
||||
@@ -150,7 +153,10 @@ function CreateNetworkDialog({
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Network</DialogTitle>
|
||||
<DialogDescription>Caution: creating a network will create a new billing account. If your team already has a network, ask them for an invite.</DialogDescription>
|
||||
<DialogDescription>
|
||||
Caution: creating a network will create a new billing account. If
|
||||
your team already has a network, ask them for an invite.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="py-4">
|
||||
@@ -173,7 +179,7 @@ function CreateNetworkDialog({
|
||||
type="submit"
|
||||
disabled={!name.trim() || createNetwork.isPending}
|
||||
>
|
||||
{createNetwork.isPending ? "Creating..." : "Create"}
|
||||
{createNetwork.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -212,10 +218,13 @@ export default function NetworkSelector() {
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
You don't have access to any networks yet. Create one or ask your admin for an invite.
|
||||
You don't have access to any networks yet. Create one or ask your
|
||||
admin for an invite.
|
||||
</p>
|
||||
<div className="flex flex-row gap-1">
|
||||
<Button variant="outline" onClick={() => refetch()}>Refresh</Button>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
Create Network
|
||||
@@ -267,9 +276,7 @@ export default function NetworkSelector() {
|
||||
<NetworkRow
|
||||
network={network}
|
||||
onClick={() => navigate(`/${network.id}`)}
|
||||
onSettingsClick={() =>
|
||||
navigate(`/${network.id}/settings`)
|
||||
}
|
||||
onSettingsClick={() => navigate(`/${network.id}/settings`)}
|
||||
/>
|
||||
{index < networks.length - 1 && (
|
||||
<Separator className="mx-4" />
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Muted } from '@/components/ui/typography';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { useNetworks } from '@/hooks/use-networks';
|
||||
import {
|
||||
useNetworkInvitations,
|
||||
useInviteMembers,
|
||||
useRevokeInvitation,
|
||||
useRemoveMember,
|
||||
} from "@/hooks/use-member-management";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { BillingSection } from "@/features/network-billing";
|
||||
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
|
||||
import type { Human } from "@/api/types";
|
||||
} from '@/hooks/use-member-management';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { BillingSection } from '@/features/network-billing';
|
||||
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
|
||||
import type { Human } from '@/api/types';
|
||||
|
||||
function MemberRow({
|
||||
human,
|
||||
@@ -66,7 +66,7 @@ function MemberRow({
|
||||
}
|
||||
|
||||
function InviteForm({ networkId }: { networkId: string }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [email, setEmail] = useState('');
|
||||
const inviteMembers = useInviteMembers(networkId);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -77,7 +77,7 @@ function InviteForm({ networkId }: { networkId: string }) {
|
||||
inviteMembers.mutate([trimmed], {
|
||||
onSuccess: () => {
|
||||
toast.success(`Invitation sent to ${trimmed}`);
|
||||
setEmail("");
|
||||
setEmail('');
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -96,7 +96,7 @@ function InviteForm({ networkId }: { networkId: string }) {
|
||||
size="sm"
|
||||
disabled={!email.trim() || inviteMembers.isPending}
|
||||
>
|
||||
{inviteMembers.isPending ? "Sending..." : "Invite"}
|
||||
{inviteMembers.isPending ? 'Sending...' : 'Invite'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
@@ -179,24 +179,30 @@ function Section({ children }: { children: React.ReactNode }) {
|
||||
export default function NetworkSettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { networkId } = useParams<{ networkId: string }>();
|
||||
if (!networkId)
|
||||
throw new Error('NetworkSettingsPage requires a :networkId route param');
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: networks } = useNetworks();
|
||||
const network = networks?.find((n) => n.id === networkId);
|
||||
const { data: invitations, error: invitationsError } = useNetworkInvitations(networkId!);
|
||||
const { data: invitations, error: invitationsError } =
|
||||
useNetworkInvitations(networkId);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const isAdmin = currentUser?.id === network?.admin_human.id;
|
||||
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
|
||||
const removeMember = useRemoveMember(networkId!);
|
||||
const removeMember = useRemoveMember(networkId);
|
||||
|
||||
const billingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("section") === "billing") {
|
||||
billingRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
if (searchParams.get('section') === 'billing') {
|
||||
billingRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const networkName = network?.name ?? "Network";
|
||||
const networkName = network?.name ?? 'Network';
|
||||
const memberCount = network?.humans.length ?? 0;
|
||||
const pendingCount = invitations?.length ?? 0;
|
||||
const networkInitials = networkName.slice(0, 2).toUpperCase();
|
||||
@@ -227,8 +233,8 @@ export default function NetworkSettingsPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-base font-semibold">{networkName}</p>
|
||||
<Muted className="text-xs">
|
||||
{memberCount} {memberCount === 1 ? "member" : "members"}
|
||||
{isAdmin ? " · You're an admin" : ""}
|
||||
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||
{isAdmin ? " · You're an admin" : ''}
|
||||
</Muted>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,7 +260,9 @@ export default function NetworkSettingsPage() {
|
||||
<MemberRow
|
||||
human={human}
|
||||
isAdmin={isRowAdmin}
|
||||
onRemove={canRemove ? () => setMemberToRemove(human) : undefined}
|
||||
onRemove={
|
||||
canRemove ? () => setMemberToRemove(human) : undefined
|
||||
}
|
||||
/>
|
||||
{index < network.humans.length - 1 && (
|
||||
<Separator className="mx-4" />
|
||||
@@ -279,7 +287,7 @@ export default function NetworkSettingsPage() {
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<InviteForm networkId={networkId!} />
|
||||
<InviteForm networkId={networkId} />
|
||||
{invitationsError && (
|
||||
<>
|
||||
<Separator />
|
||||
@@ -288,7 +296,7 @@ export default function NetworkSettingsPage() {
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
{invitations && invitations.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-4 pb-1 pt-3">
|
||||
@@ -296,13 +304,13 @@ export default function NetworkSettingsPage() {
|
||||
Pending
|
||||
</Muted>
|
||||
</div>
|
||||
{invitations!.map((inv, index) => (
|
||||
{invitations.map((inv, index) => (
|
||||
<div key={inv.email}>
|
||||
<PendingInvitationRow
|
||||
email={inv.email}
|
||||
networkId={networkId!}
|
||||
networkId={networkId}
|
||||
/>
|
||||
{index < invitations!.length - 1 && (
|
||||
{index < invitations.length - 1 && (
|
||||
<Separator className="mx-4" />
|
||||
)}
|
||||
</div>
|
||||
@@ -319,12 +327,12 @@ export default function NetworkSettingsPage() {
|
||||
title="Billing"
|
||||
description={
|
||||
isAdmin
|
||||
? "Manage your plan, seats, and payment."
|
||||
? 'Manage your plan, seats, and payment.'
|
||||
: "Your network's current plan and usage."
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<BillingSection networkId={networkId!} />
|
||||
<BillingSection networkId={networkId} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -342,7 +350,8 @@ export default function NetworkSettingsPage() {
|
||||
</li>
|
||||
<li>Any content they posted stays in the network.</li>
|
||||
<li>
|
||||
If they're in a live huddle, they may remain until the call ends.
|
||||
If they're in a live huddle, they may remain until the call
|
||||
ends.
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
|
||||
import { softDeleteParticle } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
|
||||
import { softDeleteParticle } from '@/lib/firestore-particles';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface DeleteParticleOverlayProps {
|
||||
networkId: string;
|
||||
@@ -21,7 +21,7 @@ export function DeleteParticleOverlay({
|
||||
userId,
|
||||
onClose,
|
||||
}: DeleteParticleOverlayProps) {
|
||||
useSuspendPlayback(true, "delete-particle");
|
||||
useSuspendPlayback(true, 'delete-particle');
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -33,10 +33,11 @@ export function DeleteParticleOverlay({
|
||||
particlePath(networkId, [streamId, particle.id]),
|
||||
);
|
||||
await softDeleteParticle(docPath, userId);
|
||||
toast.success("Particle deleted");
|
||||
toast.success('Particle deleted');
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to delete particle";
|
||||
const message =
|
||||
e instanceof Error ? e.message : 'Failed to delete particle';
|
||||
toast.error(message);
|
||||
setDeleting(false);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { useEffect } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
// How long to linger on a tombstone before auto-advancing. Matches the
|
||||
// "reading" cadence of a short text particle.
|
||||
@@ -23,7 +23,9 @@ export function DeletedParticleView({
|
||||
}: DeletedParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const deleterId =
|
||||
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
||||
'deleted_by_human_id' in particle
|
||||
? particle.deleted_by_human_id
|
||||
: undefined;
|
||||
const deleter = deleterId
|
||||
? resolveHumanDisplay(deleterId, network?.humans)
|
||||
: null;
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { Particle } from "@/api/types";
|
||||
import type { Particle } from '@/api/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
FileIcon,
|
||||
HelpCircleIcon,
|
||||
ScrollTextIcon,
|
||||
BookOpenIcon,
|
||||
} from 'lucide-react';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
||||
quest: { icon: ScrollTextIcon, label: "Quest" },
|
||||
paper: { icon: BookOpenIcon, label: "Paper" },
|
||||
file: { icon: FileIcon, label: "File" },
|
||||
quest: { icon: ScrollTextIcon, label: 'Quest' },
|
||||
paper: { icon: BookOpenIcon, label: 'Paper' },
|
||||
file: { icon: FileIcon, label: 'File' },
|
||||
};
|
||||
|
||||
interface FallbackParticleViewProps {
|
||||
@@ -21,9 +26,15 @@ interface FallbackParticleViewProps {
|
||||
networkId: string;
|
||||
}
|
||||
|
||||
export function FallbackParticleView({ particle, networkId }: FallbackParticleViewProps) {
|
||||
export function FallbackParticleView({
|
||||
particle,
|
||||
networkId,
|
||||
}: FallbackParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const creator = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
|
||||
const creator = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const meta = TYPE_META[particle.type] ?? {
|
||||
icon: HelpCircleIcon,
|
||||
label: particle.type,
|
||||
@@ -31,13 +42,13 @@ export function FallbackParticleView({ particle, networkId }: FallbackParticleVi
|
||||
const Icon = meta.icon;
|
||||
const title = (() => {
|
||||
switch (particle.type) {
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
case 'file':
|
||||
return particle.properties.filename;
|
||||
case "folder":
|
||||
case 'folder':
|
||||
return particle.properties.name;
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Particle } from "@/api/types";
|
||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { ComposeOverlay } from "@/features/compose/compose-overlay";
|
||||
import { Particle } from '@/api/types';
|
||||
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||||
import { ComposeOverlay } from '@/features/compose/compose-overlay';
|
||||
|
||||
interface FolderViewProps {
|
||||
folderParticle: Particle;
|
||||
@@ -9,7 +8,6 @@ interface FolderViewProps {
|
||||
}
|
||||
|
||||
export function FolderView({ path, folderParticle }: FolderViewProps) {
|
||||
const { children, error, isLoading } = useLiveParticleChildren(path);
|
||||
const { networkId } = parseParticlePath(path);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
|
||||
import { TranscriptOverlay } from "@/features/particles/transcript-overlay";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import type { ParticlePath } from '@/lib/particle-path';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
|
||||
import { TranscriptOverlay } from '@/features/particles/transcript-overlay';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
|
||||
import { useAudioSource } from '@/components/audio/use-audio-source';
|
||||
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
||||
import { ParticleAttachments } from '@/features/particles/particle-attachments';
|
||||
|
||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||
type MediaParticle = Extract<Particle, { type: 'media' }>;
|
||||
|
||||
export interface MediaParticleHandle {
|
||||
/** Seek by delta. Returns true if seeked, false if at boundary (should navigate). */
|
||||
@@ -26,50 +32,55 @@ interface MediaParticleViewProps {
|
||||
onProgress?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleViewProps>(function MediaParticleView({
|
||||
particle,
|
||||
streamPath,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}, ref) {
|
||||
// Prefer the worker-produced iOS-playable variant when present so desktop
|
||||
// and mobile read the same canonical asset. Falls back to the original.
|
||||
// Pin the choice for the lifetime of this particle: if a transcoded variant
|
||||
// arrives via Firestore mid-playback, swapping the <video> src would restart
|
||||
// playback from 0. Keep whatever we picked first; the original plays fine in
|
||||
// Electron, and the transcoded variant will be picked up on the next view.
|
||||
const pickedSourceRef = useRef<{ id: string; objectId: string; mime: string } | null>(null);
|
||||
if (pickedSourceRef.current?.id !== particle.id) {
|
||||
pickedSourceRef.current = {
|
||||
id: particle.id,
|
||||
objectId: particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
||||
mime: particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
||||
};
|
||||
}
|
||||
const activeObjectId = pickedSourceRef.current.objectId;
|
||||
const activeMime = pickedSourceRef.current.mime;
|
||||
export const MediaParticleView = forwardRef<
|
||||
MediaParticleHandle,
|
||||
MediaParticleViewProps
|
||||
>(function MediaParticleView(
|
||||
{ particle, streamPath, paused, onEnded, onProgress },
|
||||
ref,
|
||||
) {
|
||||
// Prefer the worker-produced iOS-playable variant when present so desktop and
|
||||
// mobile read the same canonical asset, falling back to the original. Pinned
|
||||
// on mount (the parent keys this component by particle.id, so a new particle
|
||||
// remounts and re-picks): if a transcoded variant arrives via Firestore for
|
||||
// the same particle, swapping the <video> src would restart playback from 0.
|
||||
const [pickedSource] = useState(() => ({
|
||||
objectId:
|
||||
particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
||||
mime:
|
||||
particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
||||
}));
|
||||
const activeObjectId = pickedSource.objectId;
|
||||
const activeMime = pickedSource.mime;
|
||||
const { data: url, error } = useDownloadUrl(activeObjectId);
|
||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const isAudio = activeMime?.startsWith("audio/");
|
||||
const isAudio = activeMime?.startsWith('audio/');
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek(deltaSec: number) {
|
||||
const el = isAudio ? audioRef.current : videoRef.current;
|
||||
if (!el) return false;
|
||||
if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false;
|
||||
if (deltaSec > 0 && el.duration - el.currentTime < deltaSec) return false;
|
||||
el.currentTime = Math.max(0, Math.min(el.duration, el.currentTime + deltaSec));
|
||||
return true;
|
||||
},
|
||||
setPlaybackRate(rate: number) {
|
||||
const el = isAudio ? audioRef.current : videoRef.current;
|
||||
if (el) el.playbackRate = rate;
|
||||
},
|
||||
}), [isAudio]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
seek(deltaSec: number) {
|
||||
const el = isAudio ? audioRef.current : videoRef.current;
|
||||
if (!el) return false;
|
||||
if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false;
|
||||
if (deltaSec > 0 && el.duration - el.currentTime < deltaSec)
|
||||
return false;
|
||||
el.currentTime = Math.max(
|
||||
0,
|
||||
Math.min(el.duration, el.currentTime + deltaSec),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
setPlaybackRate(rate: number) {
|
||||
const el = isAudio ? audioRef.current : videoRef.current;
|
||||
if (el) el.playbackRate = rate;
|
||||
},
|
||||
}),
|
||||
[isAudio],
|
||||
);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
const transcript = particle.properties.transcript;
|
||||
@@ -91,7 +102,7 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
||||
} else if (!el.ended) {
|
||||
// Calling play() on a naturally-finished element restarts it from 0.
|
||||
el.play().catch(() => {
|
||||
console.warn("Playback failed", { particleId: particle.id });
|
||||
console.warn('Playback failed', { particleId: particle.id });
|
||||
});
|
||||
}
|
||||
}, [paused, isAudio, particle.id]);
|
||||
@@ -108,14 +119,17 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
||||
return <Skeleton className="h-full w-full rounded-none" />;
|
||||
}
|
||||
|
||||
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLAudioElement | HTMLVideoElement>) => {
|
||||
const handleTimeUpdate = (
|
||||
e: React.SyntheticEvent<HTMLAudioElement | HTMLVideoElement>,
|
||||
) => {
|
||||
const { currentTime: time, duration } = e.currentTarget;
|
||||
setCurrentTime(time);
|
||||
// WebM files from MediaRecorder (screen recordings) often report Infinity/NaN
|
||||
// duration until fully buffered — fall back to the known duration from metadata.
|
||||
const effectiveDuration = Number.isFinite(duration) && duration > 0
|
||||
? duration
|
||||
: particle.properties.duration_ms / 1000;
|
||||
const effectiveDuration =
|
||||
Number.isFinite(duration) && duration > 0
|
||||
? duration
|
||||
: particle.properties.duration_ms / 1000;
|
||||
if (effectiveDuration > 0) onProgress?.(time / effectiveDuration);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Download, ExternalLink, FileIcon, ImageIcon } from 'lucide-react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AttachmentLightbox,
|
||||
getAttachmentHandler,
|
||||
type AttachmentItem,
|
||||
} from "@/features/attachments/attachment-lightbox";
|
||||
import { platform } from "@/lib/platform";
|
||||
} from '@/features/attachments/attachment-lightbox';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
type FileParticle = Extract<Particle, { type: "file" }>;
|
||||
type FileParticle = Extract<Particle, { type: 'file' }>;
|
||||
|
||||
interface ParticleAttachmentsProps {
|
||||
attachments: FileParticle[];
|
||||
variant?: "inline" | "compact";
|
||||
variant?: 'inline' | 'compact';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -31,7 +31,7 @@ function particleToItem(p: FileParticle): AttachmentItem {
|
||||
filename: p.properties.filename,
|
||||
mimeType: p.properties.mime_type,
|
||||
sizeBytes: p.properties.size_bytes,
|
||||
source: { kind: "remote", objectId: p.properties.object_id },
|
||||
source: { kind: 'remote', objectId: p.properties.object_id },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function openParticle(
|
||||
url: string | undefined,
|
||||
onPreview: (index: number) => void,
|
||||
) {
|
||||
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
|
||||
if (getAttachmentHandler(particle.properties.mime_type) === 'lightbox') {
|
||||
onPreview(index);
|
||||
} else if (url) {
|
||||
platform.link.openExternal(url);
|
||||
@@ -58,7 +58,9 @@ function ImageAttachment({
|
||||
particle: FileParticle;
|
||||
onPreview: () => void;
|
||||
}) {
|
||||
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
|
||||
const { data: url, isLoading } = useDownloadUrl(
|
||||
particle.properties.object_id,
|
||||
);
|
||||
|
||||
if (isLoading || !url) {
|
||||
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
|
||||
@@ -165,7 +167,7 @@ function CompactAttachmentItem({
|
||||
index: number;
|
||||
onPreview: (index: number) => void;
|
||||
}) {
|
||||
const isImage = particle.properties.mime_type.startsWith("image/");
|
||||
const isImage = particle.properties.mime_type.startsWith('image/');
|
||||
const { data: url } = useDownloadUrl(particle.properties.object_id);
|
||||
|
||||
return (
|
||||
@@ -200,13 +202,19 @@ function CompactAttachmentItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) {
|
||||
export function ParticleAttachments({
|
||||
attachments,
|
||||
variant = 'inline',
|
||||
}: ParticleAttachmentsProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
// Only previewable attachments populate the lightbox; the index passed to the
|
||||
// lightbox is the index into this filtered list, not `attachments`.
|
||||
const previewable = useMemo(
|
||||
() => attachments.filter((a) => getAttachmentHandler(a.properties.mime_type) === "lightbox"),
|
||||
() =>
|
||||
attachments.filter(
|
||||
(a) => getAttachmentHandler(a.properties.mime_type) === 'lightbox',
|
||||
),
|
||||
[attachments],
|
||||
);
|
||||
const items = useMemo(() => previewable.map(particleToItem), [previewable]);
|
||||
@@ -228,7 +236,7 @@ export function ParticleAttachments({ attachments, variant = "inline" }: Particl
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === "compact") {
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<>
|
||||
<div className="flex max-w-48 flex-col gap-1">
|
||||
@@ -251,7 +259,8 @@ export function ParticleAttachments({ attachments, variant = "inline" }: Particl
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{attachments.map((attachment, i) => {
|
||||
const isImage = attachment.properties.mime_type.startsWith("image/");
|
||||
const isImage =
|
||||
attachment.properties.mime_type.startsWith('image/');
|
||||
return isImage ? (
|
||||
<ImageAttachment
|
||||
key={attachment.id}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useMemo, useRef, useEffect, useCallback, memo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
memo,
|
||||
createElement,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Radio,
|
||||
MessageSquare,
|
||||
@@ -12,25 +19,28 @@ import {
|
||||
Headphones,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Small } from "@/components/ui/typography";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/types";
|
||||
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
|
||||
} from 'lucide-react';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Small } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
isParticleDeleted,
|
||||
type Particle,
|
||||
type StreamProperties,
|
||||
} from '@/api/types';
|
||||
import type { StreamParticle } from '@/hooks/use-stream-particles';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
|
||||
|
||||
function VideoThumbnail({
|
||||
objectId,
|
||||
@@ -43,8 +53,8 @@ function VideoThumbnail({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"size-8 shrink-0 overflow-hidden rounded-md bg-muted",
|
||||
isUnseen && "ring-2 ring-primary",
|
||||
'size-8 shrink-0 overflow-hidden rounded-md bg-muted',
|
||||
isUnseen && 'ring-2 ring-primary',
|
||||
)}
|
||||
>
|
||||
{url && (
|
||||
@@ -64,20 +74,20 @@ function VideoThumbnail({
|
||||
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||
if (isParticleDeleted(particle)) return Trash2;
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
case 'text':
|
||||
return MessageSquare;
|
||||
case "media": {
|
||||
case 'media': {
|
||||
const mime = particle.properties.mime_type;
|
||||
if (mime.startsWith("video/")) return Video;
|
||||
if (mime.startsWith("audio/")) return Mic;
|
||||
if (mime.startsWith("image/")) return Image;
|
||||
if (mime.startsWith('video/')) return Video;
|
||||
if (mime.startsWith('audio/')) return Mic;
|
||||
if (mime.startsWith('image/')) return Image;
|
||||
return Video;
|
||||
}
|
||||
case "file":
|
||||
case 'file':
|
||||
return FileText;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return CircleCheck;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return StickyNote;
|
||||
default:
|
||||
return Radio;
|
||||
@@ -85,25 +95,25 @@ function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||
}
|
||||
|
||||
function getMessagePreview(particle: Particle): string {
|
||||
if (isParticleDeleted(particle)) return "Deleted particle";
|
||||
if (isParticleDeleted(particle)) return 'Deleted particle';
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
case 'text':
|
||||
return particle.properties.content;
|
||||
case "media": {
|
||||
case 'media': {
|
||||
const mime = particle.properties.mime_type;
|
||||
if (mime.startsWith("image/")) return "Photo";
|
||||
if (mime.startsWith("video/") || mime.startsWith("audio/")) {
|
||||
if (mime.startsWith('image/')) return 'Photo';
|
||||
if (mime.startsWith('video/') || mime.startsWith('audio/')) {
|
||||
const transcriptText = particle.properties.transcript?.transcript;
|
||||
if (transcriptText) return transcriptText;
|
||||
return mime.startsWith("video/") ? "Video clip" : "Voice note";
|
||||
return mime.startsWith('video/') ? 'Video clip' : 'Voice note';
|
||||
}
|
||||
return "Media";
|
||||
return 'Media';
|
||||
}
|
||||
case "file":
|
||||
case 'file':
|
||||
return particle.properties.filename;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
default:
|
||||
return particle.type;
|
||||
@@ -117,7 +127,7 @@ const StreamRow = memo(function StreamRow({
|
||||
isSelected,
|
||||
shortcutKey,
|
||||
}: {
|
||||
particle: Particle & { type: "stream"; properties: StreamProperties };
|
||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
||||
networkId: string;
|
||||
onNavigate: (streamId: string) => void;
|
||||
isSelected?: boolean;
|
||||
@@ -126,18 +136,19 @@ const StreamRow = memo(function StreamRow({
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = user?.id ?? "";
|
||||
const userId = user?.id ?? '';
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
|
||||
particle.huddle_active_participants &&
|
||||
particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
@@ -145,19 +156,28 @@ const StreamRow = memo(function StreamRow({
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace("human:", "");
|
||||
const otherId = otherEntry.replace('human:', '');
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestChild) {
|
||||
const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) return getInitials(creator.email);
|
||||
}
|
||||
|
||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||
}, [isDM, particle.visible_to, particle.properties.name, userId, latestChild, network]);
|
||||
}, [
|
||||
isDM,
|
||||
particle.visible_to,
|
||||
particle.properties.name,
|
||||
userId,
|
||||
latestChild,
|
||||
network,
|
||||
]);
|
||||
|
||||
const isUnseen = useMemo(() => {
|
||||
if (!latestChild) return false;
|
||||
@@ -171,15 +191,16 @@ const StreamRow = memo(function StreamRow({
|
||||
if (!latestChild) return null;
|
||||
const isCurrentUser = latestChild.created_by_human_id === userId;
|
||||
if (isDM) {
|
||||
return isCurrentUser ? "You: " : null;
|
||||
return isCurrentUser ? 'You: ' : null;
|
||||
}
|
||||
// Group stream
|
||||
if (isCurrentUser) return "You: ";
|
||||
if (isCurrentUser) return 'You: ';
|
||||
const { displayName } = resolveHumanDisplay(
|
||||
latestChild.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1);
|
||||
const capitalized =
|
||||
displayName.charAt(0).toUpperCase() + displayName.slice(1);
|
||||
return `${capitalized}: `;
|
||||
}, [latestChild, userId, isDM, network]);
|
||||
|
||||
@@ -187,13 +208,15 @@ const StreamRow = memo(function StreamRow({
|
||||
? getMessagePreview(latestChild)
|
||||
: particle.properties.name;
|
||||
|
||||
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
||||
// Rendered via createElement below: a call-result used directly as a JSX tag
|
||||
// is flagged as a dynamically-created component.
|
||||
const typeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
||||
|
||||
const videoThumbObjectId =
|
||||
latestChild &&
|
||||
!isParticleDeleted(latestChild) &&
|
||||
latestChild.type === "media" &&
|
||||
latestChild.properties.mime_type.startsWith("video/")
|
||||
latestChild.type === 'media' &&
|
||||
latestChild.properties.mime_type.startsWith('video/')
|
||||
? latestChild.properties.object_id
|
||||
: null;
|
||||
|
||||
@@ -202,11 +225,13 @@ const StreamRow = memo(function StreamRow({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onNavigate(particle.id)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onNavigate(particle.id); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(particle.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent",
|
||||
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
|
||||
isSelected && 'bg-accent',
|
||||
hasActiveHuddle && 'bg-gradient-to-r from-red-500/10 to-transparent',
|
||||
)}
|
||||
>
|
||||
{shortcutKey && (
|
||||
@@ -217,7 +242,7 @@ const StreamRow = memo(function StreamRow({
|
||||
{videoThumbObjectId ? (
|
||||
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||
) : (
|
||||
<Avatar className={cn(isUnseen && "ring-2 ring-primary")}>
|
||||
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
@@ -227,10 +252,10 @@ const StreamRow = memo(function StreamRow({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
'truncate text-sm',
|
||||
isUnseen
|
||||
? "font-semibold text-foreground"
|
||||
: "font-medium text-muted-foreground",
|
||||
? 'font-semibold text-foreground'
|
||||
: 'font-medium text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
@@ -239,14 +264,16 @@ const StreamRow = memo(function StreamRow({
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
|
||||
<span className="text-[10px] font-medium text-red-400">
|
||||
{huddleCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{latestChild && (
|
||||
<Small
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isUnseen ? "text-primary" : "text-muted-foreground",
|
||||
'shrink-0',
|
||||
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<RelativeTimestamp date={latestChild.created_at} />
|
||||
@@ -255,18 +282,18 @@ const StreamRow = memo(function StreamRow({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TypeIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isUnseen ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{createElement(typeIcon, {
|
||||
className: cn(
|
||||
'size-3.5 shrink-0',
|
||||
isUnseen ? 'text-foreground' : 'text-muted-foreground',
|
||||
),
|
||||
})}
|
||||
<Small
|
||||
className={cn(
|
||||
"truncate",
|
||||
'truncate',
|
||||
isUnseen
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground font-normal",
|
||||
? 'text-foreground font-medium'
|
||||
: 'text-muted-foreground font-normal',
|
||||
)}
|
||||
>
|
||||
{senderPrefix && (
|
||||
@@ -276,9 +303,7 @@ const StreamRow = memo(function StreamRow({
|
||||
</Small>
|
||||
</div>
|
||||
</div>
|
||||
{isUnseen && (
|
||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
{isUnseen && <span className="size-2 shrink-0 rounded-full bg-primary" />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -313,8 +338,12 @@ export function ParticleListView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0) {
|
||||
rowRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
|
||||
if (
|
||||
selectedIndex !== null &&
|
||||
selectedIndex !== undefined &&
|
||||
selectedIndex >= 0
|
||||
) {
|
||||
rowRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
@@ -327,7 +356,8 @@ export function ParticleListView({
|
||||
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<Radio className="text-muted-foreground size-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No streams here. Start a conversation using the keyboard shortcuts below.
|
||||
No streams here. Start a conversation using the keyboard shortcuts
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -336,9 +366,15 @@ export function ParticleListView({
|
||||
return (
|
||||
<div>
|
||||
{streams.map((stream, index) => (
|
||||
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
|
||||
<StreamContextMenu
|
||||
key={stream.id}
|
||||
particle={stream}
|
||||
networkId={networkId}
|
||||
>
|
||||
<div
|
||||
ref={(el) => { rowRefs.current[index] = el; }}
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
}}
|
||||
>
|
||||
<StreamRow
|
||||
particle={stream}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Video,
|
||||
Mic,
|
||||
@@ -9,31 +9,35 @@ import {
|
||||
BookOpen,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
} from "lucide-react";
|
||||
} from 'lucide-react';
|
||||
|
||||
export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
case 'text':
|
||||
return <TextPreview particle={particle} />;
|
||||
case "media":
|
||||
case 'media':
|
||||
return <MediaPreview particle={particle} />;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return <QuestPreview particle={particle} />;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return <PaperPreview particle={particle} />;
|
||||
case "file":
|
||||
case 'file':
|
||||
return <FilePreview particle={particle} />;
|
||||
case "folder":
|
||||
case 'folder':
|
||||
return <FolderPreview particle={particle} />;
|
||||
default:
|
||||
return <EmptyPreview />;
|
||||
}
|
||||
}
|
||||
|
||||
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
|
||||
function TextPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'text' }>;
|
||||
}) {
|
||||
const truncated =
|
||||
particle.properties.content.length > 30
|
||||
? particle.properties.content.slice(0, 30) + "..."
|
||||
? particle.properties.content.slice(0, 30) + '...'
|
||||
: particle.properties.content;
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
@@ -44,14 +48,23 @@ function TextPreview({ particle }: { particle: Extract<Particle, { type: "text"
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
|
||||
function MediaPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'media' }>;
|
||||
}) {
|
||||
const { mime_type, duration_ms } = particle.properties;
|
||||
const isVideo = mime_type.startsWith("video");
|
||||
const isVideo = mime_type.startsWith('video');
|
||||
const durationSec = Math.round(duration_ms / 1000);
|
||||
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
|
||||
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`;
|
||||
|
||||
if (isVideo) {
|
||||
return <VideoThumbnail particleId={particle.properties.object_id} duration={durationLabel} />;
|
||||
return (
|
||||
<VideoThumbnail
|
||||
particleId={particle.properties.object_id}
|
||||
duration={durationLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -115,14 +128,16 @@ function VideoThumbnail({
|
||||
);
|
||||
}
|
||||
|
||||
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
|
||||
function QuestPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'quest' }>;
|
||||
}) {
|
||||
const { title, status } = particle.properties;
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
||||
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
|
||||
<p className="line-clamp-2 text-center text-sm font-medium">
|
||||
{title}
|
||||
</p>
|
||||
<p className="line-clamp-2 text-center text-sm font-medium">{title}</p>
|
||||
{status && (
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
{status}
|
||||
@@ -132,7 +147,11 @@ function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest
|
||||
);
|
||||
}
|
||||
|
||||
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
|
||||
function PaperPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'paper' }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
|
||||
<BookOpen className="h-6 w-6 text-blue-600/70 dark:text-blue-400/70" />
|
||||
@@ -143,7 +162,11 @@ function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper
|
||||
);
|
||||
}
|
||||
|
||||
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
|
||||
function FilePreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'file' }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
|
||||
<FileIcon className="h-6 w-6 text-emerald-600/70 dark:text-emerald-400/70" />
|
||||
@@ -154,7 +177,11 @@ function FilePreview({ particle }: { particle: Extract<Particle, { type: "file"
|
||||
);
|
||||
}
|
||||
|
||||
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
|
||||
function FolderPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'folder' }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
|
||||
<FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Lock } from "lucide-react";
|
||||
import { useLiveParticle } from "@/hooks/use-particle";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { StreamView } from "@/features/particles/stream-view";
|
||||
import { FolderView } from "@/features/particles/folder-view";
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useLiveParticle } from '@/hooks/use-particle';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { StreamView } from '@/features/particles/stream-view';
|
||||
import { FolderView } from '@/features/particles/folder-view';
|
||||
|
||||
/**
|
||||
* Route-level component for /:networkId/*.
|
||||
@@ -16,9 +15,11 @@ import { FolderView } from "@/features/particles/folder-view";
|
||||
* the appropriate view based on particle type.
|
||||
*/
|
||||
export default function ParticleViewResolver() {
|
||||
const { networkId, "*": rest } = useParams();
|
||||
const segments = (rest ?? "").split("/").filter(Boolean);
|
||||
const path = particlePath(networkId!, segments); // path of current container particle
|
||||
const { networkId, '*': rest } = useParams();
|
||||
if (!networkId)
|
||||
throw new Error('ParticleViewResolver requires a :networkId route param');
|
||||
const segments = (rest ?? '').split('/').filter(Boolean);
|
||||
const path = particlePath(networkId, segments); // path of current container particle
|
||||
|
||||
const { particle, isLoading, error } = useLiveParticle(path);
|
||||
|
||||
@@ -38,9 +39,9 @@ export default function ParticleViewResolver() {
|
||||
}
|
||||
|
||||
switch (particle.type) {
|
||||
case "stream":
|
||||
case 'stream':
|
||||
return <StreamView streamParticle={particle} path={path} />;
|
||||
case "folder":
|
||||
case 'folder':
|
||||
return <FolderView folderParticle={particle} path={path} />;
|
||||
default:
|
||||
return (
|
||||
@@ -59,7 +60,7 @@ function InaccessibleParticle() {
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh the networks list so the home page reflects current access.
|
||||
queryClient.invalidateQueries({ queryKey: ["networks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ['networks'] });
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
@@ -71,7 +72,7 @@ function InaccessibleParticle() {
|
||||
It may have been deleted, or your access was removed.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigate("/", { replace: true })}>
|
||||
<Button size="sm" onClick={() => navigate('/', { replace: true })}>
|
||||
Go home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { HumanPresence } from "@/hooks/use-presence-positions";
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { HumanPresence } from '@/hooks/use-presence-positions';
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 3;
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -18,7 +18,7 @@ interface PlaybackPageIndicatorProps {
|
||||
/** Set of humanIds currently online in the stream channel. */
|
||||
onlineHumanIds?: Set<string>;
|
||||
/** Render only avatars or only tracks. Omit to render both. */
|
||||
layer?: "avatars" | "tracks";
|
||||
layer?: 'avatars' | 'tracks';
|
||||
}
|
||||
|
||||
export function PlaybackPageIndicator({
|
||||
@@ -32,8 +32,8 @@ export function PlaybackPageIndicator({
|
||||
}: PlaybackPageIndicatorProps) {
|
||||
if (total === 0) return null;
|
||||
|
||||
const showAvatars = layer !== "tracks";
|
||||
const showTracks = layer !== "avatars";
|
||||
const showAvatars = layer !== 'tracks';
|
||||
const showTracks = layer !== 'avatars';
|
||||
|
||||
const paginated = total > PAGE_SIZE;
|
||||
const safeCurrent = current < 0 ? 0 : current;
|
||||
@@ -84,11 +84,12 @@ export function PlaybackPageIndicator({
|
||||
style={{
|
||||
width:
|
||||
i < current
|
||||
? "100%"
|
||||
? '100%'
|
||||
: i === current
|
||||
? `${progress * 100}%`
|
||||
: "0%",
|
||||
transition: i === current ? "width 300ms linear" : "none",
|
||||
: '0%',
|
||||
transition:
|
||||
i === current ? 'width 300ms linear' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
@@ -160,7 +161,14 @@ function SegmentPresenceAvatars({
|
||||
{visible.map((human) => (
|
||||
<Tooltip key={human.humanId}>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar size="xs" className={onlineHumanIds?.has(human.humanId) ? "ring-2 ring-green-500" : "ring-1 ring-black/50"}>
|
||||
<Avatar
|
||||
size="xs"
|
||||
className={
|
||||
onlineHumanIds?.has(human.humanId)
|
||||
? 'ring-2 ring-green-500'
|
||||
: 'ring-1 ring-black/50'
|
||||
}
|
||||
>
|
||||
<AvatarFallback>
|
||||
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
@@ -172,9 +180,7 @@ function SegmentPresenceAvatars({
|
||||
</Tooltip>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="text-[10px] text-white/70 pl-1">
|
||||
+{overflow}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Type, X } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import type { Human } from "@/api/types";
|
||||
import { useState } from 'react';
|
||||
import { Plus, Type, X } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
interface ReactionBarProps {
|
||||
reactions: Reactions;
|
||||
@@ -20,7 +23,7 @@ const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||
function getReactorNames(humanIds: string[], humans?: Human[]): string {
|
||||
return humanIds
|
||||
.map((id) => resolveHumanDisplay(id, humans).displayName)
|
||||
.join(", ");
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function getReactorList(
|
||||
@@ -61,18 +64,21 @@ export function ReactionBar({
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{/* Emoji reaction pills */}
|
||||
{activeEmojis.map((emoji) => {
|
||||
const reactors = reactions![emoji];
|
||||
const reactors = reactions?.[emoji] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
return (
|
||||
<Tooltip key={emoji}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(emoji);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors",
|
||||
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
||||
isMine
|
||||
? "bg-white/20 ring-1 ring-white/40"
|
||||
: "bg-black/40 hover:bg-black/50",
|
||||
? 'bg-white/20 ring-1 ring-white/40'
|
||||
: 'bg-black/40 hover:bg-black/50',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{emoji}</span>
|
||||
@@ -88,7 +94,7 @@ export function ReactionBar({
|
||||
|
||||
{/* Text reaction pills */}
|
||||
{activeTextReactions.map((text) => {
|
||||
const reactors = reactions![text];
|
||||
const reactors = reactions?.[text] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
||||
const reactorList = getReactorList(reactors, humans, currentHumanId);
|
||||
@@ -96,12 +102,15 @@ export function ReactionBar({
|
||||
<Tooltip key={text}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleToggle(text); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(text);
|
||||
}}
|
||||
className={cn(
|
||||
"flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors",
|
||||
'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors',
|
||||
isMine
|
||||
? "bg-white/20 ring-1 ring-white/40"
|
||||
: "bg-black/40 hover:bg-black/50",
|
||||
? 'bg-white/20 ring-1 ring-white/40'
|
||||
: 'bg-black/40 hover:bg-black/50',
|
||||
)}
|
||||
>
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
@@ -111,22 +120,30 @@ export function ReactionBar({
|
||||
</Avatar>
|
||||
<span className="truncate text-white/90">{text}</span>
|
||||
{reactors.length > 1 && (
|
||||
<span className="shrink-0 text-white/60">{reactors.length}</span>
|
||||
<span className="shrink-0 text-white/60">
|
||||
{reactors.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-[260px] space-y-1.5 text-xs">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="max-w-[260px] space-y-1.5 text-xs"
|
||||
>
|
||||
<div className="font-medium">“{text}”</div>
|
||||
<ul className="flex flex-col gap-0.5 opacity-80">
|
||||
{reactorList.map((r) => (
|
||||
<li key={r.id} className={cn(r.isMine && "font-medium opacity-100")}>
|
||||
<li
|
||||
key={r.id}
|
||||
className={cn(r.isMine && 'font-medium opacity-100')}
|
||||
>
|
||||
{r.label}
|
||||
{r.isMine && <span className="ml-1 opacity-60">(you)</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-current/15 pt-1 text-[10px] opacity-60">
|
||||
{isMine ? "Click to remove" : "Click to add yours"}
|
||||
{isMine ? 'Click to remove' : 'Click to add yours'}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -141,7 +158,10 @@ export function ReactionBar({
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(emoji);
|
||||
}}
|
||||
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
|
||||
>
|
||||
{emoji}
|
||||
@@ -149,7 +169,10 @@ export function ReactionBar({
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(false); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(false);
|
||||
}}
|
||||
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
|
||||
>
|
||||
<X className="size-3 text-white/60" />
|
||||
@@ -160,18 +183,27 @@ export function ReactionBar({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onOpenTextReaction(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTextReaction();
|
||||
}}
|
||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
||||
>
|
||||
<Type className="size-3 text-white/60" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Quick reply <kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">R</kbd>
|
||||
Quick reply{' '}
|
||||
<kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">
|
||||
R
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(true);
|
||||
}}
|
||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
||||
>
|
||||
<Plus className="size-3 text-white/60" />
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { updateParticleProperties } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { updateParticleProperties } from '@/lib/firestore-particles';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface RenameStreamOverlayProps {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -18,23 +18,23 @@ export function RenameStreamOverlay({
|
||||
streamParticle,
|
||||
onClose,
|
||||
}: RenameStreamOverlayProps) {
|
||||
useSuspendPlayback(true, "rename-stream");
|
||||
useSuspendPlayback(true, 'rename-stream');
|
||||
|
||||
const [name, setName] = useState(streamParticle.properties.name);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const trimmed = name.trim();
|
||||
const canSave =
|
||||
!saving &&
|
||||
trimmed.length > 0 &&
|
||||
trimmed !== streamParticle.properties.name;
|
||||
!saving && trimmed.length > 0 && trimmed !== streamParticle.properties.name;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
|
||||
await updateParticleProperties<"stream">(docPath, { name: trimmed });
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id]),
|
||||
);
|
||||
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -43,14 +43,15 @@ export function RenameStreamOverlay({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handler, { capture: true });
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () =>
|
||||
window.removeEventListener('keydown', handler, { capture: true });
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
@@ -65,7 +66,7 @@ export function RenameStreamOverlay({
|
||||
<span className="text-xs text-white/30">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to close
|
||||
</span>
|
||||
</div>
|
||||
@@ -77,7 +78,7 @@ export function RenameStreamOverlay({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
|
||||
@@ -1,169 +1,179 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { Headphones } from "lucide-react";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import type { Particle, StreamProperties } from "@/api/types";
|
||||
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
||||
import { ParticlePreview } from "@/features/particles/particle-preview";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { Small } from "@/components/ui/typography";
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { Headphones } from 'lucide-react';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import type { Particle, StreamProperties } from '@/api/types';
|
||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
||||
import { ParticlePreview } from '@/features/particles/particle-preview';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { Small } from '@/components/ui/typography';
|
||||
|
||||
interface StreamCardProps {
|
||||
particle: Particle & { type: "stream"; properties: StreamProperties };
|
||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
||||
networkId: string;
|
||||
onClick: () => void;
|
||||
isSelected?: boolean;
|
||||
shortcutKey?: number;
|
||||
}
|
||||
|
||||
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function StreamCard({ particle, networkId, onClick, isSelected, shortcutKey }, ref) {
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||
const network = useNetwork(networkId);
|
||||
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
||||
function StreamCard(
|
||||
{ particle, networkId, onClick, isSelected, shortcutKey },
|
||||
ref,
|
||||
) {
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants &&
|
||||
particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
const otherEntry = particle.visible_to.find(
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace("human:", "");
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
const otherEntry = particle.visible_to.find(
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace('human:', '');
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestChild) {
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) return getInitials(creator.email);
|
||||
}
|
||||
if (latestChild) {
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) return getInitials(creator.email);
|
||||
}
|
||||
|
||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||
}, [
|
||||
isDM,
|
||||
particle.visible_to,
|
||||
particle.properties.name,
|
||||
userId,
|
||||
latestChild,
|
||||
network,
|
||||
]);
|
||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||
}, [
|
||||
isDM,
|
||||
particle.visible_to,
|
||||
particle.properties.name,
|
||||
userId,
|
||||
latestChild,
|
||||
network,
|
||||
]);
|
||||
|
||||
const isUnseen = useMemo(() => {
|
||||
if (!latestChild) return false;
|
||||
const latestChildTimestamp = latestChild.created_at.getTime();
|
||||
const userPlaybackPosition =
|
||||
particle.playback_markers?.[userId]?.getTime() ?? 0;
|
||||
return latestChildTimestamp > userPlaybackPosition;
|
||||
}, [latestChild, particle.playback_markers, userId]);
|
||||
const isUnseen = useMemo(() => {
|
||||
if (!latestChild) return false;
|
||||
const latestChildTimestamp = latestChild.created_at.getTime();
|
||||
const userPlaybackPosition =
|
||||
particle.playback_markers?.[userId]?.getTime() ?? 0;
|
||||
return latestChildTimestamp > userPlaybackPosition;
|
||||
}, [latestChild, particle.playback_markers, userId]);
|
||||
|
||||
// For media particles with a transcript, show it as an overlay on the preview
|
||||
const transcript =
|
||||
latestChild?.type === "media"
|
||||
? latestChild.properties.transcript?.transcript
|
||||
: undefined;
|
||||
// For media particles with a transcript, show it as an overlay on the preview
|
||||
const transcript =
|
||||
latestChild?.type === 'media'
|
||||
? latestChild.properties.transcript?.transcript
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") onClick();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
|
||||
isUnseen && "ring-2 ring-primary",
|
||||
isSelected && "ring-2 ring-ring",
|
||||
hasActiveHuddle && "ring-2 ring-red-500/70",
|
||||
)}
|
||||
>
|
||||
{/* Preview area */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
||||
{hasActiveHuddle && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20',
|
||||
isUnseen && 'ring-2 ring-primary',
|
||||
isSelected && 'ring-2 ring-ring',
|
||||
hasActiveHuddle && 'ring-2 ring-red-500/70',
|
||||
)}
|
||||
{shortcutKey && (
|
||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
)}
|
||||
{latestChild ? (
|
||||
<ParticlePreview particle={latestChild} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
No messages yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcript overlay for media with transcripts */}
|
||||
{transcript && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
|
||||
<p className="line-clamp-2 text-md leading-snug text-white/90">
|
||||
{transcript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Avatar className={cn("size-6 shrink-0", isUnseen && "ring-2 ring-primary")}>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Small
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
isUnseen
|
||||
? "font-semibold text-foreground"
|
||||
: "font-medium text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
</Small>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
>
|
||||
{/* Preview area */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
|
||||
</span>
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
||||
)}
|
||||
{latestChild && (
|
||||
<Small
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
isUnseen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<RelativeTimestamp date={latestChild.created_at} />
|
||||
</Small>
|
||||
{shortcutKey && (
|
||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
)}
|
||||
{isUnseen && (
|
||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
||||
{latestChild ? (
|
||||
<ParticlePreview particle={latestChild} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
No messages yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcript overlay for media with transcripts */}
|
||||
{transcript && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
|
||||
<p className="line-clamp-2 text-md leading-snug text-white/90">
|
||||
{transcript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Avatar
|
||||
className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')}
|
||||
>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Small
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnseen
|
||||
? 'font-semibold text-foreground'
|
||||
: 'font-medium text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
</Small>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">
|
||||
{huddleCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{latestChild && (
|
||||
<Small
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<RelativeTimestamp date={latestChild.created_at} />
|
||||
</Small>
|
||||
)}
|
||||
{isUnseen && (
|
||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { CircleCheckBig, CircleDot } from "lucide-react";
|
||||
import { updateStreamStatus } from "@/lib/firestore-particles";
|
||||
import { toFirestoreDocPath, particlePath } from "@/lib/particle-path";
|
||||
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
||||
} from '@/components/ui/context-menu';
|
||||
import { CircleCheckBig, CircleDot } from 'lucide-react';
|
||||
import { updateStreamStatus } from '@/lib/firestore-particles';
|
||||
import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
|
||||
import type { StreamParticle } from '@/hooks/use-stream-particles';
|
||||
|
||||
interface StreamContextMenuProps {
|
||||
particle: StreamParticle;
|
||||
@@ -15,12 +15,16 @@ interface StreamContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StreamContextMenu({ particle, networkId, children }: StreamContextMenuProps) {
|
||||
const isOpen = particle.status === "open";
|
||||
export function StreamContextMenu({
|
||||
particle,
|
||||
networkId,
|
||||
children,
|
||||
}: StreamContextMenuProps) {
|
||||
const isOpen = particle.status === 'open';
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
|
||||
|
||||
const toggleStatus = async () => {
|
||||
await updateStreamStatus(docPath, isOpen ? "closed" : "open");
|
||||
await updateStreamStatus(docPath, isOpen ? 'closed' : 'open');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, UserPlus, Globe, Users, Lock } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
buildCustomVisibility,
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
} from '@/lib/stream-visibility';
|
||||
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface StreamMembersOverlayProps {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
isCreator: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -29,12 +29,15 @@ export function StreamMembersOverlay({
|
||||
isCreator,
|
||||
onClose,
|
||||
}: StreamMembersOverlayProps) {
|
||||
useSuspendPlayback(true, "stream-members");
|
||||
useSuspendPlayback(true, 'stream-members');
|
||||
|
||||
const network = useNetwork(networkId);
|
||||
const humans = network?.humans ?? [];
|
||||
const creatorId = streamParticle.created_by_human_id;
|
||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||
const visibility = useMemo(
|
||||
() => parseVisibleTo(streamParticle.visible_to, networkId),
|
||||
[streamParticle.visible_to, networkId],
|
||||
);
|
||||
|
||||
const docPath = useMemo(
|
||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
||||
@@ -42,7 +45,7 @@ export function StreamMembersOverlay({
|
||||
);
|
||||
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
visibility.mode === 'network'
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const memberSet = new Set(memberIds);
|
||||
@@ -58,7 +61,7 @@ export function StreamMembersOverlay({
|
||||
|
||||
const removeMember = useCallback(
|
||||
(id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
if (visibility.mode !== 'custom') return;
|
||||
if (id === creatorId) return;
|
||||
const next = visibility.humanIds.filter((x) => x !== id);
|
||||
if (next.length === 0) return;
|
||||
@@ -69,7 +72,7 @@ export function StreamMembersOverlay({
|
||||
|
||||
const addMember = useCallback(
|
||||
(id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
if (visibility.mode !== 'custom') return;
|
||||
void updateParticleVisibleTo(
|
||||
docPath,
|
||||
buildCustomVisibility([...visibility.humanIds, id]),
|
||||
@@ -80,14 +83,15 @@ export function StreamMembersOverlay({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handler, { capture: true });
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () =>
|
||||
window.removeEventListener('keydown', handler, { capture: true });
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
@@ -103,7 +107,7 @@ export function StreamMembersOverlay({
|
||||
<span className="text-xs text-white/30">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to close
|
||||
</span>
|
||||
</div>
|
||||
@@ -116,13 +120,13 @@ export function StreamMembersOverlay({
|
||||
{isCreator ? (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
|
||||
<VisibilityPill
|
||||
active={visibility.mode === "network"}
|
||||
active={visibility.mode === 'network'}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
label="Network-wide"
|
||||
onClick={setNetworkWide}
|
||||
/>
|
||||
<VisibilityPill
|
||||
active={visibility.mode === "custom"}
|
||||
active={visibility.mode === 'custom'}
|
||||
icon={<Lock className="size-3.5" />}
|
||||
label="Specific people"
|
||||
onClick={setCustomOnlyCreator}
|
||||
@@ -130,10 +134,10 @@ export function StreamMembersOverlay({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-white/70">
|
||||
{visibility.mode === "network" ? (
|
||||
{visibility.mode === 'network' ? (
|
||||
<>
|
||||
<Globe className="size-3.5 text-white/40" />
|
||||
<span>Everyone in {network?.name ?? "network"}</span>
|
||||
<span>Everyone in {network?.name ?? 'network'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -148,7 +152,7 @@ export function StreamMembersOverlay({
|
||||
{/* Member list */}
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
{visibility.mode === "network" ? "Has access" : "People"}{" "}
|
||||
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
||||
<span className="ml-1 text-white/20">{memberIds.length}</span>
|
||||
</h3>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
@@ -157,7 +161,7 @@ export function StreamMembersOverlay({
|
||||
const display = resolveHumanDisplay(id, humans);
|
||||
const isCreatorRow = id === creatorId;
|
||||
const canRemove =
|
||||
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
||||
isCreator && visibility.mode === 'custom' && !isCreatorRow;
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
@@ -170,8 +174,8 @@ export function StreamMembersOverlay({
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 truncate",
|
||||
!display.exists && "italic text-white/40",
|
||||
'flex-1 truncate',
|
||||
!display.exists && 'italic text-white/40',
|
||||
)}
|
||||
>
|
||||
{display.displayName}
|
||||
@@ -199,44 +203,50 @@ export function StreamMembersOverlay({
|
||||
</section>
|
||||
|
||||
{/* Add */}
|
||||
{isCreator && visibility.mode === "custom" && availableToAdd.length > 0 && (
|
||||
<section className="mt-4 border-t border-white/5 pt-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
<UserPlus className="size-3" />
|
||||
Add people
|
||||
</h3>
|
||||
<ScrollArea className="max-h-32">
|
||||
<ul className="flex flex-col gap-0.5 pr-2">
|
||||
{availableToAdd.map((human) => (
|
||||
<li key={human.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addMember(human.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(human.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">{human.email_prefix}</span>
|
||||
<UserPlus className="size-3.5 text-white/30" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
)}
|
||||
{isCreator &&
|
||||
visibility.mode === 'custom' &&
|
||||
availableToAdd.length > 0 && (
|
||||
<section className="mt-4 border-t border-white/5 pt-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
<UserPlus className="size-3" />
|
||||
Add people
|
||||
</h3>
|
||||
<ScrollArea className="max-h-32">
|
||||
<ul className="flex flex-col gap-0.5 pr-2">
|
||||
{availableToAdd.map((human) => (
|
||||
<li key={human.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addMember(human.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(human.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">
|
||||
{human.email_prefix}
|
||||
</span>
|
||||
<UserPlus className="size-3.5 text-white/30" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isCreator && visibility.mode === "custom" && availableToAdd.length === 0 && (
|
||||
<p className="mt-4 text-center text-xs text-white/30">
|
||||
<Users className="mr-1 inline size-3" />
|
||||
Everyone in the network is already a member
|
||||
</p>
|
||||
)}
|
||||
{isCreator &&
|
||||
visibility.mode === 'custom' &&
|
||||
availableToAdd.length === 0 && (
|
||||
<p className="mt-4 text-center text-xs text-white/30">
|
||||
<Users className="mr-1 inline size-3" />
|
||||
Everyone in the network is already a member
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
@@ -259,10 +269,10 @@ function VisibilityPill({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
||||
active
|
||||
? "bg-white/10 text-white/90"
|
||||
: "text-white/50 hover:text-white/80",
|
||||
? 'bg-white/10 text-white/90'
|
||||
: 'text-white/50 hover:text-white/80',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -7,16 +7,15 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useChannel } from "@/hooks/use-channel";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { ChannelMessage } from "@/lib/pusher-client";
|
||||
} from 'react';
|
||||
import { useChannel } from '@/hooks/use-channel';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ComposingMode = "recording" | "typing" | "screen";
|
||||
export type ComposingMode = 'recording' | 'typing' | 'screen';
|
||||
|
||||
export interface ComposingUser {
|
||||
humanId: string;
|
||||
@@ -93,14 +92,14 @@ export function StreamPresenceProvider({
|
||||
// Skip own events
|
||||
if (msg.humanId === currentUserId) continue;
|
||||
|
||||
if (payload.type === "composing_start" && payload.mode) {
|
||||
if (payload.type === 'composing_start' && payload.mode) {
|
||||
map.set(msg.humanId, {
|
||||
humanId: msg.humanId,
|
||||
mode: payload.mode as ComposingMode,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
changed = true;
|
||||
} else if (payload.type === "composing_stop") {
|
||||
} else if (payload.type === 'composing_stop') {
|
||||
if (map.delete(msg.humanId)) changed = true;
|
||||
}
|
||||
}
|
||||
@@ -156,14 +155,14 @@ export function StreamPresenceProvider({
|
||||
const startComposing = useCallback(
|
||||
(mode: ComposingMode) => {
|
||||
// Send immediately
|
||||
sendMessage({ type: "composing_start", mode });
|
||||
sendMessage({ type: 'composing_start', mode });
|
||||
|
||||
// Clear any existing heartbeat
|
||||
clearInterval(heartbeatRef.current);
|
||||
|
||||
// Start heartbeat
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
sendMessage({ type: "composing_start", mode });
|
||||
sendMessage({ type: 'composing_start', mode });
|
||||
}, COMPOSING_HEARTBEAT_MS);
|
||||
},
|
||||
[sendMessage],
|
||||
@@ -172,7 +171,7 @@ export function StreamPresenceProvider({
|
||||
const stopComposing = useCallback(() => {
|
||||
clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = undefined;
|
||||
sendMessage({ type: "composing_stop" });
|
||||
sendMessage({ type: 'composing_stop' });
|
||||
}, [sendMessage]);
|
||||
|
||||
// Cleanup heartbeat on unmount
|
||||
@@ -207,7 +206,7 @@ function useStreamPresenceContext() {
|
||||
const ctx = useContext(StreamPresenceContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useStreamPresence must be used within a StreamPresenceProvider",
|
||||
'useStreamPresence must be used within a StreamPresenceProvider',
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
|
||||
@@ -1,47 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe, Trash2 } from "lucide-react";
|
||||
import { updateStreamStatus } from "@/lib/firestore-particles";
|
||||
import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay";
|
||||
import { DeleteParticleOverlay } from "@/features/particles/delete-particle-overlay";
|
||||
import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay";
|
||||
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { useStreamPresence } from "@/features/particles/stream-presence-context";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Settings,
|
||||
CircleCheckBig,
|
||||
CircleDot,
|
||||
EllipsisVertical,
|
||||
Pencil,
|
||||
Globe,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { updateStreamStatus } from '@/lib/firestore-particles';
|
||||
import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
|
||||
import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
|
||||
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
|
||||
import { parseVisibleTo } from '@/lib/stream-visibility';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { useStreamPresence } from '@/features/particles/stream-presence-context';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { requireDesktop } from '@/lib/platform/desktop-only';
|
||||
|
||||
function getParticleDisplayName(particle: Particle): string {
|
||||
switch (particle.type) {
|
||||
case "stream":
|
||||
case "folder":
|
||||
case 'stream':
|
||||
case 'folder':
|
||||
return particle.properties.name;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
case 'file':
|
||||
return particle.properties.filename;
|
||||
case "text":
|
||||
case 'text':
|
||||
return particle.properties.content.slice(0, 30);
|
||||
case "media":
|
||||
case 'media':
|
||||
return particle.type;
|
||||
}
|
||||
}
|
||||
@@ -49,7 +67,7 @@ function getParticleDisplayName(particle: Particle): string {
|
||||
interface TopBarProps {
|
||||
networkId: string;
|
||||
particle: Particle | null;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
}
|
||||
|
||||
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
@@ -65,18 +83,20 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
!!particle &&
|
||||
!!userId &&
|
||||
particle.created_by_human_id === userId &&
|
||||
particle.type !== "stream" &&
|
||||
particle.type !== "folder" &&
|
||||
particle.type !== 'stream' &&
|
||||
particle.type !== 'folder' &&
|
||||
!isParticleDeleted(particle);
|
||||
|
||||
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
|
||||
const hasActiveHuddle = huddleParticipants.length > 0;
|
||||
|
||||
const handleJoinHuddle = () => {
|
||||
if (!requireDesktop("Huddle")) return;
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
platform.huddle.open({ token, serverUrl: server_url });
|
||||
});
|
||||
if (!requireDesktop('Huddle')) return;
|
||||
apiClient
|
||||
.getLivekitToken(networkId, streamParticle.id)
|
||||
.then(({ token, server_url }) => {
|
||||
platform.huddle.open({ token, serverUrl: server_url });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -88,7 +108,9 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
{streamParticle && (
|
||||
<>
|
||||
<BreadcrumbItem className="text-xs">
|
||||
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
|
||||
<BreadcrumbPage>
|
||||
{getParticleDisplayName(streamParticle)}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
@@ -97,7 +119,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="text-xs">
|
||||
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
|
||||
<BreadcrumbPage>
|
||||
<ParticleBreadcrumbContent
|
||||
particle={particle}
|
||||
networkId={networkId}
|
||||
/>
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
@@ -134,7 +161,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{streamParticle.status === "closed" && (
|
||||
{streamParticle.status === 'closed' && (
|
||||
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
|
||||
<CircleCheckBig className="size-3" />
|
||||
Closed
|
||||
@@ -160,11 +187,16 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
|
||||
await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id]),
|
||||
);
|
||||
await updateStreamStatus(
|
||||
docPath,
|
||||
streamParticle.status === 'open' ? 'closed' : 'open',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{streamParticle.status === "open" ? (
|
||||
{streamParticle.status === 'open' ? (
|
||||
<>
|
||||
<CircleCheckBig className="size-4" />
|
||||
Close stream
|
||||
@@ -191,7 +223,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
Delete particle
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => navigate("/settings")}>
|
||||
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
@@ -234,7 +266,7 @@ function MembersIndicator({
|
||||
onClick,
|
||||
}: {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const network = useNetwork(networkId);
|
||||
@@ -242,7 +274,7 @@ function MembersIndicator({
|
||||
const humans = network?.humans ?? [];
|
||||
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
visibility.mode === 'network'
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const shownMembers = memberIds
|
||||
@@ -258,7 +290,7 @@ function MembersIndicator({
|
||||
onClick={onClick}
|
||||
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10"
|
||||
>
|
||||
{visibility.mode === "network" ? (
|
||||
{visibility.mode === 'network' ? (
|
||||
<>
|
||||
<Globe className="size-3 text-white/50" />
|
||||
<span>Everyone</span>
|
||||
@@ -274,32 +306,43 @@ function MembersIndicator({
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{overflow > 0 && <span className="text-white/50">+{overflow}</span>}
|
||||
{overflow > 0 && (
|
||||
<span className="text-white/50">+{overflow}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{visibility.mode === "network"
|
||||
? `Everyone in ${network?.name ?? "network"}`
|
||||
: `${memberIds.length} ${memberIds.length === 1 ? "member" : "members"}`}
|
||||
{visibility.mode === 'network'
|
||||
? `Everyone in ${network?.name ?? 'network'}`
|
||||
: `${memberIds.length} ${memberIds.length === 1 ? 'member' : 'members'}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
|
||||
function ParticleBreadcrumbContent({
|
||||
particle,
|
||||
networkId,
|
||||
}: {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
}) {
|
||||
const network = useNetwork(networkId);
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
const display = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
|
||||
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
|
||||
const display = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const isOnline = particle.created_by_human_id
|
||||
? onlineHumanIds.has(particle.created_by_human_id)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}>
|
||||
<AvatarFallback>
|
||||
{display.initials}
|
||||
</AvatarFallback>
|
||||
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
|
||||
<AvatarFallback>{display.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
||||
</span>
|
||||
|
||||
@@ -1,41 +1,73 @@
|
||||
import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
||||
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
|
||||
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
||||
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
|
||||
import { TextParticleView } from "@/features/particles/text-particle-view";
|
||||
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
|
||||
import { DeletedParticleView } from "@/features/particles/deleted-particle-view";
|
||||
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { toggleParticleReaction } from "@/lib/firestore-particles";
|
||||
import { ReactionBar } from "@/features/particles/reaction-bar";
|
||||
import { TextReactionInput } from "@/features/particles/text-reaction-input";
|
||||
import { TopBar } from "@/features/particles/stream-top-bar";
|
||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
||||
import { usePresencePositions } from "@/hooks/use-presence-positions";
|
||||
import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStreamComposingBroadcast, type ComposingMode } from "@/features/particles/stream-presence-context";
|
||||
import { ComposingIndicator } from "@/components/composing-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMount } from "react-use";
|
||||
import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-store";
|
||||
import { usePlaybackKeys } from "@/hooks/use-playback-keys";
|
||||
import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys";
|
||||
import { useStreamActionKeys } from "@/hooks/use-stream-action-keys";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||
import {
|
||||
parseParticlePath,
|
||||
particlePath,
|
||||
toFirestoreDocPath,
|
||||
type ParticlePath,
|
||||
} from '@/lib/particle-path';
|
||||
import {
|
||||
ComposeOverlay,
|
||||
type ComposeStep,
|
||||
} from '@/features/compose/compose-overlay';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
import { PlaybackPageIndicator } from '@/features/particles/playback-page-indicator';
|
||||
import {
|
||||
MediaParticleView,
|
||||
type MediaParticleHandle,
|
||||
} from '@/features/particles/media-particle-view';
|
||||
import { TextParticleView } from '@/features/particles/text-particle-view';
|
||||
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
|
||||
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
|
||||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
import {
|
||||
KeybindingsOverlay,
|
||||
type KeybindingGroup,
|
||||
} from '@/components/keybindings-overlay';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { toggleParticleReaction } from '@/lib/firestore-particles';
|
||||
import { ReactionBar } from '@/features/particles/reaction-bar';
|
||||
import { TextReactionInput } from '@/features/particles/text-reaction-input';
|
||||
import { TopBar } from '@/features/particles/stream-top-bar';
|
||||
import { useStreamPlayback } from '@/hooks/use-stream-playback';
|
||||
import { usePrefetchAdjacentMedia } from '@/hooks/use-prefetch-adjacent-media';
|
||||
import { usePresencePositions } from '@/hooks/use-presence-positions';
|
||||
import {
|
||||
StreamPresenceProvider,
|
||||
useStreamPresence,
|
||||
useStreamComposing,
|
||||
useStreamComposingBroadcast,
|
||||
type ComposingMode,
|
||||
} from '@/features/particles/stream-presence-context';
|
||||
import { ComposingIndicator } from '@/components/composing-indicator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMount } from 'react-use';
|
||||
import {
|
||||
usePlaybackPauseStore,
|
||||
selectIsPaused,
|
||||
} from '@/stores/playback-pause-store';
|
||||
import { usePlaybackKeys } from '@/hooks/use-playback-keys';
|
||||
import { useStreamNavigationKeys } from '@/hooks/use-stream-navigation-keys';
|
||||
import { useStreamActionKeys } from '@/hooks/use-stream-action-keys';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { requireDesktop } from '@/lib/platform/desktop-only';
|
||||
|
||||
function getReactions(particle: Particle): Record<string, string[]> | undefined {
|
||||
function getReactions(
|
||||
particle: Particle,
|
||||
): Record<string, string[]> | undefined {
|
||||
if (isParticleDeleted(particle)) return undefined;
|
||||
if (particle.type === "media" || particle.type === "text") return particle.reactions;
|
||||
if (particle.type === 'media' || particle.type === 'text')
|
||||
return particle.reactions;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -44,31 +76,33 @@ function getReactions(particle: Particle): Record<string, string[]> | undefined
|
||||
const EXIT_DELAY_MS = 5000;
|
||||
const EXIT_TICK_MS = 100;
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
type PlaybackStatus = 'idle' | 'playing' | 'ended';
|
||||
|
||||
function useExitCountdown(
|
||||
status: PlaybackStatus,
|
||||
disabled: boolean,
|
||||
onExit: () => void,
|
||||
) {
|
||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
||||
const [remainingMs, setRemainingMs] = useState<number | null>(
|
||||
status === 'ended' ? EXIT_DELAY_MS : null,
|
||||
);
|
||||
const [prevStatus, setPrevStatus] = useState(status);
|
||||
|
||||
const handleExit = useEffectEvent(() => {
|
||||
onExit();
|
||||
});
|
||||
|
||||
// Start/cancel countdown based on playback status
|
||||
useEffect(() => {
|
||||
if (status === "ended") {
|
||||
setRemainingMs(EXIT_DELAY_MS);
|
||||
} else {
|
||||
setRemainingMs(null);
|
||||
}
|
||||
}, [status]);
|
||||
// Start the countdown when playback ends; cancel it otherwise.
|
||||
if (status !== prevStatus) {
|
||||
setPrevStatus(status);
|
||||
setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null);
|
||||
}
|
||||
|
||||
const isCountingDown = remainingMs !== null && remainingMs > 0;
|
||||
|
||||
// Tick the countdown down (pauses when compose is active)
|
||||
useEffect(() => {
|
||||
if (remainingMs === null || remainingMs <= 0 || disabled) return;
|
||||
if (!isCountingDown || disabled) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setRemainingMs((prev) => {
|
||||
@@ -79,7 +113,7 @@ function useExitCountdown(
|
||||
}, EXIT_TICK_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [remainingMs !== null && remainingMs > 0, disabled]);
|
||||
}, [isCountingDown, disabled]);
|
||||
|
||||
// Navigate once countdown hits zero
|
||||
useEffect(() => {
|
||||
@@ -95,36 +129,36 @@ function useExitCountdown(
|
||||
|
||||
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
||||
{
|
||||
label: "Navigation",
|
||||
label: 'Navigation',
|
||||
bindings: [
|
||||
{ keys: ["←", "→", "↑", "↓"], description: "Previous / next particle" },
|
||||
{ keys: ["Esc"], description: "Back to network" },
|
||||
{ keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
|
||||
{ keys: ['Esc'], description: 'Back to network' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Playback",
|
||||
label: 'Playback',
|
||||
bindings: [
|
||||
{ keys: ["Space"], description: "Toggle pause" },
|
||||
{ keys: ["Hold", "Space"], description: "Pause while held" },
|
||||
{ keys: ["Hold", "Shift"], description: "1.5× speed" },
|
||||
{ keys: ["Shift", "←", "→"], description: "Seek ±5s" },
|
||||
{ keys: ['Space'], description: 'Toggle pause' },
|
||||
{ keys: ['Hold', 'Space'], description: 'Pause while held' },
|
||||
{ keys: ['Hold', 'Shift'], description: '1.5× speed' },
|
||||
{ keys: ['Shift', '←', '→'], description: 'Seek ±5s' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Compose",
|
||||
label: 'Compose',
|
||||
bindings: [
|
||||
{ keys: ["Hold", "`"], description: "Reply" },
|
||||
{ keys: ["S"], description: "Screen record" },
|
||||
{ keys: ["T"], description: "Text compose" },
|
||||
{ keys: ["V"], description: "Toggle video / audio" },
|
||||
{ keys: ["H"], description: "Join huddle" },
|
||||
{ keys: ['Hold', '`'], description: 'Reply' },
|
||||
{ keys: ['S'], description: 'Screen record' },
|
||||
{ keys: ['T'], description: 'Text compose' },
|
||||
{ keys: ['V'], description: 'Toggle video / audio' },
|
||||
{ keys: ['H'], description: 'Join huddle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Reactions",
|
||||
label: 'Reactions',
|
||||
bindings: [
|
||||
{ keys: ["1-7"], description: "Toggle emoji reaction" },
|
||||
{ keys: ["R"], description: "Quick text reply" },
|
||||
{ keys: ['1-7'], description: 'Toggle emoji reaction' },
|
||||
{ keys: ['R'], description: 'Quick text reply' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -132,7 +166,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
||||
// --- StreamView ---
|
||||
|
||||
interface StreamViewProps {
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
path: ParticlePath;
|
||||
}
|
||||
|
||||
@@ -162,7 +196,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
next,
|
||||
prev,
|
||||
goTo,
|
||||
goToParticle
|
||||
goToParticle,
|
||||
} = useStreamPlayback(streamParticle, path);
|
||||
|
||||
usePrefetchAdjacentMedia(children, currentIndex);
|
||||
@@ -185,30 +219,43 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
const mediaRef = useRef<MediaParticleHandle>(null);
|
||||
|
||||
const handleToggleReaction = useCallback((emoji: string) => {
|
||||
if (!authedUser || !currentParticle) return;
|
||||
if (isParticleDeleted(currentParticle)) return;
|
||||
const handleToggleReaction = useCallback(
|
||||
(emoji: string) => {
|
||||
if (!authedUser || !currentParticle) return;
|
||||
if (isParticleDeleted(currentParticle)) return;
|
||||
|
||||
const currentParticleDocPath = currentParticle
|
||||
? toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id, currentParticle.id]),
|
||||
)
|
||||
: null;
|
||||
if (!currentParticleDocPath) return;
|
||||
|
||||
const currentParticleDocPath = currentParticle
|
||||
? toFirestoreDocPath(particlePath(networkId, [streamParticle.id, currentParticle.id]))
|
||||
: null;
|
||||
if (!currentParticleDocPath) return;
|
||||
|
||||
const reactions = getReactions(currentParticle);
|
||||
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
|
||||
}, [authedUser, currentParticle]);
|
||||
const reactions = getReactions(currentParticle);
|
||||
toggleParticleReaction(
|
||||
currentParticleDocPath,
|
||||
emoji,
|
||||
authedUser.id,
|
||||
reactions,
|
||||
);
|
||||
},
|
||||
[authedUser, currentParticle, networkId, streamParticle.id],
|
||||
);
|
||||
|
||||
const [composeActive, setComposeActive] = useState(false);
|
||||
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
||||
const [composeStep, setComposeStep] = useState<ComposeStep>('idle');
|
||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
||||
|
||||
const handleSubmitTextReaction = useCallback((text: string) => {
|
||||
handleToggleReaction(text);
|
||||
}, [handleToggleReaction]);
|
||||
const handleSubmitTextReaction = useCallback(
|
||||
(text: string) => {
|
||||
handleToggleReaction(text);
|
||||
},
|
||||
[handleToggleReaction],
|
||||
);
|
||||
|
||||
const { fastPlayback } = usePlaybackKeys({ mediaRef });
|
||||
|
||||
@@ -221,15 +268,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
});
|
||||
|
||||
const handleOpenHuddle = useCallback(() => {
|
||||
if (!requireDesktop("Huddle")) return;
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
platform.huddle.open({ token, serverUrl: server_url });
|
||||
});
|
||||
if (!requireDesktop('Huddle')) return;
|
||||
apiClient
|
||||
.getLivekitToken(networkId, streamParticle.id)
|
||||
.then(({ token, server_url }) => {
|
||||
platform.huddle.open({ token, serverUrl: server_url });
|
||||
});
|
||||
navigate(`/${networkId}`);
|
||||
}, [networkId, streamParticle.id, navigate]);
|
||||
|
||||
const handleToggleRecordingMode = useCallback(() => {
|
||||
setRecordingMode(recordingMode === "video" ? "audio" : "video");
|
||||
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video');
|
||||
}, [recordingMode, setRecordingMode]);
|
||||
|
||||
const handleToggleKeybindings = useCallback(() => {
|
||||
@@ -249,11 +298,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
const stepToMode: Record<string, ComposingMode | null> = {
|
||||
idle: null,
|
||||
submitting: null,
|
||||
recording: "recording",
|
||||
typing: "typing",
|
||||
reviewing: "typing",
|
||||
configuring: "typing",
|
||||
picking: "screen",
|
||||
recording: 'recording',
|
||||
typing: 'typing',
|
||||
reviewing: 'typing',
|
||||
configuring: 'typing',
|
||||
picking: 'screen',
|
||||
};
|
||||
const mode = stepToMode[composeStep] ?? null;
|
||||
if (mode) {
|
||||
@@ -274,35 +323,35 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
useEffect(() => () => clearTimeout(idleTimerRef.current), []);
|
||||
|
||||
// Always show controls when compose is active or exit countdown is visible
|
||||
const controlsVisible = showControls || composeActive || status === "ended";
|
||||
const controlsVisible = showControls || composeActive || status === 'ended';
|
||||
|
||||
const handleExitNavigate = useCallback(() => {
|
||||
navigate(`/${networkId}`);
|
||||
}, [navigate, networkId]);
|
||||
|
||||
const exitRemainingMs = useExitCountdown(
|
||||
status,
|
||||
paused,
|
||||
handleExitNavigate,
|
||||
);
|
||||
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
|
||||
|
||||
// Reset progress when particle changes
|
||||
useEffect(() => {
|
||||
// Reset progress when the particle changes.
|
||||
if (currentParticle?.id !== prevParticleId) {
|
||||
setPrevParticleId(currentParticle?.id);
|
||||
setProgress(0);
|
||||
}, [currentParticle?.id]);
|
||||
}
|
||||
|
||||
const handleParticleCreated = useCallback((particleId: string) => {
|
||||
if (currentIndex === -1) return;
|
||||
const handleParticleCreated = useCallback(
|
||||
(particleId: string) => {
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// When local user is at children.length - 1, and they send a new particle,
|
||||
// we want to navigate to the new particle immediately so the user is considered caught up in the stream.
|
||||
// In other cases (e.g. when user is in the middle of the stream and new particles are added),
|
||||
// we don't want to disrupt their current position by jumping them to the end of the stream.
|
||||
// NOTE: at this point, `children` contains stale data from the time when compose was sending, so it doesn't include the new particle yet.
|
||||
if (currentIndex === children.length - 1) {
|
||||
goToParticle(particleId);
|
||||
}
|
||||
}, [children, goToParticle, currentIndex]);
|
||||
// When local user is at children.length - 1, and they send a new particle,
|
||||
// we want to navigate to the new particle immediately so the user is considered caught up in the stream.
|
||||
// In other cases (e.g. when user is in the middle of the stream and new particles are added),
|
||||
// we don't want to disrupt their current position by jumping them to the end of the stream.
|
||||
// NOTE: at this point, `children` contains stale data from the time when compose was sending, so it doesn't include the new particle yet.
|
||||
if (currentIndex === children.length - 1) {
|
||||
goToParticle(particleId);
|
||||
}
|
||||
},
|
||||
[children, goToParticle, currentIndex],
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
@@ -318,7 +367,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
networkId={networkId}
|
||||
targetPath={path}
|
||||
onActiveChange={setComposeActive}
|
||||
disabled={streamParticle.status === "closed"}
|
||||
disabled={streamParticle.status === 'closed'}
|
||||
onParticleCreated={handleParticleCreated}
|
||||
/>
|
||||
</div>
|
||||
@@ -339,7 +388,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
);
|
||||
}
|
||||
switch (particle.type) {
|
||||
case "media":
|
||||
case 'media':
|
||||
return (
|
||||
<MediaParticleView
|
||||
ref={mediaRef}
|
||||
@@ -351,7 +400,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
case 'text':
|
||||
return (
|
||||
<TextParticleView
|
||||
key={particle.id}
|
||||
@@ -363,7 +412,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <FallbackParticleView particle={particle} networkId={networkId} />;
|
||||
return (
|
||||
<FallbackParticleView particle={particle} networkId={networkId} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +429,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
{/* TopBar — always visible */}
|
||||
<div className="z-10 absolute left-0 right-0 pt-2">
|
||||
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
|
||||
<TopBar
|
||||
networkId={networkId}
|
||||
particle={currentParticle}
|
||||
streamParticle={streamParticle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main playback area */}
|
||||
@@ -408,7 +463,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
|
||||
<ReactionBar
|
||||
reactions={getReactions(currentParticle)}
|
||||
currentHumanId={authedUser?.id ?? ""}
|
||||
currentHumanId={authedUser?.id ?? ''}
|
||||
humans={network?.humans}
|
||||
onToggle={handleToggleReaction}
|
||||
onOpenTextReaction={() => setTextReactionOpen(true)}
|
||||
@@ -422,14 +477,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
)}
|
||||
|
||||
{/* Composing indicator — left edge, always visible */}
|
||||
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
|
||||
<ComposingIndicator
|
||||
users={composingUsers}
|
||||
networkHumans={network?.humans}
|
||||
/>
|
||||
|
||||
<ComposeOverlay
|
||||
networkId={networkId}
|
||||
targetPath={path}
|
||||
onActiveChange={setComposeActive}
|
||||
onStepChange={setComposeStep}
|
||||
disabled={streamParticle.status === "closed"}
|
||||
disabled={streamParticle.status === 'closed'}
|
||||
onParticleCreated={handleParticleCreated}
|
||||
/>
|
||||
|
||||
@@ -475,16 +533,23 @@ function BottomBar({
|
||||
current: number;
|
||||
progress: number;
|
||||
onGoTo: (index: number) => void;
|
||||
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
|
||||
presenceBySegment: Map<
|
||||
number,
|
||||
import('@/hooks/use-presence-positions').HumanPresence[]
|
||||
>;
|
||||
onlineHumanIds: Set<string>;
|
||||
exitRemainingMs: number | null;
|
||||
onOpenKeybindings: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10 transition-all duration-300",
|
||||
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 bottom-0 z-10 transition-all duration-300',
|
||||
visible
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 translate-y-2 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{/* Presence avatars — above the blurred background */}
|
||||
<PlaybackPageIndicator
|
||||
total={total}
|
||||
@@ -536,37 +601,37 @@ function StreamViewControls({
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
back
|
||||
</span>
|
||||
)}
|
||||
<VideoAudioToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("record")}
|
||||
onClick={() => requestIntent('record')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Reply with a recording (or hold `)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Hold `
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to reply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("text")}
|
||||
onClick={() => requestIntent('text')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Reply with text (or press T)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
T
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
text
|
||||
</button>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
H
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
huddle
|
||||
</span>
|
||||
<kbd
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { toast } from 'sonner';
|
||||
import type { Particle } from '@/api/types';
|
||||
import {
|
||||
particlePath,
|
||||
parseParticlePath,
|
||||
toFirestoreDocPath,
|
||||
type ParticlePath,
|
||||
} from "@/lib/particle-path";
|
||||
import { editTextParticleContent } from "@/lib/firestore-particles";
|
||||
import { TextEditor } from "@/features/compose/text-editor";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
} from '@/lib/particle-path';
|
||||
import { editTextParticleContent } from '@/lib/firestore-particles';
|
||||
import { TextEditor } from '@/features/compose/text-editor';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
type TextParticle = Extract<Particle, { type: 'text' }>;
|
||||
|
||||
interface TextEditOverlayProps {
|
||||
particle: TextParticle;
|
||||
@@ -25,7 +25,7 @@ export function TextEditOverlay({
|
||||
streamPath,
|
||||
onClose,
|
||||
}: TextEditOverlayProps) {
|
||||
useSuspendPlayback(true, "text-edit");
|
||||
useSuspendPlayback(true, 'text-edit');
|
||||
|
||||
const [textContent, setTextContent] = useState(particle.properties.content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -48,10 +48,17 @@ export function TextEditOverlay({
|
||||
await editTextParticleContent(docPath, trimmed);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save');
|
||||
setSaving(false);
|
||||
}
|
||||
}, [saving, textContent, particle.properties.content, particle.id, streamPath, onClose]);
|
||||
}, [
|
||||
saving,
|
||||
textContent,
|
||||
particle.properties.content,
|
||||
particle.id,
|
||||
streamPath,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
return createPortal(
|
||||
// React synthetic events bubble through the React tree (not the DOM tree),
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata";
|
||||
import { extractUrls } from "@/lib/link-metadata";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import type { ParticlePath } from '@/lib/particle-path';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useAllLinkMetadata,
|
||||
type LinkPreviewEntry,
|
||||
} from '@/hooks/use-link-metadata';
|
||||
import { extractUrls } from '@/lib/link-metadata';
|
||||
import {
|
||||
LinkPreviewCard,
|
||||
LinkPreviewCardSkeleton,
|
||||
} from "@/components/link-preview-card";
|
||||
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||
import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { MarkdownEditor } from "@/features/compose/markdown-editor";
|
||||
} from '@/components/link-preview-card';
|
||||
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
||||
import { ParticleAttachments } from '@/features/particles/particle-attachments';
|
||||
import { TextEditOverlay } from '@/features/particles/text-edit-overlay';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { MarkdownEditor } from '@/features/compose/markdown-editor';
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
type TextParticle = Extract<Particle, { type: 'text' }>;
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: TextParticle;
|
||||
@@ -43,18 +46,21 @@ function computeReadDuration(
|
||||
attachmentCount: number,
|
||||
): number {
|
||||
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||
const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
|
||||
const extra =
|
||||
linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
|
||||
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
|
||||
}
|
||||
|
||||
function getImmersiveTextStyle(length: number) {
|
||||
if (length < 30) return { size: "text-5xl", weight: "font-semibold" };
|
||||
if (length < 70) return { size: "text-3xl", weight: "font-semibold" };
|
||||
return { size: "text-2xl", weight: "font-normal" };
|
||||
if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' };
|
||||
if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' };
|
||||
return { size: 'text-2xl', weight: 'font-normal' };
|
||||
}
|
||||
|
||||
function hasMarkdownFormatting(content: string): boolean {
|
||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content);
|
||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||
@@ -93,7 +99,11 @@ export function TextParticleView({
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const hasEnrichments = hasLinks || hasAttachments;
|
||||
|
||||
const durationS = computeReadDuration(content, urls.length, attachments.length);
|
||||
const durationS = computeReadDuration(
|
||||
content,
|
||||
urls.length,
|
||||
attachments.length,
|
||||
);
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
// Reset elapsed when particle changes
|
||||
@@ -120,8 +130,10 @@ export function TextParticleView({
|
||||
|
||||
// Content is just bare URLs with no surrounding text
|
||||
const contentTrimmed = content.trim();
|
||||
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
|
||||
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === "";
|
||||
const linksOnly =
|
||||
hasLinks &&
|
||||
urls.every((url) => contentTrimmed.includes(url)) &&
|
||||
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, '').trim() === '';
|
||||
|
||||
const editButton = isCreator && !isEditing && (
|
||||
<button
|
||||
@@ -170,13 +182,17 @@ export function TextParticleView({
|
||||
}
|
||||
|
||||
// Mode 2: short plain text, no enrichments — immersive centered display
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
|
||||
if (
|
||||
content.length < IMMERSIVE_CHAR_LIMIT &&
|
||||
!hasEnrichments &&
|
||||
!hasMarkdownFormatting(content)
|
||||
) {
|
||||
const style = getImmersiveTextStyle(content.length);
|
||||
return (
|
||||
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text",
|
||||
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
@@ -195,16 +211,21 @@ export function TextParticleView({
|
||||
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-6 backdrop-blur-md",
|
||||
"[&::-webkit-scrollbar]:w-2",
|
||||
"[&::-webkit-scrollbar]:p-2",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-white/30",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
|
||||
'flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-6 backdrop-blur-md',
|
||||
'[&::-webkit-scrollbar]:w-2',
|
||||
'[&::-webkit-scrollbar]:p-2',
|
||||
'[&::-webkit-scrollbar-track]:bg-transparent',
|
||||
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-white/30',
|
||||
'[&::-webkit-scrollbar-thumb]:hover:bg-white/50',
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor key={content} value={content} readOnly className="select-text pb-3" />
|
||||
<MarkdownEditor
|
||||
key={content}
|
||||
value={content}
|
||||
readOnly
|
||||
className="select-text pb-3"
|
||||
/>
|
||||
|
||||
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { sanitizeReactionText } from "@/lib/firestore-particles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { sanitizeReactionText } from '@/lib/firestore-particles';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MAX_LENGTH = 40;
|
||||
|
||||
@@ -12,15 +12,28 @@ interface TextReactionInputProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
export function TextReactionInput({
|
||||
open,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: TextReactionInputProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useSuspendPlayback(open, "text-reaction");
|
||||
useSuspendPlayback(open, 'text-reaction');
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
|
||||
// NOTE: perform side effects here when opening
|
||||
if (open) {
|
||||
setValue('')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValue("");
|
||||
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open]);
|
||||
@@ -34,6 +47,7 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
onSubmit(trimmed);
|
||||
setValue('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -50,10 +64,10 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
||||
}
|
||||
onBlur={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
@@ -64,8 +78,8 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[1.5ch] text-right text-[10px] tabular-nums",
|
||||
remaining <= 8 ? "text-amber-300/80" : "text-white/30",
|
||||
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
|
||||
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
|
||||
)}
|
||||
>
|
||||
{remaining}
|
||||
@@ -75,10 +89,10 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded-full transition-colors",
|
||||
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
|
||||
canSubmit
|
||||
? "bg-white/20 text-white hover:bg-white/30"
|
||||
: "text-white/30",
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/30',
|
||||
)}
|
||||
aria-label="Send reaction"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { Transcript } from "@/api/types";
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Transcript } from '@/api/types';
|
||||
|
||||
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||
type Word = Transcript["words"][number];
|
||||
type Sentence = Transcript['paragraphs'][number]['sentences'][number];
|
||||
type Word = Transcript['words'][number];
|
||||
|
||||
const CHUNK_SIZE = 9;
|
||||
|
||||
@@ -41,42 +41,50 @@ export function TranscriptOverlay({
|
||||
const activeWord =
|
||||
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
||||
|
||||
// Remember the last spoken word so highlights hold during pauses
|
||||
const lastSpokenWordRef = useRef<Word | null>(null);
|
||||
if (activeWord) {
|
||||
lastSpokenWordRef.current = activeWord;
|
||||
// Remember the last spoken word so highlights hold during pauses.
|
||||
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
|
||||
if (activeWord && activeWord !== lastSpokenWord) {
|
||||
setLastSpokenWord(activeWord);
|
||||
}
|
||||
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
||||
const highlightWord = activeWord ?? lastSpokenWord;
|
||||
|
||||
const lastChunkRef = useRef<Word[] | null>(null);
|
||||
|
||||
// Find which chunk contains the active word, holding the last one during pauses
|
||||
const activeChunk = useMemo(() => {
|
||||
if (activeWord) {
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
|
||||
lastChunkRef.current = chunk;
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No active word (speaker pausing) — hold the last chunk
|
||||
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
|
||||
return lastChunkRef.current;
|
||||
}
|
||||
// Sentence changed, last chunk no longer valid — use first chunk
|
||||
const fallback = chunks[0] ?? null;
|
||||
lastChunkRef.current = fallback;
|
||||
return fallback;
|
||||
// The chunk currently being spoken (null during a pause or if not found).
|
||||
const spokenChunk = useMemo(() => {
|
||||
if (!activeWord) return null;
|
||||
return (
|
||||
chunks.find((chunk) =>
|
||||
chunk.some(
|
||||
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
||||
),
|
||||
) ?? null
|
||||
);
|
||||
}, [chunks, activeWord]);
|
||||
|
||||
// Resolve which chunk to display: the spoken one, else hold the last one while
|
||||
// it's still part of the current sentence, else fall back to the first chunk.
|
||||
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
|
||||
let activeChunk: Word[] | null;
|
||||
if (spokenChunk) {
|
||||
activeChunk = spokenChunk;
|
||||
} else if (lastChunk && chunks.includes(lastChunk)) {
|
||||
activeChunk = lastChunk;
|
||||
} else {
|
||||
activeChunk = chunks[0] ?? null;
|
||||
}
|
||||
if (activeChunk !== lastChunk) {
|
||||
setLastChunk(activeChunk);
|
||||
}
|
||||
|
||||
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={centered
|
||||
? "absolute inset-0 flex items-center justify-center px-6"
|
||||
: "absolute bottom-15 left-0 right-0 flex justify-center px-6"
|
||||
}>
|
||||
<div
|
||||
className={
|
||||
centered
|
||||
? 'absolute inset-0 flex items-center justify-center px-6'
|
||||
: 'absolute bottom-15 left-0 right-0 flex justify-center px-6'
|
||||
}
|
||||
>
|
||||
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
|
||||
{activeChunk.map((word, i) => {
|
||||
const isSpoken =
|
||||
@@ -87,11 +95,11 @@ export function TranscriptOverlay({
|
||||
key={`${word.start}-${i}`}
|
||||
className={
|
||||
isSpoken
|
||||
? "text-white font-medium transition-colors duration-150"
|
||||
: "text-white/40 transition-colors duration-150"
|
||||
? 'text-white font-medium transition-colors duration-150'
|
||||
: 'text-white/40 transition-colors duration-150'
|
||||
}
|
||||
>
|
||||
{i > 0 ? " " : ""}
|
||||
{i > 0 ? ' ' : ''}
|
||||
{word.word}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic, LifeBuoy, FileText, Volume2 } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { CopyableEmail } from "@/components/copyable-email";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSoundEffectsStore } from "@/stores/sound-effects-store";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { logError, toUserMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
import { PRIVACY_URL, SUPPORT_EMAIL, TERMS_URL } from "@/lib/constants";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Info,
|
||||
Shield,
|
||||
Mail,
|
||||
Mic,
|
||||
LifeBuoy,
|
||||
FileText,
|
||||
Volume2,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Muted } from '@/components/ui/typography';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CopyableEmail } from '@/components/copyable-email';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSoundEffectsStore } from '@/stores/sound-effects-store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { logError, toUserMessage } from '@/lib/errors';
|
||||
import { toast } from 'sonner';
|
||||
import { PRIVACY_URL, SUPPORT_EMAIL, TERMS_URL } from '@/lib/constants';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
interface SettingsRowProps {
|
||||
icon: React.ReactNode;
|
||||
@@ -37,7 +47,7 @@ function SettingsRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent ${destructive ? "text-destructive" : ""}`}
|
||||
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent ${destructive ? 'text-destructive' : ''}`}
|
||||
>
|
||||
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
||||
{icon}
|
||||
@@ -86,21 +96,25 @@ export default function SettingsPage() {
|
||||
const handleToggleEmailNotifications = async (checked: boolean) => {
|
||||
setEmailNotifications(checked);
|
||||
useAuthStore.setState((state) => ({
|
||||
user: state.user ? { ...state.user, email_notifications_enabled: checked } : null,
|
||||
user: state.user
|
||||
? { ...state.user, email_notifications_enabled: checked }
|
||||
: null,
|
||||
}));
|
||||
try {
|
||||
await apiClient.updateSettings({ email_notifications_enabled: checked });
|
||||
} catch (err) {
|
||||
setEmailNotifications(!checked);
|
||||
useAuthStore.setState((state) => ({
|
||||
user: state.user ? { ...state.user, email_notifications_enabled: !checked } : null,
|
||||
user: state.user
|
||||
? { ...state.user, email_notifications_enabled: !checked }
|
||||
: null,
|
||||
}));
|
||||
toast.error(toUserMessage(err));
|
||||
logError(err, { scope: "settings.emailNotifications" });
|
||||
logError(err, { scope: 'settings.emailNotifications' });
|
||||
}
|
||||
};
|
||||
|
||||
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? "?";
|
||||
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?';
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -127,9 +141,7 @@ export default function SettingsPage() {
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{user?.email_prefix}
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium">{user?.email_prefix}</p>
|
||||
<Muted className="text-xs">{user?.email}</Muted>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +183,7 @@ export default function SettingsPage() {
|
||||
<SettingsRow
|
||||
icon={<Mic className="size-4" />}
|
||||
label="Audio & Video"
|
||||
onClick={() => navigate("/settings/audio-video")}
|
||||
onClick={() => navigate('/settings/audio-video')}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, VideoOff } from "lucide-react";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, VideoOff } from 'lucide-react';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Muted } from '@/components/ui/typography';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
import { useMediaDevices } from "@/hooks/use-media-devices";
|
||||
} from '@/components/ui/select';
|
||||
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
|
||||
import { useAudioSource } from '@/components/audio/use-audio-source';
|
||||
import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||
import {
|
||||
resolveEffectiveDeviceId,
|
||||
isSavedDeviceAvailable,
|
||||
} from "@/hooks/use-effective-device-id";
|
||||
} from '@/hooks/use-effective-device-id';
|
||||
import {
|
||||
useMediaDevicesStore,
|
||||
type SavedDevice,
|
||||
} from "@/stores/media-devices-store";
|
||||
} from '@/stores/media-devices-store';
|
||||
|
||||
const SYSTEM_DEFAULT = "__system_default__";
|
||||
const SYSTEM_DEFAULT = '__system_default__';
|
||||
|
||||
function usePreviewStream(
|
||||
enabled: boolean,
|
||||
@@ -34,12 +34,19 @@ function usePreviewStream(
|
||||
): { stream: MediaStream | null; error: string | null } {
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [prevEnabled, setPrevEnabled] = useState(enabled);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear the preview when disabled
|
||||
if (enabled !== prevEnabled) {
|
||||
setPrevEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setStream(null);
|
||||
return;
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
let active: MediaStream | null = null;
|
||||
@@ -67,7 +74,9 @@ function usePreviewStream(
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setStream(null);
|
||||
setError(err instanceof Error ? err.message : "Unable to access devices");
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Unable to access devices',
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -81,7 +90,7 @@ function usePreviewStream(
|
||||
|
||||
function deviceLabel(d: MediaDeviceInfo, index: number): string {
|
||||
if (d.label) return d.label;
|
||||
const kind = d.kind === "audioinput" ? "Microphone" : "Camera";
|
||||
const kind = d.kind === 'audioinput' ? 'Microphone' : 'Camera';
|
||||
return `${kind} ${index + 1}`;
|
||||
}
|
||||
|
||||
@@ -207,7 +216,7 @@ export default function AudioVideoSettingsPage() {
|
||||
);
|
||||
|
||||
const cameraAvailable = videoInputs.length > 0;
|
||||
const permissionGranted = permissionState === "granted";
|
||||
const permissionGranted = permissionState === 'granted';
|
||||
|
||||
const { stream, error: previewError } = usePreviewStream(
|
||||
permissionGranted,
|
||||
@@ -280,7 +289,7 @@ export default function AudioVideoSettingsPage() {
|
||||
saved={camera}
|
||||
onChange={setCamera}
|
||||
placeholder={
|
||||
videoInputs.length === 0 ? "No cameras found" : "System default"
|
||||
videoInputs.length === 0 ? 'No cameras found' : 'System default'
|
||||
}
|
||||
/>
|
||||
<CameraPreview stream={permissionGranted ? stream : null} />
|
||||
@@ -291,7 +300,7 @@ export default function AudioVideoSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(previewError || (deviceError && permissionState === "denied")) && (
|
||||
{(previewError || (deviceError && permissionState === 'denied')) && (
|
||||
<Muted className="text-destructive text-[11px]">
|
||||
{previewError ?? deviceError}
|
||||
</Muted>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from "firebase/auth";
|
||||
import { getFirestore } from "firebase/firestore";
|
||||
import { appConfig } from "@/config/env";
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
import { appConfig } from '@/config/env';
|
||||
|
||||
export const firebaseApp = initializeApp(appConfig.firebase);
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
import type { BillingCadence } from "@/api/types";
|
||||
import { useQuery, useMutation, skipToken } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/api/client';
|
||||
import type { BillingCadence } from '@/api/types';
|
||||
|
||||
export function useNetworkBilling(networkId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["network-billing", networkId],
|
||||
queryFn: () => apiClient.getNetworkBilling(networkId!),
|
||||
enabled: !!networkId,
|
||||
queryKey: ['network-billing', networkId],
|
||||
queryFn: networkId
|
||||
? () => apiClient.getNetworkBilling(networkId)
|
||||
: skipToken,
|
||||
// Refetch on window focus so the UI catches up after the user returns
|
||||
// from Stripe Checkout (webhook may land a second or two later).
|
||||
// FIX: doesn't work with electron
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 10000
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { usePusherClient } from "@/lib/pusher-provider";
|
||||
import type { ChannelMessage } from "@/lib/pusher-client";
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { usePusherClient } from '@/lib/pusher-provider';
|
||||
import type { ChannelMessage } from '@/lib/pusher-client';
|
||||
|
||||
interface UseChannelResult {
|
||||
/** Current set of humanIds present in the channel */
|
||||
@@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !channelId) {
|
||||
setPresence([]);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
if (!client || !channelId) return;
|
||||
|
||||
client.subscribe(channelId);
|
||||
|
||||
@@ -36,11 +32,11 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
||||
};
|
||||
|
||||
const onJoin = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) =>
|
||||
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
|
||||
);
|
||||
}
|
||||
const humanId = msg.humanId;
|
||||
if (!humanId) return;
|
||||
setPresence((prev) =>
|
||||
prev.includes(humanId) ? prev : [...prev, humanId],
|
||||
);
|
||||
};
|
||||
|
||||
const onLeave = (msg: { humanId?: string }) => {
|
||||
@@ -50,25 +46,24 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
||||
};
|
||||
|
||||
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
|
||||
if (msg.humanId) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ humanId: msg.humanId!, payload: msg.payload },
|
||||
]);
|
||||
}
|
||||
const humanId = msg.humanId;
|
||||
if (!humanId) return;
|
||||
setMessages((prev) => [...prev, { humanId, payload: msg.payload }]);
|
||||
};
|
||||
|
||||
client.on(channelId, "subscribed", onSubscribed);
|
||||
client.on(channelId, "join", onJoin);
|
||||
client.on(channelId, "leave", onLeave);
|
||||
client.on(channelId, "message", onMessage);
|
||||
client.on(channelId, 'subscribed', onSubscribed);
|
||||
client.on(channelId, 'join', onJoin);
|
||||
client.on(channelId, 'leave', onLeave);
|
||||
client.on(channelId, 'message', onMessage);
|
||||
|
||||
return () => {
|
||||
client.off(channelId, "subscribed", onSubscribed);
|
||||
client.off(channelId, "join", onJoin);
|
||||
client.off(channelId, "leave", onLeave);
|
||||
client.off(channelId, "message", onMessage);
|
||||
client.off(channelId, 'subscribed', onSubscribed);
|
||||
client.off(channelId, 'join', onJoin);
|
||||
client.off(channelId, 'leave', onLeave);
|
||||
client.off(channelId, 'message', onMessage);
|
||||
client.unsubscribe(channelId);
|
||||
setPresence([]);
|
||||
setMessages([]);
|
||||
};
|
||||
}, [client, channelId]);
|
||||
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
|
||||
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
|
||||
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
|
||||
import { QuotaExceededError } from "@/lib/errors";
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createParticle,
|
||||
createStreamParticle,
|
||||
} from '@/lib/firestore-particles';
|
||||
import {
|
||||
CONTAINER_TYPES,
|
||||
type NetworkUsage,
|
||||
type ParticleType,
|
||||
type ParticlePropertiesMap,
|
||||
} from '@/api/types';
|
||||
import {
|
||||
parseParticlePath,
|
||||
particlePath,
|
||||
ParticlePath,
|
||||
toFirestoreChildrenPath,
|
||||
} from '@/lib/particle-path';
|
||||
import { QuotaExceededError } from '@/lib/errors';
|
||||
import {
|
||||
isUsageExhausted,
|
||||
networkUsageQueryKey,
|
||||
useBumpNetworkUsage,
|
||||
useInvalidateNetworkUsage,
|
||||
} from "./use-network-usage";
|
||||
} from './use-network-usage';
|
||||
|
||||
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||
// Path to which the new particle will be added as a child
|
||||
@@ -32,7 +45,9 @@ export function useCreateParticle() {
|
||||
|
||||
// Containers aren't counted server-side, so we block them here
|
||||
if (!CONTAINER_TYPES.has(params.type)) {
|
||||
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId));
|
||||
const cached = qc.getQueryData<NetworkUsage>(
|
||||
networkUsageQueryKey(networkId),
|
||||
);
|
||||
if (isUsageExhausted(cached)) {
|
||||
throw new QuotaExceededError(networkId);
|
||||
}
|
||||
@@ -58,7 +73,7 @@ export function useCreateParticle() {
|
||||
|
||||
type CreateStreamParticleParams = {
|
||||
networkId: string;
|
||||
properties: ParticlePropertiesMap["stream"];
|
||||
properties: ParticlePropertiesMap['stream'];
|
||||
createdByHumanId: string;
|
||||
visibleTo?: string[];
|
||||
};
|
||||
@@ -74,6 +89,6 @@ export function useCreateStreamParticle() {
|
||||
params.createdByHumanId,
|
||||
params.visibleTo,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user