infra: add linting and formatting for js projects #230
@@ -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
Useless conditional
This use of variable 'currentParticle' always evaluates to true.
The best fix is to remove the unnecessary conditional around
currentParticleDocPathconstruction and always compute it directly after the existing early return checks.In
js/desktop/src/features/particles/stream-view.tsx, insidehandleToggleReaction(around lines 227–233), replace:currentParticleDocPathusingcurrentParticle ? ... : nullif (!currentParticleDocPath) return;with a direct assignment using
currentParticle.id. This keeps functionality unchanged becausecurrentParticleis already guaranteed non-null by the earlier guard.No imports, new methods, or new dependencies are required.