This commit is contained in:
Arjun Patel
2026-06-01 14:17:58 -07:00
parent 580703fdf6
commit 52ff92083a
165 changed files with 3736 additions and 2907 deletions
+42 -25
View File
@@ -1,27 +1,27 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { Routes, Route, useNavigate } from "react-router-dom"; import { Routes, Route, useNavigate } from 'react-router-dom';
import { RouterShell } from "@/lib/router-shell"; import { RouterShell } from '@/lib/router-shell';
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from '@/components/ui/tooltip';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { LoginPage } from "@/features/auth/login-page"; import { LoginPage } from '@/features/auth/login-page';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import SettingsPage from "@/features/settings-page"; import SettingsPage from '@/features/settings-page';
import AudioVideoSettingsPage from "@/features/settings/audio-video-settings-page"; import AudioVideoSettingsPage from '@/features/settings/audio-video-settings-page';
import NetworkSelector from "@/features/network-selector"; import NetworkSelector from '@/features/network-selector';
import NetworkRoot from "@/features/network-root"; import NetworkRoot from '@/features/network-root';
import ParticleViewResolver from "@/features/particles/particle-view-resolver"; import ParticleViewResolver from '@/features/particles/particle-view-resolver';
import Layout from "@/features/layout"; import Layout from '@/features/layout';
import NetworkSettingsPage from "@/features/network-settings"; import NetworkSettingsPage from '@/features/network-settings';
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from '@/components/ui/sonner';
import { PusherProvider } from "@/lib/pusher-provider"; import { PusherProvider } from '@/lib/pusher-provider';
import { createQueryClient } from "@/lib/query-client"; import { createQueryClient } from '@/lib/query-client';
import { import {
RouteErrorBoundary, RouteErrorBoundary,
TopLevelErrorBoundary, TopLevelErrorBoundary,
} from "@/components/app-error-boundary"; } from '@/components/app-error-boundary';
import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider"; import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
import { InAppAutoplayCard } from "@/components/in-app-autoplay-card"; import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
const queryClient = createQueryClient(); const queryClient = createQueryClient();
@@ -33,7 +33,7 @@ const App = () => {
restoreSession(); restoreSession();
}, [restoreSession]); }, [restoreSession]);
if (status === "idle" || status === "restoring") { if (status === 'idle' || status === 'restoring') {
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p> <p className="text-muted-foreground text-sm">Loading...</p>
@@ -41,7 +41,7 @@ const App = () => {
); );
} }
if (status !== "authenticated") { if (status !== 'authenticated') {
return <LoginPage />; return <LoginPage />;
} }
@@ -72,12 +72,29 @@ function AuthenticatedApp() {
<RouteErrorBoundary> <RouteErrorBoundary>
<Routes> <Routes>
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="settings/audio-video" element={<AudioVideoSettingsPage />} /> <Route
path="settings/audio-video"
element={<AudioVideoSettingsPage />}
/>
<Route path="/"> <Route path="/">
<Route index element={<Layout><NetworkSelector /></Layout>} /> <Route
index
element={
<Layout>
<NetworkSelector />
</Layout>
}
/>
<Route path=":networkId"> <Route path=":networkId">
<Route index element={<Layout><NetworkRoot /></Layout>} /> <Route
index
element={
<Layout>
<NetworkRoot />
</Layout>
}
/>
<Route path="settings" element={<NetworkSettingsPage />} /> <Route path="settings" element={<NetworkSettingsPage />} />
<Route path="*" element={<ParticleViewResolver />} /> <Route path="*" element={<ParticleViewResolver />} />
</Route> </Route>
+53 -55
View File
@@ -1,7 +1,7 @@
import { appConfig } from "@/config/env"; import { appConfig } from '@/config/env';
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from '@/stores/session-store';
import { ApiError } from "@/lib/errors"; import { ApiError } from '@/lib/errors';
import type { z } from "zod"; import type { z } from 'zod';
import { import {
BillingStatusSchema, BillingStatusSchema,
CheckoutSessionResponseSchema, CheckoutSessionResponseSchema,
@@ -16,7 +16,7 @@ import {
PortalSessionResponseSchema, PortalSessionResponseSchema,
PrepareUploadResponseSchema, PrepareUploadResponseSchema,
SignInResponseSchema, SignInResponseSchema,
} from "./types"; } from './types';
import type { import type {
AcceptInvitationRequest, AcceptInvitationRequest,
AddMembersRequest, AddMembersRequest,
@@ -26,8 +26,8 @@ import type {
RequestCodeRequest, RequestCodeRequest,
RevokeInvitationRequest, RevokeInvitationRequest,
SignInRequest, SignInRequest,
} from "./types"; } from './types';
import type { LinkMetadata } from "@/lib/link-metadata"; import type { LinkMetadata } from '@/lib/link-metadata';
interface ApiClientConfig { interface ApiClientConfig {
baseUrl: string; baseUrl: string;
@@ -50,12 +50,12 @@ class ApiClient {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (body) { if (body) {
headers["Content-Type"] = "application/json"; headers['Content-Type'] = 'application/json';
} }
const token = this.config.getToken(); const token = this.config.getToken();
if (token) { if (token) {
headers["Authorization"] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
const response = await fetch(`${this.config.baseUrl}${path}`, { const response = await fetch(`${this.config.baseUrl}${path}`, {
@@ -66,11 +66,11 @@ class ApiClient {
if (response.status === 401) { if (response.status === 401) {
this.config.onUnauthorized(); this.config.onUnauthorized();
throw new ApiError(401, "Unauthorized"); throw new ApiError(401, 'Unauthorized');
} }
if (!response.ok) { 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); throw new ApiError(response.status, text);
} }
@@ -99,43 +99,42 @@ class ApiClient {
// --- Auth --- // --- Auth ---
async requestCode(data: RequestCodeRequest): Promise<void> { 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) { async signIn(data: SignInRequest) {
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data); return this.request(SignInResponseSchema, 'POST', '/auth/sign-in', data);
} }
async me() { async me() {
return this.request(HumanSchema, "GET", "/auth/me"); return this.request(HumanSchema, 'GET', '/auth/me');
} }
async signOut(): Promise<void> { async signOut(): Promise<void> {
await this.requestVoid("POST", "/auth/sign-out"); await this.requestVoid('POST', '/auth/sign-out');
} }
async getFirebaseToken() { async getFirebaseToken() {
return this.request( return this.request(
FirebaseTokenResponseSchema, FirebaseTokenResponseSchema,
"POST", 'POST',
"/auth/firebase-token", '/auth/firebase-token',
); );
} }
// TODO: security: require passing in the particle id once api deprecates this // TODO: security: require passing in the particle id once api deprecates this
async getParticleDownloadUrl(objectId: string): Promise<string> { async getParticleDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch( const response = await this.fetch('GET', `/particles/${objectId}/download`);
"GET",
`/particles/${objectId}/download`,
);
const data = await response.json(); const data = await response.json();
return data.url; return data.url;
} }
// --- Settings --- // --- Settings ---
async updateSettings(data: { email_notifications_enabled?: boolean }): Promise<void> { async updateSettings(data: {
await this.requestVoid("PATCH", "/humans/me/settings", data); email_notifications_enabled?: boolean;
}): Promise<void> {
await this.requestVoid('PATCH', '/humans/me/settings', data);
} }
// --- Depot --- // --- Depot ---
@@ -143,8 +142,8 @@ class ApiClient {
async prepareUpload(data: PrepareUploadRequest) { async prepareUpload(data: PrepareUploadRequest) {
return this.request( return this.request(
PrepareUploadResponseSchema, PrepareUploadResponseSchema,
"POST", 'POST',
"/depot/upload", '/depot/upload',
data, data,
); );
} }
@@ -152,41 +151,32 @@ class ApiClient {
async confirmUpload(objectId: string) { async confirmUpload(objectId: string) {
return this.request( return this.request(
DepotObjectSchema, DepotObjectSchema,
"POST", 'POST',
`/depot/objects/${objectId}/confirm`, `/depot/objects/${objectId}/confirm`,
); );
} }
// --- Networks --- // --- Networks ---
async listNetworks() { async listNetworks() {
return this.request( return this.request(ListNetworksResponseSchema, 'GET', '/networks');
ListNetworksResponseSchema,
"GET",
"/networks",
);
} }
async createNetwork(data: CreateNetworkRequest) { async createNetwork(data: CreateNetworkRequest) {
return this.request(NetworkSchema, "POST", "/networks", data); return this.request(NetworkSchema, 'POST', '/networks', data);
} }
async getNetwork(id: string) { 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> { async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
await this.requestVoid( await this.requestVoid('POST', `/networks/${networkId}/members`, data);
"POST",
`/networks/${networkId}/members`,
data,
);
} }
async removeMember(networkId: string, humanId: string): Promise<void> { async removeMember(networkId: string, humanId: string): Promise<void> {
await this.requestVoid( await this.requestVoid(
"DELETE", 'DELETE',
`/networks/${networkId}/members/${humanId}`, `/networks/${networkId}/members/${humanId}`,
); );
} }
@@ -196,31 +186,39 @@ class ApiClient {
async listNetworkInvitations(networkId: string) { async listNetworkInvitations(networkId: string) {
return this.request( return this.request(
ListInvitationsResponseSchema, ListInvitationsResponseSchema,
"GET", 'GET',
`/networks/${networkId}/invitations`, `/networks/${networkId}/invitations`,
); );
} }
async listMyInvitations() { async listMyInvitations() {
return this.request( return this.request(ListInvitationsResponseSchema, 'GET', '/invitations');
ListInvitationsResponseSchema,
"GET",
"/invitations",
);
} }
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> { 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> { async revokeInvitation(
await this.requestVoid("DELETE", `/networks/${networkId}/invitations`, data); networkId: string,
data: RevokeInvitationRequest,
): Promise<void> {
await this.requestVoid(
'DELETE',
`/networks/${networkId}/invitations`,
data,
);
} }
// --- LiveKit --- // --- LiveKit ---
async getLivekitToken(networkId: string, streamId: string) { 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) --- // --- Billing (network admin only) ---
@@ -228,7 +226,7 @@ class ApiClient {
async getNetworkBilling(networkId: string) { async getNetworkBilling(networkId: string) {
return this.request( return this.request(
BillingStatusSchema, BillingStatusSchema,
"GET", 'GET',
`/networks/${networkId}/billing`, `/networks/${networkId}/billing`,
); );
} }
@@ -236,7 +234,7 @@ class ApiClient {
async createCheckoutSession(networkId: string, cadence: BillingCadence) { async createCheckoutSession(networkId: string, cadence: BillingCadence) {
return this.request( return this.request(
CheckoutSessionResponseSchema, CheckoutSessionResponseSchema,
"POST", 'POST',
`/networks/${networkId}/billing/checkout-session`, `/networks/${networkId}/billing/checkout-session`,
{ cadence }, { cadence },
); );
@@ -245,7 +243,7 @@ class ApiClient {
async createPortalSession(networkId: string) { async createPortalSession(networkId: string) {
return this.request( return this.request(
PortalSessionResponseSchema, PortalSessionResponseSchema,
"POST", 'POST',
`/networks/${networkId}/billing/portal-session`, `/networks/${networkId}/billing/portal-session`,
); );
} }
@@ -253,7 +251,7 @@ class ApiClient {
async getNetworkUsage(networkId: string) { async getNetworkUsage(networkId: string) {
return this.request( return this.request(
NetworkUsageSchema, NetworkUsageSchema,
"GET", 'GET',
`/networks/${networkId}/usage`, `/networks/${networkId}/usage`,
); );
} }
@@ -262,7 +260,7 @@ class ApiClient {
async getLinkMetadata(url: string): Promise<LinkMetadata | null> { async getLinkMetadata(url: string): Promise<LinkMetadata | null> {
const response = await this.fetch( const response = await this.fetch(
"GET", 'GET',
`/metadata?url=${encodeURIComponent(url)}`, `/metadata?url=${encodeURIComponent(url)}`,
); );
return (await response.json()) as LinkMetadata | null; return (await response.json()) as LinkMetadata | null;
+67 -27
View File
@@ -1,4 +1,4 @@
import { z } from "zod"; import { z } from 'zod';
export const HumanSchema = z.object({ export const HumanSchema = z.object({
id: z.string(), id: z.string(),
@@ -122,7 +122,7 @@ export const MediaPropertiesSchema = z.object({
duration_ms: z.number(), duration_ms: z.number(),
size_bytes: z.number(), size_bytes: z.number(),
transcript: TranscriptSchema.optional(), 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 // 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). // 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. // 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 --- // --- 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>; export type Reactions = z.infer<typeof ReactionsSchema>;
// --- Tombstone (soft-delete) --- // --- Tombstone (soft-delete) ---
@@ -175,7 +177,15 @@ const TombstoneFields = {
deleted_by_human_id: z.string().optional(), 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 { export interface ParticlePropertiesMap {
stream: StreamProperties; stream: StreamProperties;
@@ -196,9 +206,9 @@ const ParticleBaseSchema = z.object({
updated_at: z.coerce.date().optional(), updated_at: z.coerce.date().optional(),
}); });
export const ParticleSchema = z.discriminatedUnion("type", [ export const ParticleSchema = z.discriminatedUnion('type', [
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
type: z.literal("stream"), type: z.literal('stream'),
properties: StreamPropertiesSchema, properties: StreamPropertiesSchema,
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:xywx"] - visible to everyone in the network // 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(), last_child_created_at: z.coerce.date().optional(),
// Array of humanIds currently in the huddle (updated via LiveKit webhooks) // Array of humanIds currently in the huddle (updated via LiveKit webhooks)
huddle_active_participants: z.array(z.string()).optional(), huddle_active_participants: z.array(z.string()).optional(),
status: z.enum(["open", "closed"]).optional(), status: z.enum(['open', 'closed']).optional(),
}), }),
ParticleBaseSchema.extend({ 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. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network // e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()), visible_to: z.array(z.string()),
}), }),
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }), ParticleBaseSchema.extend({
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }), type: z.literal('media'),
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }), properties: MediaPropertiesSchema,
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }), reactions: ReactionsSchema,
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }), ...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 Particle = z.infer<typeof ParticleSchema>;
export type ParticleType = Particle["type"]; export type ParticleType = Particle['type'];
/** Container types can have children subcollections */ /** 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 { export function isContainerType(type: ParticleType): boolean {
return CONTAINER_TYPES.has(type); 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). */ /** True when a non-container particle has been soft-deleted (tombstoned). */
export function isParticleDeleted(particle: Particle): boolean { 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 --- // --- LiveKit types ---
@@ -247,7 +283,9 @@ export const GetLivekitTokenResponseSchema = z.object({
token: z.string(), token: z.string(),
server_url: z.string(), server_url: z.string(),
}); });
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>; export type GetLivekitTokenResponse = z.infer<
typeof GetLivekitTokenResponseSchema
>;
// --- Auth types --- // --- Auth types ---
@@ -275,21 +313,21 @@ export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
// --- Billing types --- // --- Billing types ---
export const BillingCadenceSchema = z.enum(["monthly", "annual"]); export const BillingCadenceSchema = z.enum(['monthly', 'annual']);
export type BillingCadence = z.infer<typeof BillingCadenceSchema>; 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>; export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
// Mirrors Stripe subscription.status plus "active" as the default free-tier value. // Mirrors Stripe subscription.status plus "active" as the default free-tier value.
export const BillingPlanStatusSchema = z.enum([ export const BillingPlanStatusSchema = z.enum([
"active", 'active',
"trialing", 'trialing',
"past_due", 'past_due',
"canceled", 'canceled',
"incomplete", 'incomplete',
"incomplete_expired", 'incomplete_expired',
"unpaid", 'unpaid',
]); ]);
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>; export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
@@ -308,7 +346,9 @@ export type BillingStatus = z.infer<typeof BillingStatusSchema>;
export const CheckoutSessionResponseSchema = z.object({ export const CheckoutSessionResponseSchema = z.object({
url: z.string().url(), url: z.string().url(),
}); });
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>; export type CheckoutSessionResponse = z.infer<
typeof CheckoutSessionResponseSchema
>;
export const PortalSessionResponseSchema = z.object({ export const PortalSessionResponseSchema = z.object({
url: z.string().url(), url: z.string().url(),
+2 -2
View File
@@ -1,9 +1,9 @@
declare module "*.wav" { declare module '*.wav' {
const src: string; const src: string;
export default src; export default src;
} }
declare module "*.mp3" { declare module '*.mp3' {
const src: string; const src: string;
export default src; export default src;
} }
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from 'react';
import type { AutoplayPayload } from "@/lib/autoplay-ipc"; import type { AutoplayPayload } from '@/lib/autoplay-ipc';
import { AutoplayCardContent } from "@/components/autoplay-card-content"; import { AutoplayCardContent } from '@/components/autoplay-card-content';
export function AutoplayApp() { export function AutoplayApp() {
const [payload, setPayload] = useState<AutoplayPayload | null>(null); const [payload, setPayload] = useState<AutoplayPayload | null>(null);
@@ -1,12 +1,12 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from 'react';
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from 'react-error-boundary';
import { useLocation } from "react-router-dom"; import { useLocation } from 'react-router-dom';
import { useQueryErrorResetBoundary } from "@tanstack/react-query"; import { useQueryErrorResetBoundary } from '@tanstack/react-query';
import { reportError } from "@/lib/errors"; import { reportError } from '@/lib/errors';
import { import {
RouteErrorFallback, RouteErrorFallback,
TopLevelErrorFallback, TopLevelErrorFallback,
} from "@/components/error-fallback"; } from '@/components/error-fallback';
/** Catches render crashes OUTSIDE the router so bootstrap failures still recover. */ /** Catches render crashes OUTSIDE the router so bootstrap failures still recover. */
export function TopLevelErrorBoundary({ children }: PropsWithChildren) { export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
@@ -15,7 +15,7 @@ export function TopLevelErrorBoundary({ children }: PropsWithChildren) {
FallbackComponent={TopLevelErrorFallback} FallbackComponent={TopLevelErrorFallback}
onError={(error, info) => onError={(error, info) =>
reportError(error, { reportError(error, {
boundary: "top", boundary: 'top',
componentStack: info.componentStack, componentStack: info.componentStack,
}) })
} }
@@ -37,7 +37,7 @@ export function RouteErrorBoundary({ children }: PropsWithChildren) {
FallbackComponent={RouteErrorFallback} FallbackComponent={RouteErrorFallback}
onError={(error, info) => onError={(error, info) =>
reportError(error, { reportError(error, {
boundary: "route", boundary: 'route',
pathname: location.pathname, pathname: location.pathname,
componentStack: info.componentStack, componentStack: info.componentStack,
}) })
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react';
interface AudioLevelBarsProps { interface AudioLevelBarsProps {
sourceNode: AudioNode; sourceNode: AudioNode;
@@ -83,7 +83,8 @@ export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) {
smoothedLevel <= threshold smoothedLevel <= threshold
? 0 ? 0
: Math.min(1, (smoothedLevel - threshold) / (1 - threshold)); : 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`; el.style.height = `${height}px`;
} }
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
interface AudioSource { interface AudioSource {
sourceNode: AudioNode; sourceNode: AudioNode;
@@ -17,7 +17,10 @@ export function useAudioSource(
): AudioSource | null { ): AudioSource | null {
const [audioSource, setAudioSource] = useState<AudioSource | null>(null); const [audioSource, setAudioSource] = useState<AudioSource | null>(null);
const elementSourceCache = useRef< const elementSourceCache = useRef<
WeakMap<HTMLAudioElement, { sourceNode: MediaElementAudioSourceNode; ctx: AudioContext }> WeakMap<
HTMLAudioElement,
{ sourceNode: MediaElementAudioSourceNode; ctx: AudioContext }
>
>(new WeakMap()); >(new WeakMap());
useEffect(() => { useEffect(() => {
@@ -1,6 +1,6 @@
import { useRef } from "react"; import { useRef } from 'react';
import { X } from "lucide-react"; import { X } from 'lucide-react';
import type { AutoplayPayload } from "@/lib/autoplay-ipc"; import type { AutoplayPayload } from '@/lib/autoplay-ipc';
interface AutoplayCardContentProps { interface AutoplayCardContentProps {
payload: AutoplayPayload; payload: AutoplayPayload;
@@ -17,7 +17,7 @@ export function AutoplayCardContent({
}: AutoplayCardContentProps) { }: AutoplayCardContentProps) {
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null); const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
const isVideo = payload.mimeType.startsWith("video/"); const isVideo = payload.mimeType.startsWith('video/');
const handleClick = () => { const handleClick = () => {
mediaRef.current?.pause(); 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"> <div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white">
{payload.senderInitials} {payload.senderInitials}
</div> </div>
<p className="truncate text-xs text-white/80">{payload.senderName}</p> <p className="truncate text-xs text-white/80">
{payload.senderName}
</p>
</div> </div>
</> </>
) : ( ) : (
@@ -72,7 +74,9 @@ export function AutoplayCardContent({
{payload.senderInitials} {payload.senderInitials}
</div> </div>
<div className="min-w-0 flex-1"> <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> <p className="text-xs text-muted-foreground">Playing audio...</p>
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
import type { Human } from "@/api/types"; import type { Human } from '@/api/types';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
import type { ComposingUser } from "@/features/particles/stream-presence-context"; import type { ComposingUser } from '@/features/particles/stream-presence-context';
interface ComposingIndicatorProps { interface ComposingIndicatorProps {
users: ComposingUser[]; users: ComposingUser[];
@@ -20,11 +20,11 @@ export function ComposingIndicator({
return ( return (
<div <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" 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) => { {users.map((u) => {
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans); const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
const modeLabel = u.mode === "typing" ? "typing" : "recording"; const modeLabel = u.mode === 'typing' ? 'typing' : 'recording';
return ( return (
<div <div
@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
interface ConfirmDestructiveOverlayProps { interface ConfirmDestructiveOverlayProps {
title: string; title: string;
@@ -16,21 +16,22 @@ export function ConfirmDestructiveOverlay({
title, title,
description, description,
confirmLabel, confirmLabel,
pendingLabel = "Working…", pendingLabel = 'Working…',
isPending, isPending,
onConfirm, onConfirm,
onClose, onClose,
}: ConfirmDestructiveOverlayProps) { }: ConfirmDestructiveOverlayProps) {
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onClose(); onClose();
} }
}; };
window.addEventListener("keydown", handler, { capture: true }); window.addEventListener('keydown', handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true }); return () =>
window.removeEventListener('keydown', handler, { capture: true });
}, [onClose]); }, [onClose]);
return createPortal( return createPortal(
@@ -45,7 +46,7 @@ export function ConfirmDestructiveOverlay({
<span className="text-xs text-white/30"> <span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" "} </kbd>{' '}
to close to close
</span> </span>
</div> </div>
@@ -53,7 +54,12 @@ export function ConfirmDestructiveOverlay({
<div className="text-sm text-white/60">{description}</div> <div className="text-sm text-white/60">{description}</div>
<div className="mt-5 flex items-center justify-end gap-2"> <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 Cancel
</Button> </Button>
<Button <Button
+7 -7
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { Check, Copy } from "lucide-react"; import { Check, Copy } from 'lucide-react';
import { toast } from "sonner"; import { toast } from 'sonner';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
interface CopyableEmailProps { interface CopyableEmailProps {
email: string; email: string;
@@ -22,9 +22,9 @@ export function CopyableEmail({ email, className }: CopyableEmailProps) {
try { try {
await navigator.clipboard.writeText(email); await navigator.clipboard.writeText(email);
setCopied(true); setCopied(true);
toast.success("Email copied"); toast.success('Email copied');
} catch { } 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} onClick={handleCopy}
aria-label={`Copy ${email}`} aria-label={`Copy ${email}`}
className={cn( 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, className,
)} )}
> >
+12 -20
View File
@@ -1,7 +1,7 @@
import type { FallbackProps } from "react-error-boundary"; import type { FallbackProps } from 'react-error-boundary';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from 'lucide-react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
CardContent, CardContent,
@@ -9,9 +9,9 @@ import {
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from '@/components/ui/card';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
import { appEnv } from "@/config/env"; import { appEnv } from '@/config/env';
function ErrorCard({ function ErrorCard({
error, error,
@@ -32,7 +32,7 @@ function ErrorCard({
</div> </div>
<CardDescription>{toUserMessage(error)}</CardDescription> <CardDescription>{toUserMessage(error)}</CardDescription>
</CardHeader> </CardHeader>
{appEnv === "dev" && error instanceof Error ? ( {appEnv === 'dev' && error instanceof Error ? (
<CardContent> <CardContent>
<details className="text-muted-foreground text-xs"> <details className="text-muted-foreground text-xs">
<summary className="cursor-pointer select-none"> <summary className="cursor-pointer select-none">
@@ -63,15 +63,11 @@ export function TopLevelErrorFallback({
resetErrorBoundary, resetErrorBoundary,
}: FallbackProps) { }: FallbackProps) {
const goHome = () => { const goHome = () => {
window.location.hash = "#/"; window.location.hash = '#/';
resetErrorBoundary(); resetErrorBoundary();
}; };
return ( return (
<ErrorCard <ErrorCard error={error} onGoHome={goHome} onRetry={resetErrorBoundary} />
error={error}
onGoHome={goHome}
onRetry={resetErrorBoundary}
/>
); );
} }
@@ -81,14 +77,10 @@ export function RouteErrorFallback({
}: FallbackProps) { }: FallbackProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const goHome = () => { const goHome = () => {
navigate("/"); navigate('/');
resetErrorBoundary(); resetErrorBoundary();
}; };
return ( return (
<ErrorCard <ErrorCard error={error} onGoHome={goHome} onRetry={resetErrorBoundary} />
error={error}
onGoHome={goHome}
onRetry={resetErrorBoundary}
/>
); );
} }
@@ -1,7 +1,7 @@
import { useCallback } from "react"; import { useCallback } from 'react';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
import { useAutoplayPayloadStore } from "@/stores/autoplay-payload-store"; import { useAutoplayPayloadStore } from '@/stores/autoplay-payload-store';
import { AutoplayCardContent } from "@/components/autoplay-card-content"; import { AutoplayCardContent } from '@/components/autoplay-card-content';
/** /**
* Bottom-right floating autoplay card used on the web client. The desktop * 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 }); setPendingNav({ networkId: payload.networkId, streamId: payload.streamId });
}, [payload, setPayload, setPendingNav]); }, [payload, setPayload, setPendingNav]);
if (platform.kind !== "web") return null; if (platform.kind !== 'web') return null;
if (!payload) return null; if (!payload) return null;
const isVideo = payload.mimeType.startsWith("video/"); const isVideo = payload.mimeType.startsWith('video/');
return ( return (
<div <div
@@ -1,6 +1,6 @@
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { useEffect } from "react"; import { useEffect } from 'react';
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom';
export interface KeybindingEntry { export interface KeybindingEntry {
keys: string[]; keys: string[];
@@ -23,21 +23,22 @@ export function KeybindingsOverlay({
open, open,
onClose, onClose,
groups, groups,
title = "Keyboard Shortcuts", title = 'Keyboard Shortcuts',
}: KeybindingsOverlayProps) { }: KeybindingsOverlayProps) {
useSuspendPlayback(open, "keybindings"); useSuspendPlayback(open, 'keybindings');
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "?") { if (e.key === 'Escape' || e.key === '?') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onClose(); onClose();
} }
}; };
window.addEventListener("keydown", handler, { capture: true }); window.addEventListener('keydown', handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true }); return () =>
window.removeEventListener('keydown', handler, { capture: true });
}, [open, onClose]); }, [open, onClose]);
if (!open) return null; if (!open) return null;
@@ -56,11 +57,11 @@ export function KeybindingsOverlay({
<span className="text-xs text-white/30"> <span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" "} </kbd>{' '}
or{" "} or{' '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
? ?
</kbd>{" "} </kbd>{' '}
to close to close
</span> </span>
</div> </div>
@@ -1,8 +1,8 @@
import { Copy, ExternalLink, Globe } from "lucide-react"; import { Copy, ExternalLink, Globe } from 'lucide-react';
import type { LinkMetadata } from "@/lib/link-metadata"; import type { LinkMetadata } from '@/lib/link-metadata';
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from '@/components/ui/skeleton';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
interface LinkPreviewCardProps { interface LinkPreviewCardProps {
metadata: LinkMetadata; metadata: LinkMetadata;
@@ -34,7 +34,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
alt="" alt=""
className="h-32 w-full object-cover" className="h-32 w-full object-cover"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).style.display = "none"; (e.target as HTMLImageElement).style.display = 'none';
}} }}
/> />
)} )}
@@ -47,7 +47,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
className="size-4 rounded-sm" className="size-4 rounded-sm"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).replaceWith( (e.target as HTMLImageElement).replaceWith(
document.createElement("span"), document.createElement('span'),
); );
}} }}
/> />
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { formatDistanceToNow } from "@/lib/time-utils"; import { formatDistanceToNow } from '@/lib/time-utils';
const TICK_MS = 30_000; const TICK_MS = 30_000;
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
interface ScreenSourcePickerProps { interface ScreenSourcePickerProps {
title?: string; title?: string;
@@ -9,8 +9,8 @@ interface ScreenSourcePickerProps {
} }
export function ScreenSourcePicker({ export function ScreenSourcePicker({
title = "Select a screen", title = 'Select a screen',
confirmLabel = "Select", confirmLabel = 'Select',
getSources, getSources,
onSelect, onSelect,
onCancel, onCancel,
@@ -30,8 +30,8 @@ export function ScreenSourcePicker({
}); });
}, [getSources]); }, [getSources]);
const screens = sources.filter((s) => s.id.startsWith("screen:")); const screens = sources.filter((s) => s.id.startsWith('screen:'));
const windows = sources.filter((s) => s.id.startsWith("window:")); const windows = sources.filter((s) => s.id.startsWith('window:'));
return ( return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90"> <div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
@@ -116,8 +116,8 @@ function SourceSection({
onClick={() => onSelect(source.id)} onClick={() => onSelect(source.id)}
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${ className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
selectedId === source.id selectedId === source.id
? "border-blue-500 bg-zinc-800" ? 'border-blue-500 bg-zinc-800'
: "border-transparent bg-zinc-800/50 hover:border-zinc-600" : 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
}`} }`}
> >
<img <img
+33 -30
View File
@@ -1,26 +1,26 @@
import * as React from "react" import * as React from 'react';
import { Avatar as AvatarPrimitive } from "radix-ui" import { Avatar as AvatarPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Avatar({ function Avatar({
className, className,
size = "default", size = 'default',
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & { }: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "xs" | "sm" | "lg" size?: 'default' | 'xs' | 'sm' | 'lg';
}) { }) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarImage({ function AvatarImage({
@@ -31,12 +31,12 @@ function AvatarImage({
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
className={cn( className={cn(
"rounded-full aspect-square size-full object-cover", 'rounded-full aspect-square size-full object-cover',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarFallback({ function AvatarFallback({
@@ -47,55 +47,58 @@ function AvatarFallback({
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( 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]", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="avatar-badge" data-slot="avatar-badge"
className={cn( 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", '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=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=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=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", 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="avatar-group" data-slot="avatar-group"
className={cn( className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2", '*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarGroupCount({ function AvatarGroupCount({
className, className,
...props ...props
}: React.ComponentProps<"div">) { }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="avatar-group-count" 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} {...props}
/> />
) );
} }
export { export {
@@ -105,4 +108,4 @@ export {
AvatarGroup, AvatarGroup,
AvatarGroupCount, AvatarGroupCount,
AvatarBadge, AvatarBadge,
} };
+23 -19
View File
@@ -1,36 +1,40 @@
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from "radix-ui" import { Slot } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", secondary:
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", 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", destructive:
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", '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',
link: "text-primary underline-offset-4 hover:underline", 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: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} },
) );
function Badge({ function Badge({
className, className,
variant = "default", variant = 'default',
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span" const Comp = asChild ? Slot.Root : 'span';
return ( return (
<Comp <Comp
@@ -39,7 +43,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };
+31 -34
View File
@@ -1,10 +1,10 @@
import * as React from "react" import * as React from 'react';
import { Slot } from "radix-ui" import { Slot } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" import { ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
return ( return (
<nav <nav
aria-label="breadcrumb" aria-label="breadcrumb"
@@ -12,103 +12,100 @@ function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
className={cn(className)} className={cn(className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return ( return (
<ol <ol
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground", 'flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return ( return (
<li <li
data-slot="breadcrumb-item" data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)} className={cn('inline-flex items-center gap-1', className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbLink({ function BreadcrumbLink({
asChild, asChild,
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<'a'> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "a" const Comp = asChild ? Slot.Root : 'a';
return ( return (
<Comp <Comp
data-slot="breadcrumb-link" data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)} className={cn('transition-colors hover:text-foreground', className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="breadcrumb-page" data-slot="breadcrumb-page"
role="link" role="link"
aria-disabled="true" aria-disabled="true"
aria-current="page" aria-current="page"
className={cn("font-normal text-foreground", className)} className={cn('font-normal text-foreground', className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbSeparator({ function BreadcrumbSeparator({
children, children,
className, className,
...props ...props
}: React.ComponentProps<"li">) { }: React.ComponentProps<'li'>) {
return ( return (
<li <li
data-slot="breadcrumb-separator" data-slot="breadcrumb-separator"
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)} className={cn('[&>svg]:size-3.5', className)}
{...props} {...props}
> >
{children ?? ( {children ?? <ChevronRightIcon />}
<ChevronRightIcon />
)}
</li> </li>
) );
} }
function BreadcrumbEllipsis({ function BreadcrumbEllipsis({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="breadcrumb-ellipsis" data-slot="breadcrumb-ellipsis"
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4", 'flex size-5 items-center justify-center [&>svg]:size-4',
className className,
)} )}
{...props} {...props}
> >
<MoreHorizontalIcon <MoreHorizontalIcon />
/>
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) );
} }
export { export {
@@ -119,4 +116,4 @@ export {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} };
+34 -27
View File
@@ -1,50 +1,57 @@
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from "radix-ui" import { Slot } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const buttonVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", 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", outline:
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", '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',
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", secondary:
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", 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
link: "text-primary underline-offset-4 hover:underline", 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: { 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", 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", 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", 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: '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-xs':
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-lg": "size-9", 'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} },
) );
function Button({ function Button({
className, className,
variant = "default", variant = 'default',
size = "default", size = 'default',
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : 'button';
return ( return (
<Comp <Comp
@@ -54,7 +61,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };
+36 -27
View File
@@ -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({ function Card({
className, className,
size = "default", size = 'default',
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { }: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return ( return (
<div <div
data-slot="card" data-slot="card"
data-size={size} 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} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( 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]", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-title" 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} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", 'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-content" 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} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-footer" 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} {...props}
/> />
) );
} }
export { export {
@@ -91,4 +100,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };
+9 -10
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from 'react';
import { Checkbox as CheckboxPrimitive } from "radix-ui" import { Checkbox as CheckboxPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { CheckIcon } from "lucide-react" import { CheckIcon } from 'lucide-react';
function Checkbox({ function Checkbox({
className, className,
@@ -12,8 +12,8 @@ function Checkbox({
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
> >
@@ -21,11 +21,10 @@ function Checkbox({
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5" className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
> >
<CheckIcon <CheckIcon />
/>
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };
+49 -45
View File
@@ -1,13 +1,13 @@
import * as React from "react" import * as React from 'react';
import { ContextMenu as ContextMenuPrimitive } from "radix-ui" import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { ChevronRightIcon, CheckIcon } from "lucide-react" import { ChevronRightIcon, CheckIcon } from 'lucide-react';
function ContextMenu({ function ContextMenu({
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} /> return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
} }
function ContextMenuTrigger({ function ContextMenuTrigger({
@@ -17,10 +17,10 @@ function ContextMenuTrigger({
return ( return (
<ContextMenuPrimitive.Trigger <ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger" data-slot="context-menu-trigger"
className={cn("select-none", className)} className={cn('select-none', className)}
{...props} {...props}
/> />
) );
} }
function ContextMenuGroup({ function ContextMenuGroup({
@@ -28,7 +28,7 @@ function ContextMenuGroup({
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return ( return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} /> <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
) );
} }
function ContextMenuPortal({ function ContextMenuPortal({
@@ -36,13 +36,13 @@ function ContextMenuPortal({
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return ( return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} /> <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
) );
} }
function ContextMenuSub({ function ContextMenuSub({
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) { }: 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({ function ContextMenuRadioGroup({
@@ -53,34 +53,37 @@ function ContextMenuRadioGroup({
data-slot="context-menu-radio-group" data-slot="context-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function ContextMenuContent({ function ContextMenuContent({
className, className,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: 'top' | 'right' | 'bottom' | 'left';
}) { }) {
return ( return (
<ContextMenuPrimitive.Portal> <ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
data-slot="context-menu-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} {...props}
/> />
</ContextMenuPrimitive.Portal> </ContextMenuPrimitive.Portal>
) );
} }
function ContextMenuItem({ function ContextMenuItem({
className, className,
inset, inset,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: 'default' | 'destructive';
}) { }) {
return ( return (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
@@ -89,11 +92,11 @@ function ContextMenuItem({
data-variant={variant} data-variant={variant}
className={cn( 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", "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} {...props}
/> />
) );
} }
function ContextMenuSubTrigger({ function ContextMenuSubTrigger({
@@ -102,7 +105,7 @@ function ContextMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.SubTrigger <ContextMenuPrimitive.SubTrigger
@@ -110,14 +113,14 @@ function ContextMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( 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", "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} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
) );
} }
function ContextMenuSubContent({ function ContextMenuSubContent({
@@ -127,10 +130,13 @@ function ContextMenuSubContent({
return ( return (
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content" 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} {...props}
/> />
) );
} }
function ContextMenuCheckboxItem({ function ContextMenuCheckboxItem({
@@ -140,7 +146,7 @@ function ContextMenuCheckboxItem({
inset, inset,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & { }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.CheckboxItem <ContextMenuPrimitive.CheckboxItem
@@ -148,20 +154,19 @@ function ContextMenuCheckboxItem({
data-inset={inset} data-inset={inset}
className={cn( 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", "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} checked={checked}
{...props} {...props}
> >
<span className="pointer-events-none absolute right-2"> <span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<CheckIcon <CheckIcon />
/>
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</ContextMenuPrimitive.CheckboxItem> </ContextMenuPrimitive.CheckboxItem>
) );
} }
function ContextMenuRadioItem({ function ContextMenuRadioItem({
@@ -170,7 +175,7 @@ function ContextMenuRadioItem({
inset, inset,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & { }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.RadioItem <ContextMenuPrimitive.RadioItem
@@ -178,19 +183,18 @@ function ContextMenuRadioItem({
data-inset={inset} data-inset={inset}
className={cn( 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", "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} {...props}
> >
<span className="pointer-events-none absolute right-2"> <span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<CheckIcon <CheckIcon />
/>
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</ContextMenuPrimitive.RadioItem> </ContextMenuPrimitive.RadioItem>
) );
} }
function ContextMenuLabel({ function ContextMenuLabel({
@@ -198,19 +202,19 @@ function ContextMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.Label <ContextMenuPrimitive.Label
data-slot="context-menu-label" data-slot="context-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", 'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuSeparator({ function ContextMenuSeparator({
@@ -220,26 +224,26 @@ function ContextMenuSeparator({
return ( return (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
data-slot="context-menu-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} {...props}
/> />
) );
} }
function ContextMenuShortcut({ function ContextMenuShortcut({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="context-menu-shortcut" data-slot="context-menu-shortcut"
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground", 'ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -258,4 +262,4 @@ export {
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuRadioGroup, ContextMenuRadioGroup,
} };
+41 -32
View File
@@ -1,34 +1,34 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button';
import { XIcon } from "lucide-react" import { XIcon } from 'lucide-react';
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@@ -38,10 +38,13 @@ function DialogOverlay({
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-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} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -50,7 +53,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
@@ -58,34 +61,37 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild> <DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm"> <Button
<XIcon variant="ghost"
/> className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</Button> </Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("gap-2 flex flex-col", className)} className={cn('gap-2 flex flex-col', className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
@@ -93,15 +99,15 @@ function DialogFooter({
showCloseButton = false, showCloseButton = false,
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
> >
@@ -112,7 +118,7 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ function DialogTitle({
@@ -122,10 +128,10 @@ function DialogTitle({
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)} className={cn('text-base leading-none font-medium', className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -135,10 +141,13 @@ function DialogDescription({
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-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} {...props}
/> />
) );
} }
export { export {
@@ -152,4 +161,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+52 -42
View File
@@ -1,13 +1,13 @@
import * as React from "react" import * as React from 'react';
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { CheckIcon, ChevronRightIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon } from 'lucide-react';
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@@ -26,12 +26,12 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
className, className,
align = "start", align = 'start',
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
@@ -41,11 +41,14 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
align={align} 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} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@@ -53,17 +56,17 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: 'default' | 'destructive';
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -72,11 +75,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( 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", "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} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -86,7 +89,7 @@ function DropdownMenuCheckboxItem({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
@@ -94,7 +97,7 @@ function DropdownMenuCheckboxItem({
data-inset={inset} data-inset={inset}
className={cn( 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", "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} checked={checked}
{...props} {...props}
@@ -104,13 +107,12 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator" data-slot="dropdown-menu-checkbox-item-indicator"
> >
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon <CheckIcon />
/>
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@@ -121,7 +123,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -130,7 +132,7 @@ function DropdownMenuRadioItem({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
@@ -138,7 +140,7 @@ function DropdownMenuRadioItem({
data-inset={inset} data-inset={inset}
className={cn( 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", "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} {...props}
> >
@@ -147,13 +149,12 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator" data-slot="dropdown-menu-radio-item-indicator"
> >
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon <CheckIcon />
/>
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -161,16 +162,19 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} 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} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@@ -180,29 +184,32 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-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} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" 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} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: 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({ function DropdownMenuSubTrigger({
@@ -211,7 +218,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -219,14 +226,14 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( 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", "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} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@@ -236,10 +243,13 @@ function DropdownMenuSubContent({
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" 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} {...props}
/> />
) );
} }
export { export {
@@ -258,4 +268,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };
+7 -7
View File
@@ -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 ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };
+7 -7
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Label({ function Label({
className, className,
@@ -11,12 +11,12 @@ function Label({
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+8 -8
View File
@@ -1,9 +1,9 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import { Progress as ProgressPrimitive } from "radix-ui" import { Progress as ProgressPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Progress({ function Progress({
className, className,
@@ -14,8 +14,8 @@ function Progress({
<ProgressPrimitive.Root <ProgressPrimitive.Root
data-slot="progress" data-slot="progress"
className={cn( className={cn(
"bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden", 'bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden',
className className,
)} )}
{...props} {...props}
> >
@@ -25,7 +25,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) );
} }
export { Progress } export { Progress };
+9 -9
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import { RadioGroup as RadioGroupPrimitive } from "radix-ui" import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function RadioGroup({ function RadioGroup({
className, className,
@@ -10,10 +10,10 @@ function RadioGroup({
return ( return (
<RadioGroupPrimitive.Root <RadioGroupPrimitive.Root
data-slot="radio-group" data-slot="radio-group"
className={cn("grid w-full gap-2", className)} className={cn('grid w-full gap-2', className)}
{...props} {...props}
/> />
) );
} }
function RadioGroupItem({ function RadioGroupItem({
@@ -24,8 +24,8 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
data-slot="radio-group-item" data-slot="radio-group-item"
className={cn( 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", '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 className,
)} )}
{...props} {...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" /> <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.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) );
} }
export { RadioGroup, RadioGroupItem } export { RadioGroup, RadioGroupItem };
+10 -10
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function ScrollArea({ function ScrollArea({
className, className,
@@ -11,7 +11,7 @@ function ScrollArea({
return ( return (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
data-slot="scroll-area" data-slot="scroll-area"
className={cn("relative", className)} className={cn('relative', className)}
{...props} {...props}
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
@@ -23,12 +23,12 @@ function ScrollArea({
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
) );
} }
function ScrollBar({ function ScrollBar({
className, className,
orientation = "vertical", orientation = 'vertical',
...props ...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) { }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return ( return (
@@ -37,8 +37,8 @@ function ScrollBar({
data-orientation={orientation} data-orientation={orientation}
orientation={orientation} orientation={orientation}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
> >
@@ -47,7 +47,7 @@ function ScrollBar({
className="rounded-full bg-border relative flex-1" className="rounded-full bg-border relative flex-1"
/> />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
) );
} }
export { ScrollArea, ScrollBar } export { ScrollArea, ScrollBar };
+44 -35
View File
@@ -1,15 +1,15 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import { Select as SelectPrimitive } from "radix-ui" import { Select as SelectPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react';
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
@@ -19,25 +19,25 @@ function SelectGroup({
return ( return (
<SelectPrimitive.Group <SelectPrimitive.Group
data-slot="select-group" data-slot="select-group"
className={cn("scroll-my-1 p-1", className)} className={cn('scroll-my-1 p-1', className)}
{...props} {...props}
/> />
) );
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
className, className,
size = "default", size = 'default',
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: 'sm' | 'default';
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@@ -45,7 +45,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( 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", "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} {...props}
> >
@@ -54,22 +54,27 @@ function SelectTrigger({
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" /> <ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "item-aligned", position = 'item-aligned',
align = "center", align = 'center',
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
data-align-trigger={position === "item-aligned"} 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 )} 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} position={position}
align={align} align={align}
{...props} {...props}
@@ -78,8 +83,8 @@ function SelectContent({
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
data-position={position} data-position={position}
className={cn( className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", 'data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)',
position === "popper" && "" position === 'popper' && '',
)} )}
> >
{children} {children}
@@ -87,7 +92,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@@ -97,10 +102,10 @@ function SelectLabel({
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-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} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@@ -113,7 +118,7 @@ function SelectItem({
data-slot="select-item" data-slot="select-item"
className={cn( 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", "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} {...props}
> >
@@ -124,7 +129,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@@ -134,10 +139,10 @@ function SelectSeparator({
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-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} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@@ -147,13 +152,15 @@ function SelectScrollUpButton({
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" 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} {...props}
> >
<ChevronUpIcon <ChevronUpIcon />
/>
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -163,13 +170,15 @@ function SelectScrollDownButton({
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" 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} {...props}
> >
<ChevronDownIcon <ChevronDownIcon />
/>
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -183,4 +192,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };
+8 -8
View File
@@ -1,11 +1,11 @@
import * as React from "react" import * as React from 'react';
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = 'horizontal',
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
@@ -15,12 +15,12 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", 'bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };
+5 -5
View File
@@ -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 ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn("bg-muted rounded-md animate-pulse", className)} className={cn('bg-muted rounded-md animate-pulse', className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };
+9 -9
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import { Slider as SliderPrimitive } from "radix-ui" import { Slider as SliderPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Slider({ function Slider({
className, className,
@@ -18,8 +18,8 @@ function Slider({
: Array.isArray(defaultValue) : Array.isArray(defaultValue)
? defaultValue ? defaultValue
: [min, max], : [min, max],
[value, defaultValue, min, max] [value, defaultValue, min, max],
) );
return ( return (
<SliderPrimitive.Root <SliderPrimitive.Root
@@ -29,8 +29,8 @@ function Slider({
min={min} min={min}
max={max} max={max}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
> >
@@ -51,7 +51,7 @@ function Slider({
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>
) );
} }
export { Slider } export { Slider };
+21 -25
View File
@@ -1,5 +1,11 @@
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from 'sonner';
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from 'lucide-react';
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
return ( return (
@@ -7,38 +13,28 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme="dark" theme="dark"
className="toaster group" className="toaster group"
icons={{ icons={{
success: ( success: <CircleCheckIcon className="size-4" />,
<CircleCheckIcon className="size-4" /> info: <InfoIcon className="size-4" />,
), warning: <TriangleAlertIcon className="size-4" />,
info: ( error: <OctagonXIcon className="size-4" />,
<InfoIcon className="size-4" /> loading: <Loader2Icon className="size-4 animate-spin" />,
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}} }}
style={ style={
{ {
"--normal-bg": "var(--popover)", '--normal-bg': 'var(--popover)',
"--normal-text": "var(--popover-foreground)", '--normal-text': 'var(--popover-foreground)',
"--normal-border": "var(--border)", '--normal-border': 'var(--border)',
"--border-radius": "var(--radius)", '--border-radius': 'var(--radius)',
} as React.CSSProperties } as React.CSSProperties
} }
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: "cn-toast", toast: 'cn-toast',
}, },
}} }}
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };
+9 -9
View File
@@ -1,22 +1,22 @@
import * as React from "react" import * as React from 'react';
import { Switch as SwitchPrimitive } from "radix-ui" import { Switch as SwitchPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Switch({ function Switch({
className, className,
size = "default", size = 'default',
...props ...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & { }: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default" size?: 'sm' | 'default';
}) { }) {
return ( return (
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="switch" data-slot="switch"
data-size={size} data-size={size}
className={cn( 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", '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 className,
)} )}
{...props} {...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" 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> </SwitchPrimitive.Root>
) );
} }
export { Switch } export { Switch };
+25 -25
View File
@@ -1,14 +1,14 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { Tabs as TabsPrimitive } from "radix-ui" import { Tabs as TabsPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Tabs({ function Tabs({
className, className,
orientation = "horizontal", orientation = 'horizontal',
...props ...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) { }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return ( return (
@@ -16,32 +16,32 @@ function Tabs({
data-slot="tabs" data-slot="tabs"
data-orientation={orientation} data-orientation={orientation}
className={cn( className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col", 'group/tabs flex gap-2 data-horizontal:flex-col',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
const tabsListVariants = cva( 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: { variants: {
variant: { variant: {
default: "bg-muted", default: 'bg-muted',
line: "gap-1 bg-transparent", line: 'gap-1 bg-transparent',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} },
) );
function TabsList({ function TabsList({
className, className,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<typeof TabsPrimitive.List> & }: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) { VariantProps<typeof tabsListVariants>) {
@@ -52,7 +52,7 @@ function TabsList({
className={cn(tabsListVariants({ variant }), className)} className={cn(tabsListVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
function TabsTrigger({ function TabsTrigger({
@@ -64,14 +64,14 @@ function TabsTrigger({
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( 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", "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", '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", '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", '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 className,
)} )}
{...props} {...props}
/> />
) );
} }
function TabsContent({ function TabsContent({
@@ -81,10 +81,10 @@ function TabsContent({
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
data-slot="tabs-content" data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)} className={cn('flex-1 text-sm outline-none', className)}
{...props} {...props}
/> />
) );
} }
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+25 -25
View File
@@ -1,34 +1,34 @@
import * as React from "react" import * as React from 'react';
import { type VariantProps } from "class-variance-authority" import { type VariantProps } from 'class-variance-authority';
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui" import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { toggleVariants } from "@/components/ui/toggle" import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext< const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & { VariantProps<typeof toggleVariants> & {
spacing?: number spacing?: number;
orientation?: "horizontal" | "vertical" orientation?: 'horizontal' | 'vertical';
} }
>({ >({
size: "default", size: 'default',
variant: "default", variant: 'default',
spacing: 0, spacing: 0,
orientation: "horizontal", orientation: 'horizontal',
}) });
function ToggleGroup({ function ToggleGroup({
className, className,
variant, variant,
size, size,
spacing = 0, spacing = 0,
orientation = "horizontal", orientation = 'horizontal',
children, children,
...props ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & { VariantProps<typeof toggleVariants> & {
spacing?: number spacing?: number;
orientation?: "horizontal" | "vertical" orientation?: 'horizontal' | 'vertical';
}) { }) {
return ( return (
<ToggleGroupPrimitive.Root <ToggleGroupPrimitive.Root
@@ -37,10 +37,10 @@ function ToggleGroup({
data-size={size} data-size={size}
data-spacing={spacing} data-spacing={spacing}
data-orientation={orientation} data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties} style={{ '--gap': spacing } as React.CSSProperties}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
> >
@@ -50,18 +50,18 @@ function ToggleGroup({
{children} {children}
</ToggleGroupContext.Provider> </ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root> </ToggleGroupPrimitive.Root>
) );
} }
function ToggleGroupItem({ function ToggleGroupItem({
className, className,
children, children,
variant = "default", variant = 'default',
size = "default", size = 'default',
...props ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) { VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext) const context = React.useContext(ToggleGroupContext);
return ( return (
<ToggleGroupPrimitive.Item <ToggleGroupPrimitive.Item
@@ -70,18 +70,18 @@ function ToggleGroupItem({
data-size={context.size || size} data-size={context.size || size}
data-spacing={context.spacing} data-spacing={context.spacing}
className={cn( 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({ toggleVariants({
variant: context.variant || variant, variant: context.variant || variant,
size: context.size || size, size: context.size || size,
}), }),
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</ToggleGroupPrimitive.Item> </ToggleGroupPrimitive.Item>
) );
} }
export { ToggleGroup, ToggleGroupItem } export { ToggleGroup, ToggleGroupItem };
+17 -17
View File
@@ -1,34 +1,34 @@
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { Toggle as TogglePrimitive } from "radix-ui" import { Toggle as TogglePrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const toggleVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: "bg-transparent", default: 'bg-transparent',
outline: "border border-input bg-transparent hover:bg-muted", outline: 'border border-input bg-transparent hover:bg-muted',
}, },
size: { size: {
default: "h-8 min-w-8 px-2", 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]", 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", lg: 'h-9 min-w-9 px-2.5',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} },
) );
function Toggle({ function Toggle({
className, className,
variant = "default", variant = 'default',
size = "default", size = 'default',
...props ...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & }: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) { VariantProps<typeof toggleVariants>) {
@@ -38,7 +38,7 @@ function Toggle({
className={cn(toggleVariants({ variant, size, className }))} className={cn(toggleVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Toggle, toggleVariants } export { Toggle, toggleVariants };
+10 -10
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import { Tooltip as TooltipPrimitive } from "radix-ui" import { Tooltip as TooltipPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -13,19 +13,19 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@@ -40,8 +40,8 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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)", '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 className,
)} )}
{...props} {...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.Arrow className="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+58 -74
View File
@@ -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 ( return (
<h1 <h1
className={cn( className={cn(
"scroll-m-20 text-4xl font-extrabold tracking-tight text-balance", 'scroll-m-20 text-4xl font-extrabold tracking-tight text-balance',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function H2({ className, ...props }: React.ComponentProps<"h2">) { function H2({ className, ...props }: React.ComponentProps<'h2'>) {
return ( return (
<h2 <h2
className={cn( className={cn(
"scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0", 'scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function H3({ className, ...props }: React.ComponentProps<"h3">) { function H3({ className, ...props }: React.ComponentProps<'h3'>) {
return ( return (
<h3 <h3
className={cn( className={cn(
"scroll-m-20 text-2xl font-semibold tracking-tight", 'scroll-m-20 text-2xl font-semibold tracking-tight',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function H4({ className, ...props }: React.ComponentProps<"h4">) { function H4({ className, ...props }: React.ComponentProps<'h4'>) {
return ( return (
<h4 <h4
className={cn( className={cn(
"scroll-m-20 text-xl font-semibold tracking-tight", 'scroll-m-20 text-xl font-semibold tracking-tight',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function P({ className, ...props }: React.ComponentProps<"p">) { function P({ className, ...props }: React.ComponentProps<'p'>) {
return ( return (
<p <p
className={cn( className={cn('leading-7 [&:not(:first-child)]:mt-6', className)}
"leading-7 [&:not(:first-child)]:mt-6",
className
)}
{...props} {...props}
/> />
) );
} }
function Blockquote({ className, ...props }: React.ComponentProps<"blockquote">) { function Blockquote({
className,
...props
}: React.ComponentProps<'blockquote'>) {
return ( return (
<blockquote <blockquote
className={cn( className={cn('mt-6 border-l-2 pl-6 italic', className)}
"mt-6 border-l-2 pl-6 italic",
className
)}
{...props} {...props}
/> />
) );
} }
function List({ className, ...props }: React.ComponentProps<"ul">) { function List({ className, ...props }: React.ComponentProps<'ul'>) {
return ( return (
<ul <ul
className={cn( className={cn('my-6 ml-6 list-disc [&>li]:mt-2', className)}
"my-6 ml-6 list-disc [&>li]:mt-2",
className
)}
{...props} {...props}
/> />
) );
} }
function InlineCode({ className, ...props }: React.ComponentProps<"code">) { function InlineCode({ className, ...props }: React.ComponentProps<'code'>) {
return ( return (
<code <code
className={cn( className={cn(
"bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold", 'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function Lead({ className, ...props }: React.ComponentProps<"p">) { function Lead({ className, ...props }: React.ComponentProps<'p'>) {
return ( return (
<p <p className={cn('text-muted-foreground text-xl', className)} {...props} />
className={cn( );
"text-muted-foreground text-xl",
className
)}
{...props}
/>
)
} }
function Large({ className, ...props }: React.ComponentProps<"div">) { function Large({ className, ...props }: React.ComponentProps<'div'>) {
return ( return <div className={cn('text-lg font-semibold', className)} {...props} />;
<div
className={cn(
"text-lg font-semibold",
className
)}
{...props}
/>
)
} }
function Small({ className, ...props }: React.ComponentProps<"small">) { function Small({ className, ...props }: React.ComponentProps<'small'>) {
return ( return (
<small <small
className={cn( className={cn('text-sm leading-none font-medium', className)}
"text-sm leading-none font-medium",
className
)}
{...props} {...props}
/> />
) );
} }
function Muted({ className, ...props }: React.ComponentProps<"p">) { function Muted({ className, ...props }: React.ComponentProps<'p'>) {
return ( return (
<p <p className={cn('text-muted-foreground text-sm', className)} {...props} />
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 { Video, Mic } from 'lucide-react';
import { useMediaSettingsStore } from "@/stores/media-settings-store"; import { useMediaSettingsStore } from '@/stores/media-settings-store';
export function VideoAudioToggle() { export function VideoAudioToggle() {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
@@ -9,17 +9,19 @@ export function VideoAudioToggle() {
<span <span
role="button" role="button"
onClick={() => onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video") setRecordingMode(recordingMode === 'video' ? 'audio' : 'video')
} }
title={ 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" 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"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
V V
</kbd>{" "} </kbd>{' '}
{recordingMode === "video" ? ( {recordingMode === 'video' ? (
<> <>
<Video className="inline size-3" /> video <Video className="inline size-3" /> video
</> </>
+8 -14
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { Copy, Minus, Square, X } from "lucide-react"; import { Copy, Minus, Square, X } from 'lucide-react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
export function WindowControls() { export function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
@@ -12,7 +12,7 @@ export function WindowControls() {
return platform.window.onMaximizeChange(setIsMaximized); return platform.window.onMaximizeChange(setIsMaximized);
}, []); }, []);
if (platform.kind !== "electron") return null; if (platform.kind !== 'electron') return null;
const minimize = ( const minimize = (
<Button <Button
@@ -33,7 +33,7 @@ export function WindowControls() {
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => platform.window.maximize()} onClick={() => platform.window.maximize()}
aria-label={isMaximized ? "Restore" : "Maximize"} aria-label={isMaximized ? 'Restore' : 'Maximize'}
className="dark:hover:bg-white/10 rounded text-white/50" className="dark:hover:bg-white/10 rounded text-white/50"
> >
{isMaximized ? <Copy /> : <Square />} {isMaximized ? <Copy /> : <Square />}
@@ -56,12 +56,6 @@ export function WindowControls() {
const buttons = [close, maximize, minimize]; const buttons = [close, maximize, minimize];
return ( return (
<div <div className={cn('no-drag flex items-center gap-0.5')}>{buttons}</div>
className={cn(
"no-drag flex items-center gap-0.5"
)}
>
{buttons}
</div>
); );
} }
+23 -21
View File
@@ -8,7 +8,7 @@
// rules + App Check), so both configs live in source. To refresh, run: // rules + App Check), so both configs live in source. To refresh, run:
// cd infra/gcp/{dev,prod} && terraform output -json firebase_config // cd infra/gcp/{dev,prod} && terraform output -json firebase_config
declare const __APP_ENV__: "dev" | "prod"; declare const __APP_ENV__: 'dev' | 'prod';
type FirebaseConfig = { type FirebaseConfig = {
apiKey: string; apiKey: string;
@@ -27,34 +27,36 @@ type AppConfig = {
sentryDsn: string; sentryDsn: string;
}; };
const configs: Record<"dev" | "prod", AppConfig> = { const configs: Record<'dev' | 'prod', AppConfig> = {
dev: { dev: {
orionUrl: "https://orion.dev.flowy.live", orionUrl: 'https://orion.dev.flowy.live',
pusherUrl: "wss://pusher.dev.flowy.live/ws", pusherUrl: 'wss://pusher.dev.flowy.live/ws',
firebase: { firebase: {
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk", apiKey: 'AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk',
appId: "1:1006580076785:web:e2a0736d60a78e02b15950", appId: '1:1006580076785:web:e2a0736d60a78e02b15950',
authDomain: "flowy-dev-440017.firebaseapp.com", authDomain: 'flowy-dev-440017.firebaseapp.com',
messagingSenderId: "1006580076785", messagingSenderId: '1006580076785',
projectId: "flowy-dev-440017", projectId: 'flowy-dev-440017',
storageBucket: "flowy-dev-440017.firebasestorage.app", 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: { prod: {
orionUrl: "https://orion.flowy.live", orionUrl: 'https://orion.flowy.live',
pusherUrl: "wss://pusher.flowy.live/ws", pusherUrl: 'wss://pusher.flowy.live/ws',
firebase: { firebase: {
apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg", apiKey: 'AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg',
appId: "1:68063426854:web:5054f16f50898f5706e9e7", appId: '1:68063426854:web:5054f16f50898f5706e9e7',
authDomain: "flowy-prod-440017.firebaseapp.com", authDomain: 'flowy-prod-440017.firebaseapp.com',
messagingSenderId: "68063426854", messagingSenderId: '68063426854',
projectId: "flowy-prod-440017", projectId: 'flowy-prod-440017',
storageBucket: "flowy-prod-440017.firebasestorage.app", 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 appConfig: AppConfig = configs[__APP_ENV__];
export const appEnv: "dev" | "prod" = __APP_ENV__; export const appEnv: 'dev' | 'prod' = __APP_ENV__;
+6 -2
View File
@@ -17,7 +17,9 @@ declare global {
openHuddle: (data: { token: string; serverUrl: string }) => void; openHuddle: (data: { token: string; serverUrl: string }) => void;
closeHuddle: () => void; closeHuddle: () => void;
platform: NodeJS.Platform; platform: NodeJS.Platform;
onMaximizeChange: (callback: (isMaximized: boolean) => void) => () => void; onMaximizeChange: (
callback: (isMaximized: boolean) => void,
) => () => void;
}; };
electronHuddle: { electronHuddle: {
getScreenSources: () => Promise<ScreenSource[]>; getScreenSources: () => Promise<ScreenSource[]>;
@@ -28,7 +30,9 @@ declare global {
navigate: (data: { networkId: string; streamId: string }) => void; navigate: (data: { networkId: string; streamId: string }) => void;
onPlay: (callback: (payload: AutoplayPayload) => void) => () => void; onPlay: (callback: (payload: AutoplayPayload) => void) => () => void;
onStop: (callback: () => 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: { electronScreen: {
getScreenSources: () => Promise<ScreenSource[]>; getScreenSources: () => Promise<ScreenSource[]>;
@@ -1,5 +1,5 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from 'react';
import { Dialog as DialogPrimitive } from "radix-ui"; import { Dialog as DialogPrimitive } from 'radix-ui';
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -8,21 +8,19 @@ import {
Loader2, Loader2,
Trash2, Trash2,
X, X,
} from "lucide-react"; } from 'lucide-react';
import { useDownloadUrl } from "@/hooks/use-download-url"; import { useDownloadUrl } from '@/hooks/use-download-url';
import { useObjectUrl } from "@/hooks/use-object-url"; import { useObjectUrl } from '@/hooks/use-object-url';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
export interface AttachmentItem { export interface AttachmentItem {
id: string; id: string;
filename: string; filename: string;
mimeType: string; mimeType: string;
sizeBytes?: number; sizeBytes?: number;
source: source: { kind: 'remote'; objectId: string } | { kind: 'local'; file: File };
| { kind: "remote"; objectId: string }
| { kind: "local"; file: File };
} }
interface AttachmentLightboxProps { interface AttachmentLightboxProps {
@@ -37,11 +35,13 @@ interface AttachmentLightboxProps {
* Return `"lightbox"` for mime types that preview in-app, `"external"` otherwise. * 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. * Callers use this to decide whether to open the lightbox or hand off to the OS.
*/ */
export function getAttachmentHandler(mimeType: string): "lightbox" | "external" { export function getAttachmentHandler(
if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) { mimeType: string,
return "lightbox"; ): 'lightbox' | 'external' {
if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) {
return 'lightbox';
} }
return "external"; return 'external';
} }
function formatSize(bytes?: number): string | null { function formatSize(bytes?: number): string | null {
@@ -68,21 +68,19 @@ export function AttachmentLightbox({
// Remote items resolve through the signed-URL cache; disabled when not remote. // Remote items resolve through the signed-URL cache; disabled when not remote.
const remoteObjectId = const remoteObjectId =
current?.source.kind === "remote" ? current.source.objectId : undefined; current?.source.kind === 'remote' ? current.source.objectId : undefined;
const { data: remoteUrl, isLoading: isRemoteLoading } = const { data: remoteUrl, isLoading: isRemoteLoading } =
useDownloadUrl(remoteObjectId); useDownloadUrl(remoteObjectId);
// Local items resolve to a blob URL; remote items use the signed-URL cache. // Local items resolve to a blob URL; remote items use the signed-URL cache.
const localFile = const localFile =
current?.source.kind === "local" ? current.source.file : null; current?.source.kind === 'local' ? current.source.file : null;
const localUrl = useObjectUrl(localFile); const localUrl = useObjectUrl(localFile);
const url = const url =
current?.source.kind === "remote" current?.source.kind === 'remote' ? (remoteUrl ?? null) : localUrl;
? remoteUrl ?? null
: localUrl;
const canDownload = current?.source.kind === "remote" && !!url; const canDownload = current?.source.kind === 'remote' && !!url;
const goTo = useCallback( const goTo = useCallback(
(delta: number) => { (delta: number) => {
@@ -94,7 +92,7 @@ export function AttachmentLightbox({
); );
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
if (!current || !url || current.source.kind !== "remote") return; if (!current || !url || current.source.kind !== 'remote') return;
platform.attachment.download(url, current.filename); platform.attachment.download(url, current.filename);
}, [current, url]); }, [current, url]);
@@ -111,7 +109,7 @@ export function AttachmentLightbox({
// Otherwise openIndex stays — the next item shifts into its place. // Otherwise openIndex stays — the next item shifts into its place.
}, [current, onRemove, items.length, openIndex, onOpenChange]); }, [current, onRemove, items.length, openIndex, onOpenChange]);
useSuspendPlayback(isOpen, "attachment-lightbox"); useSuspendPlayback(isOpen, 'attachment-lightbox');
// Keyboard handling — only listens while open. Registered in the capture // Keyboard handling — only listens while open. Registered in the capture
// phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫) // phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫)
@@ -122,8 +120,8 @@ export function AttachmentLightbox({
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
target && target &&
(target.tagName === "INPUT" || (target.tagName === 'INPUT' ||
target.tagName === "TEXTAREA" || target.tagName === 'TEXTAREA' ||
target.isContentEditable) target.isContentEditable)
) { ) {
return; return;
@@ -132,29 +130,38 @@ export function AttachmentLightbox({
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
}; };
if (e.key === "Escape") { if (e.key === 'Escape') {
consume(); consume();
onOpenChange(null); onOpenChange(null);
} else if (e.key === "ArrowLeft" && hasMultiple) { } else if (e.key === 'ArrowLeft' && hasMultiple) {
consume(); consume();
goTo(-1); goTo(-1);
} else if (e.key === "ArrowRight" && hasMultiple) { } else if (e.key === 'ArrowRight' && hasMultiple) {
consume(); consume();
goTo(1); goTo(1);
} else if ((e.key === "d" || e.key === "D") && canDownload) { } else if ((e.key === 'd' || e.key === 'D') && canDownload) {
consume(); consume();
handleDownload(); handleDownload();
} else if ((e.key === "Backspace" || e.key === "Delete") && onRemove) { } else if ((e.key === 'Backspace' || e.key === 'Delete') && onRemove) {
consume(); consume();
handleRemove(); handleRemove();
} }
}; };
window.addEventListener("keydown", handle, true); window.addEventListener('keydown', handle, true);
return () => window.removeEventListener("keydown", handle, true); return () => window.removeEventListener('keydown', handle, true);
}, [isOpen, onOpenChange, onRemove, hasMultiple, canDownload, goTo, handleDownload, handleRemove]); }, [
isOpen,
onOpenChange,
onRemove,
hasMultiple,
canDownload,
goTo,
handleDownload,
handleRemove,
]);
const isImage = current?.mimeType.startsWith("image/"); const isImage = current?.mimeType.startsWith('image/');
const isVideo = current?.mimeType.startsWith("video/"); const isVideo = current?.mimeType.startsWith('video/');
const sizeLabel = formatSize(current?.sizeBytes); const sizeLabel = formatSize(current?.sizeBytes);
return ( return (
@@ -277,7 +284,9 @@ export function AttachmentLightbox({
{url && !isImage && !isVideo && ( {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"> <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" /> <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>
)} )}
</div> </div>
+11 -12
View File
@@ -1,9 +1,9 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Input } from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { Label } from "@/components/ui/label"; import { Label } from '@/components/ui/label';
import { H3, Muted } from "@/components/ui/typography"; import { H3, Muted } from '@/components/ui/typography';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
interface CodeStepProps { interface CodeStepProps {
email: string; email: string;
@@ -11,7 +11,7 @@ interface CodeStepProps {
} }
export function CodeStep({ email, onBack }: CodeStepProps) { export function CodeStep({ email, onBack }: CodeStepProps) {
const [code, setCode] = useState(""); const [code, setCode] = useState('');
const isSigningIn = useAuthStore((s) => s.isSigningIn); const isSigningIn = useAuthStore((s) => s.isSigningIn);
const error = useAuthStore((s) => s.error); const error = useAuthStore((s) => s.error);
const signIn = useAuthStore((s) => s.signIn); const signIn = useAuthStore((s) => s.signIn);
@@ -31,7 +31,8 @@ export function CodeStep({ email, onBack }: CodeStepProps) {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<H3>Check your email</H3> <H3>Check your email</H3>
<Muted> <Muted>
We sent a code to <strong className="text-foreground">{email}</strong>. We sent a code to <strong className="text-foreground">{email}</strong>
.
</Muted> </Muted>
</div> </div>
@@ -52,13 +53,11 @@ export function CodeStep({ email, onBack }: CodeStepProps) {
/> />
</div> </div>
{error && ( {error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button type="submit" disabled={isSigningIn || !code}> <Button type="submit" disabled={isSigningIn || !code}>
{isSigningIn ? "Signing in..." : "Sign in"} {isSigningIn ? 'Signing in...' : 'Sign in'}
</Button> </Button>
<Button type="button" variant="ghost" onClick={onBack}> <Button type="button" variant="ghost" onClick={onBack}>
Back Back
+14 -16
View File
@@ -1,18 +1,18 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Input } from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { Label } from "@/components/ui/label"; import { Label } from '@/components/ui/label';
import { H3, Muted } from "@/components/ui/typography"; import { H3, Muted } from '@/components/ui/typography';
import { PRIVACY_URL, TERMS_URL } from "@/lib/constants"; import { PRIVACY_URL, TERMS_URL } from '@/lib/constants';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
interface EmailStepProps { interface EmailStepProps {
onCodeSent: (email: string) => void; onCodeSent: (email: string) => void;
} }
export function EmailStep({ onCodeSent }: EmailStepProps) { export function EmailStep({ onCodeSent }: EmailStepProps) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState('');
const isRequestingCode = useAuthStore((s) => s.isRequestingCode); const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
const error = useAuthStore((s) => s.error); const error = useAuthStore((s) => s.error);
const requestCode = useAuthStore((s) => s.requestCode); const requestCode = useAuthStore((s) => s.requestCode);
@@ -51,24 +51,22 @@ export function EmailStep({ onCodeSent }: EmailStepProps) {
/> />
</div> </div>
{error && ( {error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" disabled={isRequestingCode || !email}> <Button type="submit" disabled={isRequestingCode || !email}>
{isRequestingCode ? "Sending..." : "Continue"} {isRequestingCode ? 'Sending...' : 'Continue'}
</Button> </Button>
<Muted className="text-center text-xs"> <Muted className="text-center text-xs">
By continuing, you agree to our{" "} By continuing, you agree to our{' '}
<button <button
type="button" type="button"
onClick={() => platform.link.openExternal(TERMS_URL)} onClick={() => platform.link.openExternal(TERMS_URL)}
className="underline underline-offset-2 hover:text-foreground" className="underline underline-offset-2 hover:text-foreground"
> >
Terms of Service Terms of Service
</button>{" "} </button>{' '}
and{" "} and{' '}
<button <button
type="button" type="button"
onClick={() => platform.link.openExternal(PRIVACY_URL)} onClick={() => platform.link.openExternal(PRIVACY_URL)}
+10 -13
View File
@@ -1,13 +1,13 @@
import { useState } from "react"; import { useState } from 'react';
import { WindowControls } from "@/components/window-controls"; import { WindowControls } from '@/components/window-controls';
import { EmailStep } from "./email-step"; import { EmailStep } from './email-step';
import { CodeStep } from "./code-step"; import { CodeStep } from './code-step';
type Step = "email" | "code"; type Step = 'email' | 'code';
export function LoginPage() { export function LoginPage() {
const [step, setStep] = useState<Step>("email"); const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState(""); const [email, setEmail] = useState('');
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@@ -16,18 +16,15 @@ export function LoginPage() {
</div> </div>
<div className="flex flex-1 items-center justify-center p-4"> <div className="flex flex-1 items-center justify-center p-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
{step === "email" ? ( {step === 'email' ? (
<EmailStep <EmailStep
onCodeSent={(submittedEmail) => { onCodeSent={(submittedEmail) => {
setEmail(submittedEmail); setEmail(submittedEmail);
setStep("code"); setStep('code');
}} }}
/> />
) : ( ) : (
<CodeStep <CodeStep email={email} onBack={() => setStep('email')} />
email={email}
onBack={() => setStep("email")}
/>
)} )}
</div> </div>
</div> </div>
@@ -1,21 +1,21 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from 'react';
import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react"; import { FileIcon, Globe, Loader2, Plus, X } from 'lucide-react';
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from '@/components/ui/skeleton';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import type { LinkPreviewEntry } from "@/hooks/use-link-metadata"; import type { LinkPreviewEntry } from '@/hooks/use-link-metadata';
import { import {
AttachmentLightbox, AttachmentLightbox,
getAttachmentHandler, getAttachmentHandler,
type AttachmentItem, type AttachmentItem,
} from "@/features/attachments/attachment-lightbox"; } from '@/features/attachments/attachment-lightbox';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
export interface PendingAttachment { export interface PendingAttachment {
id: string; id: string;
file: File; file: File;
thumbnailUrl?: string; thumbnailUrl?: string;
status: "pending" | "uploading" | "uploaded" | "error"; status: 'pending' | 'uploading' | 'uploaded' | 'error';
} }
interface AttachmentStripProps { interface AttachmentStripProps {
@@ -29,9 +29,9 @@ function pendingToItem(p: PendingAttachment): AttachmentItem {
return { return {
id: p.id, id: p.id,
filename: p.file.name, filename: p.file.name,
mimeType: p.file.type || "application/octet-stream", mimeType: p.file.type || 'application/octet-stream',
sizeBytes: p.file.size, sizeBytes: p.file.size,
source: { kind: "local", file: p.file }, source: { kind: 'local', file: p.file },
}; };
} }
@@ -50,20 +50,20 @@ function AttachmentThumbnail({
onRemove: () => void; onRemove: () => void;
onPreview?: () => void; onPreview?: () => void;
}) { }) {
const isImage = attachment.file.type.startsWith("image/"); const isImage = attachment.file.type.startsWith('image/');
const isUploading = attachment.status === "uploading"; const isUploading = attachment.status === 'uploading';
const isError = attachment.status === "error"; const isError = attachment.status === 'error';
const previewable = getAttachmentHandler(attachment.file.type) === "lightbox"; const previewable = getAttachmentHandler(attachment.file.type) === 'lightbox';
return ( return (
<div <div
role={previewable ? "button" : undefined} role={previewable ? 'button' : undefined}
tabIndex={previewable ? 0 : undefined} tabIndex={previewable ? 0 : undefined}
onClick={previewable && onPreview ? onPreview : undefined} onClick={previewable && onPreview ? onPreview : undefined}
className={cn( className={cn(
"group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10", 'group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10',
previewable && "cursor-pointer", previewable && 'cursor-pointer',
isError && "ring-1 ring-red-400/50", isError && 'ring-1 ring-red-400/50',
)} )}
> >
{isImage && attachment.thumbnailUrl ? ( {isImage && attachment.thumbnailUrl ? (
@@ -131,7 +131,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
alt="" alt=""
className="size-3 rounded-sm" className="size-3 rounded-sm"
onError={(e) => { 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. // Lightbox state — only previewable attachments go in.
const previewable = useMemo( const previewable = useMemo(
() => attachments.filter((a) => getAttachmentHandler(a.file.type) === "lightbox"), () =>
attachments.filter(
(a) => getAttachmentHandler(a.file.type) === 'lightbox',
),
[attachments], [attachments],
); );
const items = useMemo(() => previewable.map(pendingToItem), [previewable]); const items = useMemo(() => previewable.map(pendingToItem), [previewable]);
@@ -1,34 +1,48 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from "sonner"; import { toast } from 'sonner';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle"; import {
import { QuotaExceededError } from "@/lib/errors"; useCreateParticle,
import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage } from "@/hooks/use-network-usage"; useCreateStreamParticle,
import { useRecorder } from "@/features/compose/use-recorder"; } from '@/hooks/use-create-particle';
import { useScreenRecorder } from "@/features/compose/use-screen-recorder"; import { QuotaExceededError } from '@/lib/errors';
import { particlePath, parseParticlePath } from "@/lib/particle-path"; import {
import type { ParticlePath } from "@/lib/particle-path"; isUsageExhausted,
import { RecordingOverlay } from "@/features/compose/recording-overlay"; useInvalidateNetworkUsage,
import { ScreenSourcePicker } from "@/components/screen-source-picker"; useNetworkUsage,
import { TextComposeStep } from "@/features/compose/text-compose-step"; } from '@/hooks/use-network-usage';
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step"; import { useRecorder } from '@/features/compose/use-recorder';
import { apiClient } from "@/api/client"; import { useScreenRecorder } from '@/features/compose/use-screen-recorder';
import { useMediaSettingsStore } from "@/stores/media-settings-store"; import { particlePath, parseParticlePath } from '@/lib/particle-path';
import { useMediaDevicesStore } from "@/stores/media-devices-store"; import type { ParticlePath } from '@/lib/particle-path';
import { useMediaDevices } from "@/hooks/use-media-devices"; import { RecordingOverlay } from '@/features/compose/recording-overlay';
import { resolveEffectiveDeviceId } from "@/hooks/use-effective-device-id"; import { ScreenSourcePicker } from '@/components/screen-source-picker';
import { useFileInput } from "@/hooks/use-file-input"; import { TextComposeStep } from '@/features/compose/text-compose-step';
import { createImageThumbnail } from "@/lib/image-thumbnail"; import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants"; import { apiClient } from '@/api/client';
import type { PendingAttachment } from "@/features/compose/attachment-strip"; import { useMediaSettingsStore } from '@/stores/media-settings-store';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useMediaDevicesStore } from '@/stores/media-devices-store';
import { useComposeIntentStore } from "@/stores/compose-intent-store"; import { useMediaDevices } from '@/hooks/use-media-devices';
import { platform } from "@/lib/platform"; import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
import { requireDesktop } from "@/lib/platform/desktop-only"; 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 { interface ComposeOverlayProps {
networkId: string; networkId: string;
@@ -41,7 +55,6 @@ interface ComposeOverlayProps {
disabled?: boolean; disabled?: boolean;
} }
const HOLD_THRESHOLD_MS = 250; const HOLD_THRESHOLD_MS = 250;
/** /**
@@ -56,16 +69,17 @@ export function ComposeOverlay({
onParticleCreated, onParticleCreated,
disabled, disabled,
}: ComposeOverlayProps) { }: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>("idle"); const [step, setStep] = useState<ComposeStep>('idle');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [textContent, setTextContent] = useState(""); const [textContent, setTextContent] = useState('');
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null); const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
const [reviewDurationMs, setReviewDurationMs] = useState(0); const [reviewDurationMs, setReviewDurationMs] = useState(0);
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null); const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]); const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media"); const [recordingSource, setRecordingSource] =
useState<RecordingSource>('media');
const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const savedMic = useMediaDevicesStore((s) => s.mic); const savedMic = useMediaDevicesStore((s) => s.mic);
@@ -97,72 +111,80 @@ export function ComposeOverlay({
setStep(next); setStep(next);
}, []); }, []);
useSuspendPlayback(step !== "idle", "compose"); useSuspendPlayback(step !== 'idle', 'compose');
// Notify parent when active state changes // Notify parent when active state changes
useEffect(() => { useEffect(() => {
onActiveChange?.(step !== "idle"); onActiveChange?.(step !== 'idle');
onStepChange?.(step); onStepChange?.(step);
// Refresh quota when the overlay activates — user is about to send, so // 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. // we want the most accurate count before the client-side gate kicks in.
if (step !== "idle") { if (step !== 'idle') {
void invalidateUsage(networkId); void invalidateUsage(networkId);
} }
}, [step, onActiveChange, onStepChange, invalidateUsage, networkId]); }, [step, onActiveChange, onStepChange, invalidateUsage, networkId]);
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => { const revokeAttachmentThumbnails = useCallback(
for (const a of items) { (items: PendingAttachment[]) => {
if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl); for (const a of items) {
} if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl);
}, []); }
},
[],
);
const cancel = useCallback(() => { const cancel = useCallback(() => {
setStepSync("idle"); setStepSync('idle');
setError(null); setError(null);
setTextContent(""); setTextContent('');
setMediaStream(null); setMediaStream(null);
setReviewBlob(null); setReviewBlob(null);
setReviewDurationMs(0); setReviewDurationMs(0);
setReviewMimeType(null); setReviewMimeType(null);
setRecordingSource("media"); setRecordingSource('media');
setAttachments((prev) => { setAttachments((prev) => {
revokeAttachmentThumbnails(prev); revokeAttachmentThumbnails(prev);
return []; return [];
}); });
}, [setStepSync, revokeAttachmentThumbnails]); }, [setStepSync, revokeAttachmentThumbnails]);
const addAttachments = useCallback(async (files: File[]) => { const addAttachments = useCallback(
const currentCount = attachments.length; async (files: File[]) => {
const available = MAX_ATTACHMENTS - currentCount; const currentCount = attachments.length;
if (available <= 0) { const available = MAX_ATTACHMENTS - currentCount;
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`); if (available <= 0) {
return; 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 thumbnailUrl = await createImageThumbnail(file);
newAttachments.push({
id: crypto.randomUUID(),
file,
thumbnailUrl,
status: "pending",
});
}
if (newAttachments.length > 0) { const accepted = files.slice(0, available);
setAttachments((prev) => [...prev, ...newAttachments]); if (accepted.length < files.length) {
} toast.error(
}, [attachments.length]); `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) => { const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => { setAttachments((prev) => {
@@ -174,7 +196,7 @@ export function ComposeOverlay({
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({ const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
onFilesSelected: addAttachments, onFilesSelected: addAttachments,
enabled: step === "typing" || step === "reviewing", enabled: step === 'typing' || step === 'reviewing',
}); });
const { startRecording, stopRecording, cancelRecording } = useRecorder({ const { startRecording, stopRecording, cancelRecording } = useRecorder({
@@ -184,7 +206,7 @@ export function ComposeOverlay({
onStreamReady: (stream) => setMediaStream(stream), onStreamReady: (stream) => setMediaStream(stream),
onStreamCleanup: () => setMediaStream(null), onStreamCleanup: () => setMediaStream(null),
onFinish: (blob, durationMs, mimeType) => { onFinish: (blob, durationMs, mimeType) => {
setStepSync("reviewing"); setStepSync('reviewing');
setReviewBlob(blob); setReviewBlob(blob);
setReviewDurationMs(durationMs); setReviewDurationMs(durationMs);
setReviewMimeType(mimeType); setReviewMimeType(mimeType);
@@ -199,7 +221,7 @@ export function ComposeOverlay({
} = useScreenRecorder({ } = useScreenRecorder({
micDeviceId, micDeviceId,
onFinish: (blob, durationMs, mimeType) => { onFinish: (blob, durationMs, mimeType) => {
setStepSync("reviewing"); setStepSync('reviewing');
setReviewBlob(blob); setReviewBlob(blob);
setReviewDurationMs(durationMs); setReviewDurationMs(durationMs);
setReviewMimeType(mimeType); setReviewMimeType(mimeType);
@@ -214,7 +236,7 @@ export function ComposeOverlay({
const uploadMedia = useCallback( const uploadMedia = useCallback(
async (blob: Blob, mimeType: string) => { async (blob: Blob, mimeType: string) => {
const ext = "webm"; const ext = 'webm';
const fileName = `recording-${Date.now()}.${ext}`; const fileName = `recording-${Date.now()}.${ext}`;
const { object_id, upload_url, upload_headers } = const { object_id, upload_url, upload_headers } =
@@ -226,7 +248,7 @@ export function ComposeOverlay({
}); });
await fetch(upload_url, { await fetch(upload_url, {
method: "PUT", method: 'PUT',
headers: upload_headers, headers: upload_headers,
body: blob, body: blob,
}); });
@@ -244,12 +266,12 @@ export function ComposeOverlay({
await apiClient.prepareUpload({ await apiClient.prepareUpload({
network_id: networkId, network_id: networkId,
name: file.name, name: file.name,
content_type: file.type || "application/octet-stream", content_type: file.type || 'application/octet-stream',
content_length: file.size, content_length: file.size,
}); });
await fetch(upload_url, { await fetch(upload_url, {
method: "PUT", method: 'PUT',
headers: upload_headers, headers: upload_headers,
body: file, body: file,
}); });
@@ -272,7 +294,9 @@ export function ComposeOverlay({
attachments.map(async (attachment) => { attachments.map(async (attachment) => {
setAttachments((prev) => setAttachments((prev) =>
prev.map((a) => prev.map((a) =>
a.id === attachment.id ? { ...a, status: "uploading" as const } : a, a.id === attachment.id
? { ...a, status: 'uploading' as const }
: a,
), ),
); );
@@ -280,11 +304,11 @@ export function ComposeOverlay({
await createParticle.mutateAsync({ await createParticle.mutateAsync({
path: childrenPath, path: childrenPath,
type: "file", type: 'file',
properties: { properties: {
object_id, object_id,
filename: attachment.file.name, 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, size_bytes: attachment.file.size,
}, },
createdByHumanId: userId, createdByHumanId: userId,
@@ -292,9 +316,11 @@ export function ComposeOverlay({
}), }),
); );
const failed = results.filter((r) => r.status === "rejected"); const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) { 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], [attachments, userId, uploadFile, createParticle],
@@ -308,7 +334,7 @@ export function ComposeOverlay({
if (textContent.trim()) { if (textContent.trim()) {
particleId = await createParticle.mutateAsync({ particleId = await createParticle.mutateAsync({
path, path,
type: "text", type: 'text',
properties: { content: textContent }, properties: { content: textContent },
createdByHumanId: userId, createdByHumanId: userId,
}); });
@@ -318,17 +344,20 @@ export function ComposeOverlay({
reviewMimeType, reviewMimeType,
); );
const isAudioOnly = reviewMimeType.startsWith("audio/"); const isAudioOnly = reviewMimeType.startsWith('audio/');
particleId = await createParticle.mutateAsync({ particleId = await createParticle.mutateAsync({
path, path,
type: "media", type: 'media',
properties: { properties: {
object_id, object_id,
mime_type: reviewMimeType, mime_type: reviewMimeType,
duration_ms: reviewDurationMs, duration_ms: reviewDurationMs,
size_bytes, size_bytes,
...(!isAudioOnly && { ...(!isAudioOnly && {
source: recordingSource === "screen" ? "screen" as const : "camera" as const, source:
recordingSource === 'screen'
? ('screen' as const)
: ('camera' as const),
}), }),
}, },
createdByHumanId: userId, createdByHumanId: userId,
@@ -350,36 +379,48 @@ export function ComposeOverlay({
createParticle, createParticle,
uploadMedia, uploadMedia,
uploadAttachments, uploadAttachments,
onParticleCreated onParticleCreated,
], ],
); );
const handleQuotaError = useCallback((err: unknown): boolean => { const handleQuotaError = useCallback(
if (err instanceof QuotaExceededError) { (err: unknown): boolean => {
toast.error("Daily message limit reached. Upgrade to Pro to keep sending."); if (err instanceof QuotaExceededError) {
cancel(); toast.error(
return true; 'Daily message limit reached. Upgrade to Pro to keep sending.',
} );
return false; cancel();
}, [cancel]); return true;
}
return false;
},
[cancel],
);
// Reply mode: create particle directly under targetPath. // Reply mode: create particle directly under targetPath.
const onSubmitReply = useCallback(async () => { const onSubmitReply = useCallback(async () => {
if (!targetPath || !userId || stepRef.current === "submitting") return; if (!targetPath || !userId || stepRef.current === 'submitting') return;
setStepSync("submitting"); setStepSync('submitting');
try { try {
await createChildParticle(targetPath); await createChildParticle(targetPath);
cancel(); cancel();
} catch (err) { } catch (err) {
if (!handleQuotaError(err)) throw err; if (!handleQuotaError(err)) throw err;
} }
}, [targetPath, userId, setStepSync, createChildParticle, cancel, handleQuotaError]); }, [
targetPath,
userId,
setStepSync,
createChildParticle,
cancel,
handleQuotaError,
]);
// New stream mode: create stream + first child // New stream mode: create stream + first child
const handleStreamSubmit = useCallback( const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => { async (streamName: string, visibleTo: string[]) => {
if (!userId || stepRef.current === "submitting") return; if (!userId || stepRef.current === 'submitting') return;
setStepSync("submitting"); setStepSync('submitting');
try { try {
const streamId = await createStream.mutateAsync({ const streamId = await createStream.mutateAsync({
@@ -399,7 +440,15 @@ export function ComposeOverlay({
if (!handleQuotaError(err)) throw err; if (!handleQuotaError(err)) throw err;
} }
}, },
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError, setStepSync], [
networkId,
userId,
createStream,
createChildParticle,
cancel,
handleQuotaError,
setStepSync,
],
); );
// --- Compose intent handlers --- // --- Compose intent handlers ---
@@ -408,13 +457,15 @@ export function ComposeOverlay({
// guards (disabled, quota) and screen-vs-media branching live in one place. // guards (disabled, quota) and screen-vs-media branching live in one place.
const guardIdle = useCallback((): boolean => { const guardIdle = useCallback((): boolean => {
if (stepRef.current !== "idle") return false; if (stepRef.current !== 'idle') return false;
if (disabledRef.current) { if (disabledRef.current) {
toast.info("This stream is closed"); toast.info('This stream is closed');
return false; return false;
} }
if (quotaExhaustedRef.current) { 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 false;
} }
return true; return true;
@@ -423,19 +474,19 @@ export function ComposeOverlay({
const handleRecordIntent = useCallback(() => { const handleRecordIntent = useCallback(() => {
if (!guardIdle()) return; if (!guardIdle()) return;
recordStartRef.current = Date.now(); recordStartRef.current = Date.now();
setRecordingSource("media"); setRecordingSource('media');
setStepSync("recording"); setStepSync('recording');
startRecording(); startRecording();
}, [guardIdle, setStepSync, startRecording]); }, [guardIdle, setStepSync, startRecording]);
const handleTextIntent = useCallback(() => { const handleTextIntent = useCallback(() => {
if (!guardIdle()) return; if (!guardIdle()) return;
setStepSync("typing"); setStepSync('typing');
}, [guardIdle, setStepSync]); }, [guardIdle, setStepSync]);
const handleStopIntent = useCallback(() => { const handleStopIntent = useCallback(() => {
if (stepRef.current !== "recording") return; if (stepRef.current !== 'recording') return;
if (recordingSourceRef.current === "screen") { if (recordingSourceRef.current === 'screen') {
stopScreenRecording(); stopScreenRecording();
} else { } else {
stopRecording(); stopRecording();
@@ -444,24 +495,24 @@ export function ComposeOverlay({
const handleCancelIntent = useCallback(() => { const handleCancelIntent = useCallback(() => {
const s = stepRef.current; const s = stepRef.current;
if (s === "recording" || s === "reviewing") { if (s === 'recording' || s === 'reviewing') {
if (recordingSourceRef.current === "screen") { if (recordingSourceRef.current === 'screen') {
cancelScreenRecording(); cancelScreenRecording();
} else { } else {
cancelRecording(); cancelRecording();
} }
cancel(); cancel();
} else if (s === "typing" || s === "configuring" || s === "picking") { } else if (s === 'typing' || s === 'configuring' || s === 'picking') {
cancel(); cancel();
} }
}, [cancel, cancelRecording, cancelScreenRecording]); }, [cancel, cancelRecording, cancelScreenRecording]);
const handleSendIntent = useCallback(() => { const handleSendIntent = useCallback(() => {
if (stepRef.current !== "reviewing") return; if (stepRef.current !== 'reviewing') return;
if (targetPath) { if (targetPath) {
onSubmitReply(); onSubmitReply();
} else { } else {
setStepSync("configuring"); setStepSync('configuring');
} }
}, [targetPath, onSubmitReply, setStepSync]); }, [targetPath, onSubmitReply, setStepSync]);
@@ -480,15 +531,32 @@ export function ComposeOverlay({
const intent = state.intent; const intent = state.intent;
if (!intent || intent === prev.intent) return; if (!intent || intent === prev.intent) return;
switch (intent.kind) { switch (intent.kind) {
case "record": handleRecordIntent(); break; case 'record':
case "text": handleTextIntent(); break; handleRecordIntent();
case "stop": handleStopIntent(); break; break;
case "cancel": handleCancelIntent(); break; case 'text':
case "send": handleSendIntent(); break; handleTextIntent();
break;
case 'stop':
handleStopIntent();
break;
case 'cancel':
handleCancelIntent();
break;
case 'send':
handleSendIntent();
break;
} }
clearIntent(); clearIntent();
}); });
}, [handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]); }, [
handleRecordIntent,
handleTextIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
clearIntent,
]);
// --- Keyboard handling --- // --- Keyboard handling ---
@@ -496,8 +564,12 @@ export function ComposeOverlay({
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current; const currentStep = stepRef.current;
if (currentStep === "typing" || currentStep === "configuring" || currentStep === "picking") { if (
if (e.key === "Escape") { currentStep === 'typing' ||
currentStep === 'configuring' ||
currentStep === 'picking'
) {
if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
cancel(); cancel();
} }
@@ -506,52 +578,55 @@ export function ComposeOverlay({
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( if (
target.tagName === "INPUT" || target.tagName === 'INPUT' ||
target.tagName === "TEXTAREA" || target.tagName === 'TEXTAREA' ||
target.isContentEditable target.isContentEditable
) { ) {
return; return;
} }
switch (currentStep) { switch (currentStep) {
case "idle": { case 'idle': {
if (e.key === "`" && !e.repeat) { if (e.key === '`' && !e.repeat) {
e.preventDefault(); e.preventDefault();
handleRecordIntent(); handleRecordIntent();
} else if (e.key === "s" || e.key === "S") { } else if (e.key === 's' || e.key === 'S') {
e.preventDefault(); e.preventDefault();
if (!guardIdle()) break; if (!guardIdle()) break;
if (!requireDesktop("Screen recording")) break; if (!requireDesktop('Screen recording')) break;
setRecordingSource("screen"); setRecordingSource('screen');
setStepSync("picking"); setStepSync('picking');
} else if (e.key === "t" || e.key === "T") { } else if (e.key === 't' || e.key === 'T') {
e.preventDefault(); e.preventDefault();
handleTextIntent(); handleTextIntent();
} }
break; break;
} }
case "recording": { case 'recording': {
if (e.key === "`" && !e.repeat) { if (e.key === '`' && !e.repeat) {
// Second tap stops media recording (toggle mode) // Second tap stops media recording (toggle mode)
e.preventDefault(); e.preventDefault();
handleStopIntent(); 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 // S stops screen recording when main window is focused
e.preventDefault(); e.preventDefault();
handleStopIntent(); 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(); e.preventDefault();
handleCancelIntent(); handleCancelIntent();
} }
break; break;
} }
case "reviewing": { case 'reviewing': {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") { if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
handleCancelIntent(); handleCancelIntent();
} else if (e.key === "Enter") { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
handleSendIntent(); handleSendIntent();
} }
@@ -561,30 +636,46 @@ export function ComposeOverlay({
}; };
const handleKeyUp = (e: KeyboardEvent) => { const handleKeyUp = (e: KeyboardEvent) => {
if (stepRef.current === "recording" && e.key === "`" && recordingSourceRef.current === "media") { if (
stepRef.current === 'recording' &&
e.key === '`' &&
recordingSourceRef.current === 'media'
) {
e.preventDefault(); e.preventDefault();
// Only stop on release if held long enough (hold-to-record mode). // Only stop on release if held long enough (hold-to-record mode).
// Quick taps are handled by the second keydown (toggle 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(); handleStopIntent();
recordStartRef.current = 0; recordStartRef.current = 0;
} }
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown);
window.addEventListener("keyup", handleKeyUp); window.addEventListener('keyup', handleKeyUp);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener('keyup', handleKeyUp);
}; };
}, [cancel, setStepSync, guardIdle, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent]); }, [
cancel,
setStepSync,
guardIdle,
handleRecordIntent,
handleTextIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
]);
// --- Screen source selection handler --- // --- Screen source selection handler ---
const handleScreenSourceSelected = useCallback( const handleScreenSourceSelected = useCallback(
(sourceId: string) => { (sourceId: string) => {
setStepSync("recording"); setStepSync('recording');
startScreenRecording(sourceId); startScreenRecording(sourceId);
}, },
[setStepSync, startScreenRecording], [setStepSync, startScreenRecording],
@@ -592,15 +683,15 @@ export function ComposeOverlay({
// --- Render --- // --- Render ---
if (step === "idle") return null; if (step === 'idle') return null;
const handleTextAdvance = targetPath const handleTextAdvance = targetPath
? onSubmitReply ? onSubmitReply
: () => setStepSync("configuring"); : () => setStepSync('configuring');
return ( return (
<> <>
{step === "picking" && ( {step === 'picking' && (
<ScreenSourcePicker <ScreenSourcePicker
title="Record your screen" title="Record your screen"
confirmLabel="Record" confirmLabel="Record"
@@ -609,24 +700,25 @@ export function ComposeOverlay({
onCancel={cancel} onCancel={cancel}
/> />
)} )}
{(step === "recording" || step === "reviewing") && recordingSource === "media" && ( {(step === 'recording' || step === 'reviewing') &&
<RecordingOverlay recordingSource === 'media' && (
step={step} <RecordingOverlay
mediaStream={mediaStream} step={step}
recordingMode={recordingMode} mediaStream={mediaStream}
reviewBlob={reviewBlob} recordingMode={recordingMode}
error={error} reviewBlob={reviewBlob}
onClose={cancel} error={error}
attachments={attachments} onClose={cancel}
onRemoveAttachment={removeAttachment} attachments={attachments}
onAddFiles={openFilePicker} onRemoveAttachment={removeAttachment}
isDragging={isDragging} onAddFiles={openFilePicker}
dropZoneProps={dropZoneProps} isDragging={isDragging}
mirror={true} dropZoneProps={dropZoneProps}
objectFit="cover" mirror={true}
/> objectFit="cover"
)} />
{step === "recording" && recordingSource === "screen" && ( )}
{step === 'recording' && recordingSource === 'screen' && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90"> <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="absolute top-8 z-10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -643,7 +735,7 @@ export function ComposeOverlay({
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
S S
</kbd>{" "} </kbd>{' '}
stop stop
</button> </button>
<button <button
@@ -654,13 +746,13 @@ export function ComposeOverlay({
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q Q
</kbd>{" "} </kbd>{' '}
cancel cancel
</button> </button>
</div> </div>
</div> </div>
)} )}
{step === "reviewing" && recordingSource === "screen" && reviewBlob && ( {step === 'reviewing' && recordingSource === 'screen' && reviewBlob && (
<RecordingOverlay <RecordingOverlay
step="reviewing" step="reviewing"
mediaStream={null} mediaStream={null}
@@ -677,7 +769,7 @@ export function ComposeOverlay({
objectFit="contain" objectFit="contain"
/> />
)} )}
{step === "typing" && ( {step === 'typing' && (
<TextComposeStep <TextComposeStep
textContent={textContent} textContent={textContent}
onTextChange={setTextContent} onTextChange={setTextContent}
@@ -690,16 +782,18 @@ export function ComposeOverlay({
dropZoneProps={dropZoneProps} dropZoneProps={dropZoneProps}
/> />
)} )}
{!targetPath && step === "configuring" && ( {!targetPath && step === 'configuring' && (
<ConfigureStreamStep <ConfigureStreamStep
networkId={networkId} networkId={networkId}
onCancel={cancel} onCancel={cancel}
onSubmit={handleStreamSubmit} onSubmit={handleStreamSubmit}
/> />
)} )}
{step === "submitting" && ( {step === 'submitting' && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90"> <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> </div>
)} )}
</> </>
@@ -1,8 +1,8 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { Progress } from "@/components/ui/progress"; import { Progress } from '@/components/ui/progress';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { useNetworkUsage } from "@/hooks/use-network-usage"; import { useNetworkUsage } from '@/hooks/use-network-usage';
import { useIsNetworkAdmin, useNetwork } from "@/hooks/use-networks"; import { useIsNetworkAdmin, useNetwork } from '@/hooks/use-networks';
interface ComposeQuotaIndicatorProps { interface ComposeQuotaIndicatorProps {
networkId: string; networkId: string;
@@ -18,7 +18,9 @@ const SHOW_PROGRESS_AT_FRACTION = 0.7;
* *
* Pro networks and any network still loading usage render nothing. * Pro networks and any network still loading usage render nothing.
*/ */
export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) { export function ComposeQuotaIndicator({
networkId,
}: ComposeQuotaIndicatorProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: usage } = useNetworkUsage(networkId); const { data: usage } = useNetworkUsage(networkId);
const isAdmin = useIsNetworkAdmin(networkId); const isAdmin = useIsNetworkAdmin(networkId);
@@ -38,7 +40,8 @@ export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps)
: `This network reached today's ${usage.limit}-message limit`} : `This network reached today's ${usage.limit}-message limit`}
</div> </div>
<div className="text-xs text-muted-foreground"> <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> </div>
{isAdmin ? ( {isAdmin ? (
<Button <Button
@@ -49,10 +52,10 @@ export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps)
</Button> </Button>
) : ( ) : (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Ask{" "} Ask{' '}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{network?.admin_human.email_prefix ?? "your admin"} {network?.admin_human.email_prefix ?? 'your admin'}
</span>{" "} </span>{' '}
to upgrade to Pro to upgrade to Pro
</div> </div>
)} )}
@@ -79,8 +82,8 @@ function formatResetRelative(resetAt: Date): string {
const now = new Date(); const now = new Date();
const diffMs = resetAt.getTime() - now.getTime(); const diffMs = resetAt.getTime() - now.getTime();
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000))); const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
if (hours < 1) return "soon"; if (hours < 1) return 'soon';
if (hours === 1) return "in 1 hour"; if (hours === 1) return 'in 1 hour';
return `in ${hours} hours`; 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, // 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. // so a user in UTC-8 sees "4:00 PM" instead of a relative hint alone.
return resetAt.toLocaleTimeString(undefined, { return resetAt.toLocaleTimeString(undefined, {
hour: "numeric", hour: 'numeric',
minute: "2-digit", minute: '2-digit',
}); });
} }
@@ -1,13 +1,13 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from 'react';
import { useNetworks } from "@/hooks/use-networks"; import { useNetworks } from '@/hooks/use-networks';
import { cn, removeDuplicates } from "@/lib/utils"; import { cn, removeDuplicates } from '@/lib/utils';
import { metaKey } from "@/lib/platform"; import { metaKey } from '@/lib/platform';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { generateRandomName } from "@/lib/random-name"; import { generateRandomName } from '@/lib/random-name';
import { Input } from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { Label } from "@/components/ui/label"; import { Label } from '@/components/ui/label';
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from '@/components/ui/scroll-area';
interface ConfigureStreamStepProps { interface ConfigureStreamStepProps {
networkId: string | null; networkId: string | null;
@@ -42,7 +42,9 @@ export function ConfigureStreamStep({
const buildVisibleTo = useCallback((): string[] => { const buildVisibleTo = useCallback((): string[] => {
if (everyone && networkId) return [`network:${networkId}`]; 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]); }, [everyone, networkId, selectedIds, userId]);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
@@ -53,12 +55,12 @@ export function ConfigureStreamStep({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
switch (e.key) { switch (e.key) {
case "Escape": case 'Escape':
e.preventDefault(); e.preventDefault();
onCancel(); onCancel();
return; return;
case "Enter": case 'Enter':
if (e.metaKey || e.ctrlKey) { if (e.metaKey || e.ctrlKey) {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
@@ -100,8 +102,8 @@ export function ConfigureStreamStep({
role="button" role="button"
onClick={() => setEveryone((prev) => !prev)} onClick={() => setEveryone((prev) => !prev)}
className={cn( className={cn(
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors", '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", 'text-white/70 hover:bg-white/5',
)} )}
> >
<Checkbox <Checkbox
@@ -129,8 +131,8 @@ export function ConfigureStreamStep({
role="button" role="button"
onClick={() => toggleMember(member.id)} onClick={() => toggleMember(member.id)}
className={cn( className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors", '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", 'text-white/70 hover:bg-white/5',
)} )}
> >
<Checkbox <Checkbox
@@ -159,16 +161,16 @@ export function ConfigureStreamStep({
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" "} </kbd>{' '}
cancel cancel
</span> </span>
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter {metaKey}+Enter
</kbd>{" "} </kbd>{' '}
create create
</span> </span>
</div> </div>
</div > </div>
); );
} }
@@ -36,8 +36,9 @@
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */ /* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
--crepe-font-default: inherit; --crepe-font-default: inherit;
--crepe-font-title: inherit; --crepe-font-title: inherit;
--crepe-font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, --crepe-font-code:
"Liberation Mono", monospace; ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono',
monospace;
} }
/* Glassy floating menus over content. */ /* Glassy floating menus over content. */
@@ -1,9 +1,9 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react';
import { Crepe } from "@milkdown/crepe"; import { Crepe } from '@milkdown/crepe';
import "@milkdown/crepe/theme/common/style.css"; import '@milkdown/crepe/theme/common/style.css';
import "@milkdown/crepe/theme/frame-dark.css"; import '@milkdown/crepe/theme/frame-dark.css';
import "./markdown-editor.css"; import './markdown-editor.css';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
interface MarkdownEditorProps { interface MarkdownEditorProps {
/** Initial markdown. The editor owns its content after mount; edits flow out /** Initial markdown. The editor owns its content after mount; edits flow out
@@ -61,7 +61,7 @@ export function MarkdownEditor({
[Crepe.Feature.AI]: false, [Crepe.Feature.AI]: false,
}, },
featureConfigs: { featureConfigs: {
[Crepe.Feature.Placeholder]: { text: placeholder ?? "" }, [Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
}, },
}); });
@@ -82,7 +82,7 @@ export function MarkdownEditor({
} }
created = crepe; created = crepe;
if (autoFocus && !readOnly) { if (autoFocus && !readOnly) {
root.querySelector<HTMLElement>(".ProseMirror")?.focus(); root.querySelector<HTMLElement>('.ProseMirror')?.focus();
} }
}); });
@@ -97,7 +97,7 @@ export function MarkdownEditor({
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className={cn("llink-crepe", !readOnly && "llink-crepe--fill", className)} className={cn('llink-crepe', !readOnly && 'llink-crepe--fill', className)}
/> />
); );
} }
@@ -1,17 +1,17 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { Paperclip } from "lucide-react"; import { Paperclip } from 'lucide-react';
import type { RecordingMode } from "@/hooks/use-recording-mode"; import type { RecordingMode } from '@/hooks/use-recording-mode';
import { AudioLevelBars } from "@/components/audio/audio-level-bars"; import { AudioLevelBars } from '@/components/audio/audio-level-bars';
import { useAudioSource } from "@/components/audio/use-audio-source"; import { useAudioSource } from '@/components/audio/use-audio-source';
import { useObjectUrl } from "@/hooks/use-object-url"; import { useObjectUrl } from '@/hooks/use-object-url';
import { AttachmentStrip } from "@/features/compose/attachment-strip"; import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from "@/features/compose/attachment-strip"; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { useComposeIntentStore } from "@/stores/compose-intent-store"; import { useComposeIntentStore } from '@/stores/compose-intent-store';
interface RecordingOverlayProps { interface RecordingOverlayProps {
step: "recording" | "reviewing"; step: 'recording' | 'reviewing';
mediaStream: MediaStream | null; mediaStream: MediaStream | null;
recordingMode: RecordingMode; recordingMode: RecordingMode;
reviewBlob: Blob | null; reviewBlob: Blob | null;
@@ -30,7 +30,7 @@ interface RecordingOverlayProps {
/** Mirror the video horizontally. Defaults to true (selfie-view for webcam). */ /** Mirror the video horizontally. Defaults to true (selfie-view for webcam). */
mirror?: boolean; mirror?: boolean;
/** How video fills its container. Defaults to "cover". Use "contain" for screen recordings. */ /** How video fills its container. Defaults to "cover". Use "contain" for screen recordings. */
objectFit?: "cover" | "contain"; objectFit?: 'cover' | 'contain';
} }
function RecordingTimer() { function RecordingTimer() {
@@ -45,7 +45,7 @@ function RecordingTimer() {
const minutes = Math.floor(elapsed / 60); const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60; const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, "0")}`; const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -59,12 +59,12 @@ function ReviewPlayback({
blob, blob,
isVideo, isVideo,
mirror = true, mirror = true,
objectFit = "cover", objectFit = 'cover',
}: { }: {
blob: Blob; blob: Blob;
isVideo: boolean; isVideo: boolean;
mirror?: boolean; mirror?: boolean;
objectFit?: "cover" | "contain"; objectFit?: 'cover' | 'contain';
}) { }) {
const objectUrl = useObjectUrl(blob); const objectUrl = useObjectUrl(blob);
const audioElRef = useRef<HTMLAudioElement | null>(null); const audioElRef = useRef<HTMLAudioElement | null>(null);
@@ -80,7 +80,7 @@ function ReviewPlayback({
autoPlay autoPlay
loop loop
playsInline 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' : ''}`}
/> />
); );
} }
@@ -118,14 +118,14 @@ export function RecordingOverlay({
isDragging, isDragging,
dropZoneProps, dropZoneProps,
mirror = true, mirror = true,
objectFit = "cover", objectFit = 'cover',
}: RecordingOverlayProps) { }: RecordingOverlayProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const recordingAudioSource = useAudioSource(mediaStream ?? null); const recordingAudioSource = useAudioSource(mediaStream ?? null);
// Set video srcObject for live preview // Set video srcObject for live preview
useEffect(() => { useEffect(() => {
if (videoRef.current && mediaStream && recordingMode === "video") { if (videoRef.current && mediaStream && recordingMode === 'video') {
videoRef.current.srcObject = mediaStream; videoRef.current.srcObject = mediaStream;
} }
}, [mediaStream, recordingMode]); }, [mediaStream, recordingMode]);
@@ -137,16 +137,16 @@ export function RecordingOverlay({
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [error, onClose]); }, [error, onClose]);
const isReviewing = step === "reviewing"; const isReviewing = step === 'reviewing';
const isRecording = step === "recording"; const isRecording = step === 'recording';
const isLoading = isRecording && !mediaStream; const isLoading = isRecording && !mediaStream;
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
return ( return (
<div <div
className={cn( className={cn(
"absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90", '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 && isDragging && 'ring-2 ring-inset ring-white/30',
)} )}
{...(isReviewing ? dropZoneProps : {})} {...(isReviewing ? dropZoneProps : {})}
> >
@@ -154,15 +154,15 @@ export function RecordingOverlay({
{isLoading && ( {isLoading && (
<div className="z-10 flex flex-col items-center gap-2"> <div className="z-10 flex flex-col items-center gap-2">
<span className="animate-pulse text-sm text-white/60"> <span className="animate-pulse text-sm text-white/60">
{recordingMode === "video" {recordingMode === 'video'
? "Starting camera..." ? 'Starting camera...'
: "Starting mic..."} : 'Starting mic...'}
</span> </span>
</div> </div>
)} )}
{/* Camera preview (video mode, recording) */} {/* Camera preview (video mode, recording) */}
{isRecording && recordingMode === "video" && mediaStream && ( {isRecording && recordingMode === 'video' && mediaStream && (
<video <video
ref={videoRef} ref={videoRef}
muted muted
@@ -176,7 +176,7 @@ export function RecordingOverlay({
{isReviewing && reviewBlob && ( {isReviewing && reviewBlob && (
<ReviewPlayback <ReviewPlayback
blob={reviewBlob} blob={reviewBlob}
isVideo={recordingMode === "video"} isVideo={recordingMode === 'video'}
mirror={mirror} mirror={mirror}
objectFit={objectFit} objectFit={objectFit}
/> />
@@ -210,28 +210,29 @@ export function RecordingOverlay({
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50"> <div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button <button
type="button" type="button"
onClick={() => requestIntent("stop")} onClick={() => requestIntent('stop')}
className="cursor-pointer rounded transition-colors hover:text-white/80" className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Finish recording (or release `)" title="Finish recording (or release `)"
> >
Release{" "} Release{' '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
` `
</kbd>{" "} </kbd>{' '}
to review to review
</button> </button>
<button <button
type="button" type="button"
onClick={() => requestIntent("cancel")} onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80" className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard recording (or press Esc / Q)" title="Discard recording (or press Esc / Q)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" or "} </kbd>
{' or '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q Q
</kbd>{" "} </kbd>{' '}
to cancel to cancel
</button> </button>
</div> </div>
@@ -251,27 +252,28 @@ export function RecordingOverlay({
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="flex items-center gap-4 text-sm text-white/50">
<button <button
type="button" type="button"
onClick={() => requestIntent("send")} onClick={() => requestIntent('send')}
className="cursor-pointer rounded transition-colors hover:text-white/80" className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Send (or press Enter)" title="Send (or press Enter)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter Enter
</kbd>{" "} </kbd>{' '}
next next
</button> </button>
<button <button
type="button" type="button"
onClick={() => requestIntent("cancel")} onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80" className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard (or press Esc / Q)" title="Discard (or press Esc / Q)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" or "} </kbd>
{' or '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q Q
</kbd>{" "} </kbd>{' '}
to cancel to cancel
</button> </button>
<span> <span>
@@ -288,17 +290,12 @@ export function RecordingOverlay({
attach attach
</Button> </Button>
</span> </span>
</div> </div>
</div> </div>
)} )}
{/* Error state */} {/* Error state */}
{error && ( {error && <div className="z-10 text-sm text-red-400">{error}</div>}
<div className="z-10 text-sm text-red-400">
{error}
</div>
)}
</div> </div>
); );
} }
@@ -1,5 +1,5 @@
import { TextEditor } from "@/features/compose/text-editor"; import { TextEditor } from '@/features/compose/text-editor';
import type { PendingAttachment } from "@/features/compose/attachment-strip"; import type { PendingAttachment } from '@/features/compose/attachment-strip';
interface TextComposeStepProps { interface TextComposeStepProps {
textContent: string; textContent: string;
+27 -25
View File
@@ -1,12 +1,12 @@
import { useEffect, useRef, useCallback, useState } from "react"; import { useEffect, useRef, useCallback, useState } from 'react';
import { Paperclip } from "lucide-react"; import { Paperclip } from 'lucide-react';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { metaKey } from "@/lib/platform"; import { metaKey } from '@/lib/platform';
import { useAllLinkMetadata } from "@/hooks/use-link-metadata"; import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
import { AttachmentStrip } from "@/features/compose/attachment-strip"; import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from "@/features/compose/attachment-strip"; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { MarkdownEditor } from "@/features/compose/markdown-editor"; import { MarkdownEditor } from '@/features/compose/markdown-editor';
export interface TextEditorAttachmentProps { export interface TextEditorAttachmentProps {
attachments: PendingAttachment[]; attachments: PendingAttachment[];
@@ -35,9 +35,9 @@ interface TextEditorProps {
const IMMERSIVE_CHAR_LIMIT = 120; const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveTextStyle(length: number) { function getImmersiveTextStyle(length: number) {
if (length < 70) return { size: "text-5xl", weight: "font-semibold" }; if (length < 70) return { size: 'text-5xl', weight: 'font-semibold' };
if (length < 130) return { size: "text-3xl", weight: "font-semibold" }; if (length < 130) return { size: 'text-3xl', weight: 'font-semibold' };
return { size: "text-2xl", weight: "font-normal" }; return { size: 'text-2xl', weight: 'font-normal' };
} }
export function TextEditor({ export function TextEditor({
@@ -45,7 +45,7 @@ export function TextEditor({
onTextChange, onTextChange,
onSubmit, onSubmit,
onCancel, onCancel,
submitHint = "next", submitHint = 'next',
attachmentProps, attachmentProps,
}: TextEditorProps) { }: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -61,7 +61,9 @@ export function TextEditor({
const attachmentCount = attachmentProps?.attachments.length ?? 0; const attachmentCount = attachmentProps?.attachments.length ?? 0;
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0; const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
const immersive = 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 // Keep the immersive textarea focused with the caret at the end when we
// (re)enter it. The card-mode editor manages its own focus. // (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). // editor's own key handling (e.g. ⌘+Enter must submit, not insert a break).
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onCancel(); onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (textContent.trim()) onSubmit(); 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
setForceCardMode(true); setForceCardMode(true);
@@ -112,20 +114,20 @@ export function TextEditor({
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" "} </kbd>{' '}
cancel cancel
</span> </span>
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter {metaKey}+Enter
</kbd>{" "} </kbd>{' '}
{submitHint} {submitHint}
</span> </span>
{immersive && ( {immersive && (
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+M {metaKey}+M
</kbd>{" "} </kbd>{' '}
markdown markdown
</span> </span>
)} )}
@@ -156,8 +158,8 @@ export function TextEditor({
return ( return (
<div <div
className={cn( className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-black/90", 'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
isDragging && "ring-2 ring-inset ring-white/30", isDragging && 'ring-2 ring-inset ring-white/30',
)} )}
{...dropZoneProps} {...dropZoneProps}
> >
@@ -169,7 +171,7 @@ export function TextEditor({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Type a message..." placeholder="Type a message..."
className={cn( 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.size,
style.weight, style.weight,
)} )}
@@ -184,8 +186,8 @@ export function TextEditor({
return ( return (
<div <div
className={cn( className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-black/90", 'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
isDragging && "ring-2 ring-inset ring-white/30", isDragging && 'ring-2 ring-inset ring-white/30',
)} )}
{...dropZoneProps} {...dropZoneProps}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
+15 -15
View File
@@ -1,13 +1,13 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from 'react';
import type { RecordingMode } from "@/hooks/use-recording-mode"; import type { RecordingMode } from '@/hooks/use-recording-mode';
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus"; const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
const VIDEO_FALLBACK_MIME = "video/webm"; const VIDEO_FALLBACK_MIME = 'video/webm';
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus"; const AUDIO_PREFERRED_MIME = 'audio/webm;codecs=opus';
const AUDIO_FALLBACK_MIME = "audio/webm"; const AUDIO_FALLBACK_MIME = 'audio/webm';
function getMediaMime(mode: "video" | "audio"): string { function getMediaMime(mode: 'video' | 'audio'): string {
if (mode === "audio") { if (mode === 'audio') {
return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME) return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
? AUDIO_PREFERRED_MIME ? AUDIO_PREFERRED_MIME
: AUDIO_FALLBACK_MIME; : AUDIO_FALLBACK_MIME;
@@ -36,7 +36,7 @@ function buildConstraints(
? { deviceId: { exact: micDeviceId } } ? { deviceId: { exact: micDeviceId } }
: true; : true;
if (mode === "audio") return { audio }; if (mode === 'audio') return { audio };
const video: MediaTrackConstraints = cameraDeviceId const video: MediaTrackConstraints = cameraDeviceId
? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } } ? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } }
@@ -57,13 +57,13 @@ async function getStreamWithFallback(
if ( if (
hasDeviceId && hasDeviceId &&
err instanceof Error && err instanceof Error &&
(err.name === "OverconstrainedError" || err.name === "NotFoundError") (err.name === 'OverconstrainedError' || err.name === 'NotFoundError')
) { ) {
const relaxed: MediaStreamConstraints = { const relaxed: MediaStreamConstraints = {
audio: typeof constraints.audio === "object" ? true : constraints.audio, audio: typeof constraints.audio === 'object' ? true : constraints.audio,
...(constraints.video !== undefined && { ...(constraints.video !== undefined && {
video: video:
typeof constraints.video === "object" typeof constraints.video === 'object'
? { aspectRatio: { ideal: 4 / 3 } } ? { aspectRatio: { ideal: 4 / 3 } }
: constraints.video, : constraints.video,
}), }),
@@ -143,13 +143,13 @@ export function useRecorder({
} catch (err) { } catch (err) {
stopTracks(); stopTracks();
onErrorRef.current( 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]); }, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]);
const stopRecording = useCallback(() => { const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") { if (recorderRef.current?.state === 'recording') {
recorderRef.current.stop(); recorderRef.current.stop();
} }
}, []); }, []);
@@ -158,7 +158,7 @@ export function useRecorder({
if (recorderRef.current) { if (recorderRef.current) {
recorderRef.current.ondataavailable = null; recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null; recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") { if (recorderRef.current.state === 'recording') {
recorderRef.current.stop(); recorderRef.current.stop();
} }
} }
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from 'react';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
import { requireDesktop } from "@/lib/platform/desktop-only"; import { requireDesktop } from '@/lib/platform/desktop-only';
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus"; const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
const VIDEO_FALLBACK_MIME = "video/webm"; const VIDEO_FALLBACK_MIME = 'video/webm';
function getScreenMime(): string { function getScreenMime(): string {
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME) return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
@@ -33,7 +33,7 @@ async function getMicStream(
if ( if (
micDeviceId && micDeviceId &&
err instanceof Error && err instanceof Error &&
(err.name === "OverconstrainedError" || err.name === "NotFoundError") (err.name === 'OverconstrainedError' || err.name === 'NotFoundError')
) { ) {
return navigator.mediaDevices.getUserMedia({ audio: true }); return navigator.mediaDevices.getUserMedia({ audio: true });
} }
@@ -77,14 +77,14 @@ export function useScreenRecorder({
const startRecording = useCallback( const startRecording = useCallback(
async (sourceId: string) => { async (sourceId: string) => {
if (!requireDesktop("Screen recording")) return; if (!requireDesktop('Screen recording')) return;
try { try {
// 1. Screen video // 1. Screen video
const screenStream = await navigator.mediaDevices.getUserMedia({ const screenStream = await navigator.mediaDevices.getUserMedia({
audio: false, audio: false,
video: { video: {
mandatory: { mandatory: {
chromeMediaSource: "desktop", chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId, chromeMediaSourceId: sourceId,
}, },
} as MediaTrackConstraints, } as MediaTrackConstraints,
@@ -130,7 +130,7 @@ export function useScreenRecorder({
// 5. Listen for stop from floating window // 5. Listen for stop from floating window
cleanupIpcRef.current = platform.screenRecord.onStopRequested(() => { cleanupIpcRef.current = platform.screenRecord.onStopRequested(() => {
if (recorderRef.current?.state === "recording") { if (recorderRef.current?.state === 'recording') {
recorderRef.current.stop(); recorderRef.current.stop();
} }
}); });
@@ -138,7 +138,9 @@ export function useScreenRecorder({
stopAllTracks(); stopAllTracks();
platform.screenRecord.cancel(); platform.screenRecord.cancel();
onErrorRef.current( 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(() => { const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") { if (recorderRef.current?.state === 'recording') {
recorderRef.current.stop(); recorderRef.current.stop();
} }
}, []); }, []);
@@ -155,7 +157,7 @@ export function useScreenRecorder({
if (recorderRef.current) { if (recorderRef.current) {
recorderRef.current.ondataavailable = null; recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null; recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") { if (recorderRef.current.state === 'recording') {
recorderRef.current.stop(); recorderRef.current.stop();
} }
} }
+42 -33
View File
@@ -1,9 +1,9 @@
import { WindowControls } from "@/components/window-controls"; import { WindowControls } from '@/components/window-controls';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from 'react-router-dom';
import { Home, Settings, Users, Volume2, VolumeOff } from "lucide-react"; import { Home, Settings, Users, Volume2, VolumeOff } from 'lucide-react';
import { Switch } from "@/components/ui/switch"; import { Switch } from '@/components/ui/switch';
import { useAutoplayStore } from "@/stores/autoplay-store"; import { useAutoplayStore } from '@/stores/autoplay-store';
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -11,31 +11,31 @@ import {
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from '@/components/ui/breadcrumb';
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useNetworks } from "@/hooks/use-networks"; import { useNetworks } from '@/hooks/use-networks';
import { particlePath } from "@/lib/particle-path"; import { particlePath } from '@/lib/particle-path';
import { useParticle } from "@/hooks/use-particle"; import { useParticle } from '@/hooks/use-particle';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { PropsWithChildren, useCallback } from "react"; import { PropsWithChildren, useCallback } from 'react';
import { useDockBadge } from "@/hooks/use-dock-badge"; import { useDockBadge } from '@/hooks/use-dock-badge';
import { toast } from "sonner"; import { toast } from 'sonner';
import { RouteErrorBoundary } from "@/components/app-error-boundary"; import { RouteErrorBoundary } from '@/components/app-error-boundary';
function getParticleDisplayName(particle: Particle): string { function getParticleDisplayName(particle: Particle): string {
switch (particle.type) { switch (particle.type) {
case "stream": case 'stream':
case "folder": case 'folder':
return particle.properties.name; return particle.properties.name;
case "quest": case 'quest':
return particle.properties.title; return particle.properties.title;
case "paper": case 'paper':
return particle.properties.title; return particle.properties.title;
case "file": case 'file':
return particle.properties.filename; return particle.properties.filename;
case "text": case 'text':
return particle.properties.content.slice(0, 30); return particle.properties.content.slice(0, 30);
case "media": case 'media':
return particle.type; return particle.type;
} }
} }
@@ -65,19 +65,23 @@ function AutoplayToggle() {
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
toggleMuted(); toggleMuted();
if (muted) { if (muted) {
toast.success("Auto-play enabled"); toast.success('Auto-play enabled');
} else { } else {
toast.info("Auto-play disabled"); toast.info('Auto-play disabled');
} }
}, [toggleMuted, muted]); }, [toggleMuted, muted]);
return ( return (
<div className="no-drag flex items-center gap-1.5"> <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 <Switch
checked={!muted} checked={!muted}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
aria-label={muted ? "Unmute autoplay" : "Mute autoplay"} aria-label={muted ? 'Unmute autoplay' : 'Mute autoplay'}
/> />
</div> </div>
); );
@@ -85,10 +89,13 @@ function AutoplayToggle() {
function TopBar() { function TopBar() {
const navigate = useNavigate(); const navigate = useNavigate();
const { networkId, "*": rest } = useParams(); const { networkId, '*': rest } = useParams();
const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean); 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); const { data: particle } = useParticle(path);
@@ -106,7 +113,7 @@ function TopBar() {
) : ( ) : (
<BreadcrumbLink <BreadcrumbLink
className="flex cursor-pointer items-center gap-1" className="flex cursor-pointer items-center gap-1"
onClick={() => navigate("/")} onClick={() => navigate('/')}
> >
<Home className="size-3.5" /> <Home className="size-3.5" />
</BreadcrumbLink> </BreadcrumbLink>
@@ -137,7 +144,9 @@ function TopBar() {
<span key={path} className="contents"> <span key={path} className="contents">
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem className="text-xs"> <BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(particle)}</BreadcrumbPage> <BreadcrumbPage>
{getParticleDisplayName(particle)}
</BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
</span> </span>
)} )}
@@ -163,7 +172,7 @@ function TopBar() {
variant="ghost" variant="ghost"
size="sm" size="sm"
className="no-drag text-muted-foreground" className="no-drag text-muted-foreground"
onClick={() => navigate("/settings")} onClick={() => navigate('/settings')}
> >
<Settings className="size-3.5" /> <Settings className="size-3.5" />
</Button> </Button>
+46 -46
View File
@@ -1,24 +1,24 @@
import { useState } from "react"; import { useState } from 'react';
import { ExternalLink } from "lucide-react"; import { ExternalLink } from 'lucide-react';
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Label } from "@/components/ui/label"; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Separator } from "@/components/ui/separator"; import { Separator } from '@/components/ui/separator';
import { Muted } from "@/components/ui/typography"; import { Muted } from '@/components/ui/typography';
import { CopyableEmail } from "@/components/copyable-email"; import { CopyableEmail } from '@/components/copyable-email';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
import { SUPPORT_EMAIL } from "@/lib/constants"; import { SUPPORT_EMAIL } from '@/lib/constants';
import { import {
useCreateCheckoutSession, useCreateCheckoutSession,
useCreatePortalSession, useCreatePortalSession,
useNetworkBilling, useNetworkBilling,
} from "@/hooks/use-billing"; } from '@/hooks/use-billing';
import { useNetworkUsage } from "@/hooks/use-network-usage"; import { useNetworkUsage } from '@/hooks/use-network-usage';
import { useIsNetworkAdmin } from "@/hooks/use-networks"; import { useIsNetworkAdmin } from '@/hooks/use-networks';
import type { BillingCadence, BillingStatus } from "@/api/types"; import type { BillingCadence, BillingStatus } from '@/api/types';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
function formatCents(cents: number): string { function formatCents(cents: number): string {
if (cents % 100 === 0) return `$${cents / 100}`; if (cents % 100 === 0) return `$${cents / 100}`;
@@ -27,17 +27,17 @@ function formatCents(cents: number): string {
function formatDate(date: Date): string { function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(undefined, {
month: "long", month: 'long',
day: "numeric", day: 'numeric',
year: "numeric", year: 'numeric',
}); });
} }
function PlanStatusBadge({ status }: { status: BillingStatus["plan_status"] }) { function PlanStatusBadge({ status }: { status: BillingStatus['plan_status'] }) {
if (status === "past_due") if (status === 'past_due')
return <Badge variant="destructive">Past due</Badge>; return <Badge variant="destructive">Past due</Badge>;
if (status === "canceled") return <Badge variant="secondary">Canceled</Badge>; if (status === 'canceled') return <Badge variant="secondary">Canceled</Badge>;
if (status === "trialing") return <Badge variant="secondary">Trialing</Badge>; if (status === 'trialing') return <Badge variant="secondary">Trialing</Badge>;
return null; return null;
} }
@@ -76,8 +76,8 @@ function CadenceOption({
<Label <Label
htmlFor={`cadence-${value}`} htmlFor={`cadence-${value}`}
className={cn( className={cn(
"hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors", 'hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors',
selected && "bg-accent/50", selected && 'bg-accent/50',
)} )}
> >
<RadioGroupItem id={`cadence-${value}`} value={value} /> <RadioGroupItem id={`cadence-${value}`} value={value} />
@@ -98,15 +98,15 @@ function CadenceOption({
function formatResetLocal(resetAt: Date): string { function formatResetLocal(resetAt: Date): string {
const time = resetAt.toLocaleTimeString(undefined, { const time = resetAt.toLocaleTimeString(undefined, {
hour: "numeric", hour: 'numeric',
minute: "2-digit", minute: '2-digit',
}); });
const now = new Date(); const now = new Date();
const isSameDay = const isSameDay =
resetAt.getFullYear() === now.getFullYear() && resetAt.getFullYear() === now.getFullYear() &&
resetAt.getMonth() === now.getMonth() && resetAt.getMonth() === now.getMonth() &&
resetAt.getDate() === now.getDate(); 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; if (!usage) return null;
const isPro = usage.plan === "pro"; const isPro = usage.plan === 'pro';
return ( return (
<> <>
@@ -127,9 +127,9 @@ function PlanSummary({ networkId }: { networkId: string }) {
label="Plan" label="Plan"
value={ value={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{isPro ? "Llink Pro" : "Llink Free"}</span> <span>{isPro ? 'Llink Pro' : 'Llink Free'}</span>
<Badge variant={isPro ? "default" : "secondary"}> <Badge variant={isPro ? 'default' : 'secondary'}>
{isPro ? "Pro" : "Free"} {isPro ? 'Pro' : 'Free'}
</Badge> </Badge>
</div> </div>
} }
@@ -168,7 +168,7 @@ function FreeBilling({
billing: BillingStatus; billing: BillingStatus;
}) { }) {
const createCheckout = useCreateCheckoutSession(networkId); const createCheckout = useCreateCheckoutSession(networkId);
const [cadence, setCadence] = useState<BillingCadence>("annual"); const [cadence, setCadence] = useState<BillingCadence>('annual');
const handleUpgrade = () => { const handleUpgrade = () => {
createCheckout.mutate(cadence, { createCheckout.mutate(cadence, {
@@ -194,7 +194,7 @@ function FreeBilling({
perSeatCents={annualPerSeatMonthlyCents} perSeatCents={annualPerSeatMonthlyCents}
billedNote="Billed annually" billedNote="Billed annually"
saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined} saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
selected={cadence === "annual"} selected={cadence === 'annual'}
/> />
<Separator className="mx-4" /> <Separator className="mx-4" />
<CadenceOption <CadenceOption
@@ -202,7 +202,7 @@ function FreeBilling({
label="Monthly" label="Monthly"
perSeatCents={billing.price_monthly_cents} perSeatCents={billing.price_monthly_cents}
billedNote="Billed monthly · cancel anytime" billedNote="Billed monthly · cancel anytime"
selected={cadence === "monthly"} selected={cadence === 'monthly'}
/> />
</RadioGroup> </RadioGroup>
<div className="px-4 py-3"> <div className="px-4 py-3">
@@ -211,7 +211,7 @@ function FreeBilling({
onClick={handleUpgrade} onClick={handleUpgrade}
disabled={createCheckout.isPending} disabled={createCheckout.isPending}
> >
{createCheckout.isPending ? "Opening Stripe..." : "Upgrade to Pro"} {createCheckout.isPending ? 'Opening Stripe...' : 'Upgrade to Pro'}
</Button> </Button>
</div> </div>
</> </>
@@ -233,9 +233,9 @@ function ProBilling({
}); });
}; };
const cadenceLabel = billing.cadence === "annual" ? "Annual" : "Monthly"; const cadenceLabel = billing.cadence === 'annual' ? 'Annual' : 'Monthly';
const perSeatCents = const perSeatCents =
billing.cadence === "annual" billing.cadence === 'annual'
? Math.round(billing.price_annual_cents / 12) ? Math.round(billing.price_annual_cents / 12)
: billing.price_monthly_cents; : billing.price_monthly_cents;
const renewal = billing.current_period_end const renewal = billing.current_period_end
@@ -249,7 +249,7 @@ function ProBilling({
Your subscription is set to downgrade to Free on {renewal}. Your subscription is set to downgrade to Free on {renewal}.
</div> </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"> <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 Your last payment failed. Update your payment method to keep Pro
active. active.
@@ -271,7 +271,7 @@ function ProBilling({
<> <>
<Separator className="mx-4" /> <Separator className="mx-4" />
<InfoRow <InfoRow
label={billing.cancel_at_period_end ? "Ends" : "Renews"} label={billing.cancel_at_period_end ? 'Ends' : 'Renews'}
value={renewal} value={renewal}
/> />
</> </>
@@ -284,9 +284,7 @@ function ProBilling({
disabled={createPortal.isPending} disabled={createPortal.isPending}
> >
<ExternalLink className="mr-2 size-3.5" /> <ExternalLink className="mr-2 size-3.5" />
{createPortal.isPending {createPortal.isPending ? 'Opening Stripe...' : 'Manage subscription'}
? "Opening Stripe..."
: "Manage subscription"}
</Button> </Button>
</div> </div>
</> </>
@@ -340,11 +338,13 @@ function AdminBillingControls({ networkId }: { networkId: string }) {
if (error) { if (error) {
return ( return (
<div className="px-4 py-3"> <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> </div>
); );
} }
if (billing.plan === "pro") { if (billing.plan === 'pro') {
return <ProBilling networkId={networkId} billing={billing} />; return <ProBilling networkId={networkId} billing={billing} />;
} }
return <FreeBilling networkId={networkId} billing={billing} />; return <FreeBilling networkId={networkId} billing={billing} />;
+41 -31
View File
@@ -1,15 +1,15 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { CircleDot, CircleCheckBig } from "lucide-react"; import { CircleDot, CircleCheckBig } from 'lucide-react';
import { particlePath } from "@/lib/particle-path"; import { particlePath } from '@/lib/particle-path';
import { ParticleListView } from "@/features/particles/particle-list-view"; import { ParticleListView } from '@/features/particles/particle-list-view';
import { VideoAudioToggle } from "@/components/video-audio-toggle"; import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { ComposeOverlay } from "./compose/compose-overlay"; import { ComposeOverlay } from './compose/compose-overlay';
import { useComposeIntentStore } from "@/stores/compose-intent-store"; import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator"; import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useStreamParticles } from "@/hooks/use-stream-particles"; import { useStreamParticles } from '@/hooks/use-stream-particles';
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav"; import { useStreamKeyboardNav } from '@/hooks/use-stream-keyboard-nav';
/** /**
* Route-level component for /:networkId (index). * Route-level component for /:networkId (index).
@@ -17,29 +17,33 @@ import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
*/ */
export default function NetworkRoot() { export default function NetworkRoot() {
const { networkId } = useParams(); const { networkId } = useParams();
if (!networkId) throw new Error("NetworkRoot requires a :networkId route param"); if (!networkId)
throw new Error('NetworkRoot requires a :networkId route param');
const navigate = useNavigate(); const navigate = useNavigate();
const path = particlePath(networkId, []); const path = particlePath(networkId, []);
const [composeActive, setComposeActive] = useState(false); const [composeActive, setComposeActive] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const statusTab: "open" | "closed" = const statusTab: 'open' | 'closed' =
searchParams.get("status") === "closed" ? "closed" : "open"; searchParams.get('status') === 'closed' ? 'closed' : 'open';
const setStatusTab = (next: "open" | "closed") => { const setStatusTab = (next: 'open' | 'closed') => {
setSearchParams( setSearchParams(
(prev) => { (prev) => {
const params = new URLSearchParams(prev); const params = new URLSearchParams(prev);
if (next === "open") params.delete("status"); if (next === 'open') params.delete('status');
else params.set("status", next); else params.set('status', next);
return params; return params;
}, },
{ replace: true }, { replace: true },
); );
}; };
const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(path, { const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(
status: statusTab, path,
}); {
status: statusTab,
},
);
const { selectedIndex } = useStreamKeyboardNav({ const { selectedIndex } = useStreamKeyboardNav({
streams, streams,
@@ -56,11 +60,17 @@ export default function NetworkRoot() {
<div className="flex shrink-0 items-center p-1 border-b"> <div className="flex shrink-0 items-center p-1 border-b">
<Tabs <Tabs
value={statusTab} value={statusTab}
onValueChange={(v) => setStatusTab(v === "closed" ? "closed" : "open")} onValueChange={(v) =>
setStatusTab(v === 'closed' ? 'closed' : 'open')
}
> >
<TabsList> <TabsList>
<TabsTrigger value="open"><CircleDot className="size-3 text-green-500" /> Open</TabsTrigger> <TabsTrigger value="open">
<TabsTrigger value="closed"><CircleCheckBig className="size-3" /> Closed</TabsTrigger> <CircleDot className="size-3 text-green-500" /> Open
</TabsTrigger>
<TabsTrigger value="closed">
<CircleCheckBig className="size-3" /> Closed
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
</div> </div>
@@ -99,39 +109,39 @@ function NetworkRootControls() {
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <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"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter Enter
</kbd>{" "} </kbd>{' '}
navigate navigate
</span> </span>
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
19 19
</kbd>{" "} </kbd>{' '}
jump jump
</span> </span>
<VideoAudioToggle /> <VideoAudioToggle />
<button <button
type="button" type="button"
onClick={() => requestIntent("record")} onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80" className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Start recording (or hold `)" title="Start recording (or hold `)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold ` Hold `
</kbd>{" "} </kbd>{' '}
to start to start
</button> </button>
<button <button
type="button" type="button"
onClick={() => requestIntent("text")} onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80" className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Compose text (or press T)" title="Compose text (or press T)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T T
</kbd>{" "} </kbd>{' '}
text text
</button> </button>
</div> </div>
+39 -32
View File
@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from 'react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { Check, Plus, Settings, Users } from "lucide-react"; import { Check, Plus, Settings, Users } from 'lucide-react';
import { toast } from "sonner"; import { toast } from 'sonner';
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Small } from "@/components/ui/typography"; import { Small } from '@/components/ui/typography';
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -14,16 +14,19 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from '@/components/ui/dialog';
import { Input } from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from "@/components/ui/separator"; import { Separator } from '@/components/ui/separator';
import { useNetworks } from "@/hooks/use-networks"; import { useNetworks } from '@/hooks/use-networks';
import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-member-management"; import {
import { apiClient } from "@/api/client"; useMyInvitations,
import { Progress } from "@/components/ui/progress"; useAcceptInvitation,
import { toUserMessage } from "@/lib/errors"; } from '@/hooks/use-member-management';
import type { Network, Invitation } from "@/api/types"; 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({ function NetworkRow({
network, network,
@@ -53,7 +56,7 @@ function NetworkRow({
<div className="text-muted-foreground flex items-center gap-1"> <div className="text-muted-foreground flex items-center gap-1">
<Users className="size-3" /> <Users className="size-3" />
<Small className="text-muted-foreground"> <Small className="text-muted-foreground">
{memberCount} {memberCount === 1 ? "member" : "members"} {memberCount} {memberCount === 1 ? 'member' : 'members'}
</Small> </Small>
</div> </div>
</div> </div>
@@ -66,7 +69,7 @@ function NetworkRow({
onSettingsClick(); onSettingsClick();
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation(); e.stopPropagation();
onSettingsClick(); onSettingsClick();
} }
@@ -109,7 +112,7 @@ function InvitationRow({ invitation }: { invitation: Invitation }) {
disabled={acceptInvitation.isPending} disabled={acceptInvitation.isPending}
> >
<Check className="mr-1 size-3.5" /> <Check className="mr-1 size-3.5" />
{acceptInvitation.isPending ? "Joining..." : "Accept"} {acceptInvitation.isPending ? 'Joining...' : 'Accept'}
</Button> </Button>
</div> </div>
); );
@@ -122,7 +125,7 @@ function CreateNetworkDialog({
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const [name, setName] = useState(""); const [name, setName] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -130,10 +133,10 @@ function CreateNetworkDialog({
mutationFn: (networkName: string) => mutationFn: (networkName: string) =>
apiClient.createNetwork({ name: networkName }), apiClient.createNetwork({ name: networkName }),
onSuccess: (network) => { onSuccess: (network) => {
queryClient.invalidateQueries({ queryKey: ["networks"] }); queryClient.invalidateQueries({ queryKey: ['networks'] });
toast.success(`Created ${network.name}`); toast.success(`Created ${network.name}`);
onOpenChange(false); onOpenChange(false);
setName(""); setName('');
navigate(`/${network.id}/settings`); navigate(`/${network.id}/settings`);
}, },
}); });
@@ -150,7 +153,10 @@ function CreateNetworkDialog({
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create a Network</DialogTitle> <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> </DialogHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="py-4"> <div className="py-4">
@@ -173,7 +179,7 @@ function CreateNetworkDialog({
type="submit" type="submit"
disabled={!name.trim() || createNetwork.isPending} disabled={!name.trim() || createNetwork.isPending}
> >
{createNetwork.isPending ? "Creating..." : "Create"} {createNetwork.isPending ? 'Creating...' : 'Create'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -212,10 +218,13 @@ export default function NetworkSelector() {
return ( return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-4 px-4 text-center"> <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"> <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> </p>
<div className="flex flex-row gap-1"> <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)}> <Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-1 size-3.5" /> <Plus className="mr-1 size-3.5" />
Create Network Create Network
@@ -267,9 +276,7 @@ export default function NetworkSelector() {
<NetworkRow <NetworkRow
network={network} network={network}
onClick={() => navigate(`/${network.id}`)} onClick={() => navigate(`/${network.id}`)}
onSettingsClick={() => onSettingsClick={() => navigate(`/${network.id}/settings`)}
navigate(`/${network.id}/settings`)
}
/> />
{index < networks.length - 1 && ( {index < networks.length - 1 && (
<Separator className="mx-4" /> <Separator className="mx-4" />
+39 -31
View File
@@ -1,26 +1,26 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from "lucide-react"; import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from 'lucide-react';
import { toast } from "sonner"; import { toast } from 'sonner';
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Input } from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from "@/components/ui/separator"; import { Separator } from '@/components/ui/separator';
import { Muted } from "@/components/ui/typography"; import { Muted } from '@/components/ui/typography';
import { WindowControls } from "@/components/window-controls"; import { WindowControls } from '@/components/window-controls';
import { useNetworks } from "@/hooks/use-networks"; import { useNetworks } from '@/hooks/use-networks';
import { import {
useNetworkInvitations, useNetworkInvitations,
useInviteMembers, useInviteMembers,
useRevokeInvitation, useRevokeInvitation,
useRemoveMember, useRemoveMember,
} from "@/hooks/use-member-management"; } from '@/hooks/use-member-management';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { BillingSection } from "@/features/network-billing"; import { BillingSection } from '@/features/network-billing';
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay"; import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
import type { Human } from "@/api/types"; import type { Human } from '@/api/types';
function MemberRow({ function MemberRow({
human, human,
@@ -66,7 +66,7 @@ function MemberRow({
} }
function InviteForm({ networkId }: { networkId: string }) { function InviteForm({ networkId }: { networkId: string }) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState('');
const inviteMembers = useInviteMembers(networkId); const inviteMembers = useInviteMembers(networkId);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -77,7 +77,7 @@ function InviteForm({ networkId }: { networkId: string }) {
inviteMembers.mutate([trimmed], { inviteMembers.mutate([trimmed], {
onSuccess: () => { onSuccess: () => {
toast.success(`Invitation sent to ${trimmed}`); toast.success(`Invitation sent to ${trimmed}`);
setEmail(""); setEmail('');
}, },
}); });
}; };
@@ -96,7 +96,7 @@ function InviteForm({ networkId }: { networkId: string }) {
size="sm" size="sm"
disabled={!email.trim() || inviteMembers.isPending} disabled={!email.trim() || inviteMembers.isPending}
> >
{inviteMembers.isPending ? "Sending..." : "Invite"} {inviteMembers.isPending ? 'Sending...' : 'Invite'}
</Button> </Button>
</form> </form>
); );
@@ -179,11 +179,13 @@ function Section({ children }: { children: React.ReactNode }) {
export default function NetworkSettingsPage() { export default function NetworkSettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { networkId } = useParams<{ networkId: string }>(); const { networkId } = useParams<{ networkId: string }>();
if (!networkId) throw new Error("NetworkSettingsPage requires a :networkId route param"); if (!networkId)
throw new Error('NetworkSettingsPage requires a :networkId route param');
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { data: networks } = useNetworks(); const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId); 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 currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id; const isAdmin = currentUser?.id === network?.admin_human.id;
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null); const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
@@ -192,12 +194,15 @@ export default function NetworkSettingsPage() {
const billingRef = useRef<HTMLDivElement>(null); const billingRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (searchParams.get("section") === "billing") { if (searchParams.get('section') === 'billing') {
billingRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); billingRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
} }
}, [searchParams]); }, [searchParams]);
const networkName = network?.name ?? "Network"; const networkName = network?.name ?? 'Network';
const memberCount = network?.humans.length ?? 0; const memberCount = network?.humans.length ?? 0;
const pendingCount = invitations?.length ?? 0; const pendingCount = invitations?.length ?? 0;
const networkInitials = networkName.slice(0, 2).toUpperCase(); const networkInitials = networkName.slice(0, 2).toUpperCase();
@@ -228,8 +233,8 @@ export default function NetworkSettingsPage() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-base font-semibold">{networkName}</p> <p className="truncate text-base font-semibold">{networkName}</p>
<Muted className="text-xs"> <Muted className="text-xs">
{memberCount} {memberCount === 1 ? "member" : "members"} {memberCount} {memberCount === 1 ? 'member' : 'members'}
{isAdmin ? " · You're an admin" : ""} {isAdmin ? " · You're an admin" : ''}
</Muted> </Muted>
</div> </div>
</div> </div>
@@ -255,7 +260,9 @@ export default function NetworkSettingsPage() {
<MemberRow <MemberRow
human={human} human={human}
isAdmin={isRowAdmin} isAdmin={isRowAdmin}
onRemove={canRemove ? () => setMemberToRemove(human) : undefined} onRemove={
canRemove ? () => setMemberToRemove(human) : undefined
}
/> />
{index < network.humans.length - 1 && ( {index < network.humans.length - 1 && (
<Separator className="mx-4" /> <Separator className="mx-4" />
@@ -320,7 +327,7 @@ export default function NetworkSettingsPage() {
title="Billing" title="Billing"
description={ description={
isAdmin isAdmin
? "Manage your plan, seats, and payment." ? 'Manage your plan, seats, and payment.'
: "Your network's current plan and usage." : "Your network's current plan and usage."
} }
/> />
@@ -343,7 +350,8 @@ export default function NetworkSettingsPage() {
</li> </li>
<li>Any content they posted stays in the network.</li> <li>Any content they posted stays in the network.</li>
<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> </li>
</ul> </ul>
} }
@@ -1,10 +1,10 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import { toast } from "sonner"; import { toast } from 'sonner';
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay"; import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
import { softDeleteParticle } from "@/lib/firestore-particles"; import { softDeleteParticle } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface DeleteParticleOverlayProps { interface DeleteParticleOverlayProps {
networkId: string; networkId: string;
@@ -21,7 +21,7 @@ export function DeleteParticleOverlay({
userId, userId,
onClose, onClose,
}: DeleteParticleOverlayProps) { }: DeleteParticleOverlayProps) {
useSuspendPlayback(true, "delete-particle"); useSuspendPlayback(true, 'delete-particle');
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -33,10 +33,11 @@ export function DeleteParticleOverlay({
particlePath(networkId, [streamId, particle.id]), particlePath(networkId, [streamId, particle.id]),
); );
await softDeleteParticle(docPath, userId); await softDeleteParticle(docPath, userId);
toast.success("Particle deleted"); toast.success('Particle deleted');
onClose(); onClose();
} catch (e) { } 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); toast.error(message);
setDeleting(false); setDeleting(false);
} }
@@ -1,8 +1,8 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { Trash2 } from "lucide-react"; import { Trash2 } from 'lucide-react';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { useNetwork } from "@/hooks/use-networks"; import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
// How long to linger on a tombstone before auto-advancing. Matches the // How long to linger on a tombstone before auto-advancing. Matches the
// "reading" cadence of a short text particle. // "reading" cadence of a short text particle.
@@ -23,7 +23,9 @@ export function DeletedParticleView({
}: DeletedParticleViewProps) { }: DeletedParticleViewProps) {
const network = useNetwork(networkId); const network = useNetwork(networkId);
const deleterId = 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 const deleter = deleterId
? resolveHumanDisplay(deleterId, network?.humans) ? resolveHumanDisplay(deleterId, network?.humans)
: null; : null;
@@ -1,19 +1,24 @@
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from '@/components/ui/card';
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react"; import {
import { useNetwork } from "@/hooks/use-networks"; FileIcon,
import { resolveHumanDisplay } from "@/lib/humans"; 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 }> = { const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
quest: { icon: ScrollTextIcon, label: "Quest" }, quest: { icon: ScrollTextIcon, label: 'Quest' },
paper: { icon: BookOpenIcon, label: "Paper" }, paper: { icon: BookOpenIcon, label: 'Paper' },
file: { icon: FileIcon, label: "File" }, file: { icon: FileIcon, label: 'File' },
}; };
interface FallbackParticleViewProps { interface FallbackParticleViewProps {
@@ -21,9 +26,15 @@ interface FallbackParticleViewProps {
networkId: string; networkId: string;
} }
export function FallbackParticleView({ particle, networkId }: FallbackParticleViewProps) { export function FallbackParticleView({
particle,
networkId,
}: FallbackParticleViewProps) {
const network = useNetwork(networkId); 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] ?? { const meta = TYPE_META[particle.type] ?? {
icon: HelpCircleIcon, icon: HelpCircleIcon,
label: particle.type, label: particle.type,
@@ -31,13 +42,13 @@ export function FallbackParticleView({ particle, networkId }: FallbackParticleVi
const Icon = meta.icon; const Icon = meta.icon;
const title = (() => { const title = (() => {
switch (particle.type) { switch (particle.type) {
case "quest": case 'quest':
return particle.properties.title; return particle.properties.title;
case "paper": case 'paper':
return particle.properties.title; return particle.properties.title;
case "file": case 'file':
return particle.properties.filename; return particle.properties.filename;
case "folder": case 'folder':
return particle.properties.name; return particle.properties.name;
default: default:
return null; return null;
@@ -1,6 +1,6 @@
import { Particle } from "@/api/types"; import { Particle } from '@/api/types';
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import { ComposeOverlay } from "@/features/compose/compose-overlay"; import { ComposeOverlay } from '@/features/compose/compose-overlay';
interface FolderViewProps { interface FolderViewProps {
folderParticle: Particle; folderParticle: Particle;
@@ -1,16 +1,22 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import {
import type { Particle } from "@/api/types"; forwardRef,
import type { ParticlePath } from "@/lib/particle-path"; useEffect,
import { useDownloadUrl } from "@/hooks/use-download-url"; useImperativeHandle,
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback"; useRef,
import { TranscriptOverlay } from "@/features/particles/transcript-overlay"; useState,
import { Skeleton } from "@/components/ui/skeleton"; } from 'react';
import { AudioLevelBars } from "@/components/audio/audio-level-bars"; import type { Particle } from '@/api/types';
import { useAudioSource } from "@/components/audio/use-audio-source"; import type { ParticlePath } from '@/lib/particle-path';
import { useParticleAttachments } from "@/hooks/use-particle-attachments"; import { useDownloadUrl } from '@/hooks/use-download-url';
import { ParticleAttachments } from "@/features/particles/particle-attachments"; 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 { export interface MediaParticleHandle {
/** Seek by delta. Returns true if seeked, false if at boundary (should navigate). */ /** Seek by delta. Returns true if seeked, false if at boundary (should navigate). */
@@ -26,13 +32,13 @@ interface MediaParticleViewProps {
onProgress?: (ratio: number) => void; onProgress?: (ratio: number) => void;
} }
export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleViewProps>(function MediaParticleView({ export const MediaParticleView = forwardRef<
particle, MediaParticleHandle,
streamPath, MediaParticleViewProps
paused, >(function MediaParticleView(
onEnded, { particle, streamPath, paused, onEnded, onProgress },
onProgress, ref,
}, ref) { ) {
// Prefer the worker-produced iOS-playable variant when present so desktop and // Prefer the worker-produced iOS-playable variant when present so desktop and
// mobile read the same canonical asset, falling back to the original. Pinned // 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 // on mount (the parent keys this component by particle.id, so a new particle
@@ -51,22 +57,30 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const isAudio = activeMime?.startsWith("audio/"); const isAudio = activeMime?.startsWith('audio/');
useImperativeHandle(ref, () => ({ useImperativeHandle(
seek(deltaSec: number) { ref,
const el = isAudio ? audioRef.current : videoRef.current; () => ({
if (!el) return false; seek(deltaSec: number) {
if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false; const el = isAudio ? audioRef.current : videoRef.current;
if (deltaSec > 0 && el.duration - el.currentTime < deltaSec) return false; if (!el) return false;
el.currentTime = Math.max(0, Math.min(el.duration, el.currentTime + deltaSec)); if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false;
return true; if (deltaSec > 0 && el.duration - el.currentTime < deltaSec)
}, return false;
setPlaybackRate(rate: number) { el.currentTime = Math.max(
const el = isAudio ? audioRef.current : videoRef.current; 0,
if (el) el.playbackRate = rate; Math.min(el.duration, el.currentTime + deltaSec),
}, );
}), [isAudio]); return true;
},
setPlaybackRate(rate: number) {
const el = isAudio ? audioRef.current : videoRef.current;
if (el) el.playbackRate = rate;
},
}),
[isAudio],
);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const transcript = particle.properties.transcript; const transcript = particle.properties.transcript;
@@ -88,7 +102,7 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
} else if (!el.ended) { } else if (!el.ended) {
// Calling play() on a naturally-finished element restarts it from 0. // Calling play() on a naturally-finished element restarts it from 0.
el.play().catch(() => { el.play().catch(() => {
console.warn("Playback failed", { particleId: particle.id }); console.warn('Playback failed', { particleId: particle.id });
}); });
} }
}, [paused, isAudio, particle.id]); }, [paused, isAudio, particle.id]);
@@ -105,14 +119,17 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
return <Skeleton className="h-full w-full rounded-none" />; 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; const { currentTime: time, duration } = e.currentTarget;
setCurrentTime(time); setCurrentTime(time);
// WebM files from MediaRecorder (screen recordings) often report Infinity/NaN // WebM files from MediaRecorder (screen recordings) often report Infinity/NaN
// duration until fully buffered — fall back to the known duration from metadata. // duration until fully buffered — fall back to the known duration from metadata.
const effectiveDuration = Number.isFinite(duration) && duration > 0 const effectiveDuration =
? duration Number.isFinite(duration) && duration > 0
: particle.properties.duration_ms / 1000; ? duration
: particle.properties.duration_ms / 1000;
if (effectiveDuration > 0) onProgress?.(time / effectiveDuration); if (effectiveDuration > 0) onProgress?.(time / effectiveDuration);
}; };
@@ -1,22 +1,22 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from 'react';
import { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react"; import { Download, ExternalLink, FileIcon, ImageIcon } from 'lucide-react';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { useDownloadUrl } from "@/hooks/use-download-url"; import { useDownloadUrl } from '@/hooks/use-download-url';
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { import {
AttachmentLightbox, AttachmentLightbox,
getAttachmentHandler, getAttachmentHandler,
type AttachmentItem, type AttachmentItem,
} from "@/features/attachments/attachment-lightbox"; } from '@/features/attachments/attachment-lightbox';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
type FileParticle = Extract<Particle, { type: "file" }>; type FileParticle = Extract<Particle, { type: 'file' }>;
interface ParticleAttachmentsProps { interface ParticleAttachmentsProps {
attachments: FileParticle[]; attachments: FileParticle[];
variant?: "inline" | "compact"; variant?: 'inline' | 'compact';
} }
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
@@ -31,7 +31,7 @@ function particleToItem(p: FileParticle): AttachmentItem {
filename: p.properties.filename, filename: p.properties.filename,
mimeType: p.properties.mime_type, mimeType: p.properties.mime_type,
sizeBytes: p.properties.size_bytes, 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, url: string | undefined,
onPreview: (index: number) => void, onPreview: (index: number) => void,
) { ) {
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") { if (getAttachmentHandler(particle.properties.mime_type) === 'lightbox') {
onPreview(index); onPreview(index);
} else if (url) { } else if (url) {
platform.link.openExternal(url); platform.link.openExternal(url);
@@ -58,7 +58,9 @@ function ImageAttachment({
particle: FileParticle; particle: FileParticle;
onPreview: () => void; onPreview: () => void;
}) { }) {
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id); const { data: url, isLoading } = useDownloadUrl(
particle.properties.object_id,
);
if (isLoading || !url) { if (isLoading || !url) {
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />; return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
@@ -165,7 +167,7 @@ function CompactAttachmentItem({
index: number; index: number;
onPreview: (index: number) => void; 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); const { data: url } = useDownloadUrl(particle.properties.object_id);
return ( 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); const [openIndex, setOpenIndex] = useState<number | null>(null);
// Only previewable attachments populate the lightbox; the index passed to the // Only previewable attachments populate the lightbox; the index passed to the
// lightbox is the index into this filtered list, not `attachments`. // lightbox is the index into this filtered list, not `attachments`.
const previewable = useMemo( const previewable = useMemo(
() => attachments.filter((a) => getAttachmentHandler(a.properties.mime_type) === "lightbox"), () =>
attachments.filter(
(a) => getAttachmentHandler(a.properties.mime_type) === 'lightbox',
),
[attachments], [attachments],
); );
const items = useMemo(() => previewable.map(particleToItem), [previewable]); 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 ( return (
<> <>
<div className="flex max-w-48 flex-col gap-1"> <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"> <ScrollArea className="w-full">
<div className="flex items-center gap-2 py-1"> <div className="flex items-center gap-2 py-1">
{attachments.map((attachment, i) => { {attachments.map((attachment, i) => {
const isImage = attachment.properties.mime_type.startsWith("image/"); const isImage =
attachment.properties.mime_type.startsWith('image/');
return isImage ? ( return isImage ? (
<ImageAttachment <ImageAttachment
key={attachment.id} key={attachment.id}
@@ -1,5 +1,12 @@
import { useMemo, useRef, useEffect, useCallback, memo, createElement } from "react"; import {
import { useNavigate } from "react-router-dom"; useMemo,
useRef,
useEffect,
useCallback,
memo,
createElement,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Radio, Radio,
MessageSquare, MessageSquare,
@@ -12,24 +19,28 @@ import {
Headphones, Headphones,
Trash2, Trash2,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from 'lucide-react';
import { cn, getInitials } from "@/lib/utils"; import { cn, getInitials } from '@/lib/utils';
import { useLiveLatestChild } from "@/hooks/use-particle"; import { useLiveLatestChild } from '@/hooks/use-particle';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { particlePath } from "@/lib/particle-path"; import { particlePath } from '@/lib/particle-path';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
import { RelativeTimestamp } from "@/components/relative-timestamp"; import { RelativeTimestamp } from '@/components/relative-timestamp';
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Separator } from "@/components/ui/separator"; import { Separator } from '@/components/ui/separator';
import { Progress } from "@/components/ui/progress"; import { Progress } from '@/components/ui/progress';
import { Small } from "@/components/ui/typography"; import { Small } from '@/components/ui/typography';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/types"; import {
import type { StreamParticle } from "@/hooks/use-stream-particles"; isParticleDeleted,
import { useNetwork } from "@/hooks/use-networks"; type Particle,
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay"; type StreamProperties,
import { useDownloadUrl } from "@/hooks/use-download-url"; } from '@/api/types';
import { StreamContextMenu } from "@/features/particles/stream-context-menu"; 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({ function VideoThumbnail({
objectId, objectId,
@@ -42,8 +53,8 @@ function VideoThumbnail({
return ( return (
<div <div
className={cn( className={cn(
"size-8 shrink-0 overflow-hidden rounded-md bg-muted", 'size-8 shrink-0 overflow-hidden rounded-md bg-muted',
isUnseen && "ring-2 ring-primary", isUnseen && 'ring-2 ring-primary',
)} )}
> >
{url && ( {url && (
@@ -63,20 +74,20 @@ function VideoThumbnail({
function getParticleTypeIcon(particle: Particle): LucideIcon { function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2; if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) { switch (particle.type) {
case "text": case 'text':
return MessageSquare; return MessageSquare;
case "media": { case 'media': {
const mime = particle.properties.mime_type; const mime = particle.properties.mime_type;
if (mime.startsWith("video/")) return Video; if (mime.startsWith('video/')) return Video;
if (mime.startsWith("audio/")) return Mic; if (mime.startsWith('audio/')) return Mic;
if (mime.startsWith("image/")) return Image; if (mime.startsWith('image/')) return Image;
return Video; return Video;
} }
case "file": case 'file':
return FileText; return FileText;
case "quest": case 'quest':
return CircleCheck; return CircleCheck;
case "paper": case 'paper':
return StickyNote; return StickyNote;
default: default:
return Radio; return Radio;
@@ -84,25 +95,25 @@ function getParticleTypeIcon(particle: Particle): LucideIcon {
} }
function getMessagePreview(particle: Particle): string { function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return "Deleted particle"; if (isParticleDeleted(particle)) return 'Deleted particle';
switch (particle.type) { switch (particle.type) {
case "text": case 'text':
return particle.properties.content; return particle.properties.content;
case "media": { case 'media': {
const mime = particle.properties.mime_type; const mime = particle.properties.mime_type;
if (mime.startsWith("image/")) return "Photo"; if (mime.startsWith('image/')) return 'Photo';
if (mime.startsWith("video/") || mime.startsWith("audio/")) { if (mime.startsWith('video/') || mime.startsWith('audio/')) {
const transcriptText = particle.properties.transcript?.transcript; const transcriptText = particle.properties.transcript?.transcript;
if (transcriptText) return transcriptText; 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; return particle.properties.filename;
case "quest": case 'quest':
return particle.properties.title; return particle.properties.title;
case "paper": case 'paper':
return particle.properties.title; return particle.properties.title;
default: default:
return particle.type; return particle.type;
@@ -116,7 +127,7 @@ const StreamRow = memo(function StreamRow({
isSelected, isSelected,
shortcutKey, shortcutKey,
}: { }: {
particle: Particle & { type: "stream"; properties: StreamProperties }; particle: Particle & { type: 'stream'; properties: StreamProperties };
networkId: string; networkId: string;
onNavigate: (streamId: string) => void; onNavigate: (streamId: string) => void;
isSelected?: boolean; isSelected?: boolean;
@@ -125,18 +136,19 @@ const StreamRow = memo(function StreamRow({
const streamPath = particlePath(networkId, [particle.id]); const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath); const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const userId = user?.id ?? ""; const userId = user?.id ?? '';
const network = useNetwork(networkId); const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined); useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const hasActiveHuddle = 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 huddleCount = particle.huddle_active_participants?.length ?? 0;
const isDM = const isDM =
particle.visible_to.length === 2 && particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:")); particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => { const initials = useMemo(() => {
if (isDM) { if (isDM) {
@@ -144,19 +156,28 @@ const StreamRow = memo(function StreamRow({
(v) => v !== `human:${userId}`, (v) => v !== `human:${userId}`,
); );
if (otherEntry) { if (otherEntry) {
const otherId = otherEntry.replace("human:", ""); const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId); const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email); if (otherHuman) return getInitials(otherHuman.email);
} }
} }
if (latestChild) { 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); if (creator) return getInitials(creator.email);
} }
return particle.properties.name.slice(0, 2).toUpperCase(); 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(() => { const isUnseen = useMemo(() => {
if (!latestChild) return false; if (!latestChild) return false;
@@ -170,15 +191,16 @@ const StreamRow = memo(function StreamRow({
if (!latestChild) return null; if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_human_id === userId; const isCurrentUser = latestChild.created_by_human_id === userId;
if (isDM) { if (isDM) {
return isCurrentUser ? "You: " : null; return isCurrentUser ? 'You: ' : null;
} }
// Group stream // Group stream
if (isCurrentUser) return "You: "; if (isCurrentUser) return 'You: ';
const { displayName } = resolveHumanDisplay( const { displayName } = resolveHumanDisplay(
latestChild.created_by_human_id, latestChild.created_by_human_id,
network?.humans, network?.humans,
); );
const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1); const capitalized =
displayName.charAt(0).toUpperCase() + displayName.slice(1);
return `${capitalized}: `; return `${capitalized}: `;
}, [latestChild, userId, isDM, network]); }, [latestChild, userId, isDM, network]);
@@ -193,8 +215,8 @@ const StreamRow = memo(function StreamRow({
const videoThumbObjectId = const videoThumbObjectId =
latestChild && latestChild &&
!isParticleDeleted(latestChild) && !isParticleDeleted(latestChild) &&
latestChild.type === "media" && latestChild.type === 'media' &&
latestChild.properties.mime_type.startsWith("video/") latestChild.properties.mime_type.startsWith('video/')
? latestChild.properties.object_id ? latestChild.properties.object_id
: null; : null;
@@ -203,11 +225,13 @@ const StreamRow = memo(function StreamRow({
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => onNavigate(particle.id)} 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( className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent", 'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
isSelected && "bg-accent", isSelected && 'bg-accent',
hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent", hasActiveHuddle && 'bg-gradient-to-r from-red-500/10 to-transparent',
)} )}
> >
{shortcutKey && ( {shortcutKey && (
@@ -218,7 +242,7 @@ const StreamRow = memo(function StreamRow({
{videoThumbObjectId ? ( {videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} /> <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"> <AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials} {initials}
</AvatarFallback> </AvatarFallback>
@@ -228,10 +252,10 @@ const StreamRow = memo(function StreamRow({
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p <p
className={cn( className={cn(
"truncate text-sm", 'truncate text-sm',
isUnseen isUnseen
? "font-semibold text-foreground" ? 'font-semibold text-foreground'
: "font-medium text-muted-foreground", : 'font-medium text-muted-foreground',
)} )}
> >
{particle.properties.name} {particle.properties.name}
@@ -240,14 +264,16 @@ const StreamRow = memo(function StreamRow({
{hasActiveHuddle && ( {hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5"> <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" /> <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> </span>
)} )}
{latestChild && ( {latestChild && (
<Small <Small
className={cn( className={cn(
"shrink-0", 'shrink-0',
isUnseen ? "text-primary" : "text-muted-foreground", isUnseen ? 'text-primary' : 'text-muted-foreground',
)} )}
> >
<RelativeTimestamp date={latestChild.created_at} /> <RelativeTimestamp date={latestChild.created_at} />
@@ -258,16 +284,16 @@ const StreamRow = memo(function StreamRow({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{createElement(typeIcon, { {createElement(typeIcon, {
className: cn( className: cn(
"size-3.5 shrink-0", 'size-3.5 shrink-0',
isUnseen ? "text-foreground" : "text-muted-foreground", isUnseen ? 'text-foreground' : 'text-muted-foreground',
), ),
})} })}
<Small <Small
className={cn( className={cn(
"truncate", 'truncate',
isUnseen isUnseen
? "text-foreground font-medium" ? 'text-foreground font-medium'
: "text-muted-foreground font-normal", : 'text-muted-foreground font-normal',
)} )}
> >
{senderPrefix && ( {senderPrefix && (
@@ -277,9 +303,7 @@ const StreamRow = memo(function StreamRow({
</Small> </Small>
</div> </div>
</div> </div>
{isUnseen && ( {isUnseen && <span className="size-2 shrink-0 rounded-full bg-primary" />}
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</div> </div>
); );
}); });
@@ -314,8 +338,12 @@ export function ParticleListView({
); );
useEffect(() => { useEffect(() => {
if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0) { if (
rowRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); selectedIndex !== null &&
selectedIndex !== undefined &&
selectedIndex >= 0
) {
rowRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
} }
}, [selectedIndex]); }, [selectedIndex]);
@@ -328,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"> <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" /> <Radio className="text-muted-foreground size-8" />
<p className="text-muted-foreground text-sm"> <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> </p>
</div> </div>
); );
@@ -337,9 +366,15 @@ export function ParticleListView({
return ( return (
<div> <div>
{streams.map((stream, index) => ( {streams.map((stream, index) => (
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}> <StreamContextMenu
key={stream.id}
particle={stream}
networkId={networkId}
>
<div <div
ref={(el) => { rowRefs.current[index] = el; }} ref={(el) => {
rowRefs.current[index] = el;
}}
> >
<StreamRow <StreamRow
particle={stream} particle={stream}
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from '@/components/ui/skeleton';
import { import {
Video, Video,
Mic, Mic,
@@ -9,31 +9,35 @@ import {
BookOpen, BookOpen,
FileIcon, FileIcon,
FolderIcon, FolderIcon,
} from "lucide-react"; } from 'lucide-react';
export function ParticlePreview({ particle }: { particle: Particle }) { export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) { switch (particle.type) {
case "text": case 'text':
return <TextPreview particle={particle} />; return <TextPreview particle={particle} />;
case "media": case 'media':
return <MediaPreview particle={particle} />; return <MediaPreview particle={particle} />;
case "quest": case 'quest':
return <QuestPreview particle={particle} />; return <QuestPreview particle={particle} />;
case "paper": case 'paper':
return <PaperPreview particle={particle} />; return <PaperPreview particle={particle} />;
case "file": case 'file':
return <FilePreview particle={particle} />; return <FilePreview particle={particle} />;
case "folder": case 'folder':
return <FolderPreview particle={particle} />; return <FolderPreview particle={particle} />;
default: default:
return <EmptyPreview />; return <EmptyPreview />;
} }
} }
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) { function TextPreview({
particle,
}: {
particle: Extract<Particle, { type: 'text' }>;
}) {
const truncated = const truncated =
particle.properties.content.length > 30 particle.properties.content.length > 30
? particle.properties.content.slice(0, 30) + "..." ? particle.properties.content.slice(0, 30) + '...'
: particle.properties.content; : particle.properties.content;
return ( return (
<div className="flex h-full w-full items-center justify-center p-4"> <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 { 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 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) { if (isVideo) {
return <VideoThumbnail particleId={particle.properties.object_id} duration={durationLabel} />; return (
<VideoThumbnail
particleId={particle.properties.object_id}
duration={durationLabel}
/>
);
} }
return ( 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; const { title, status } = particle.properties;
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4"> <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" /> <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"> <p className="line-clamp-2 text-center text-sm font-medium">{title}</p>
{title}
</p>
{status && ( {status && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wide"> <span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{status} {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 ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4"> <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" /> <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 ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4"> <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" /> <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 ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4"> <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" /> <FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
@@ -1,14 +1,13 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from 'react-router-dom';
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from '@tanstack/react-query';
import { Lock } from "lucide-react"; import { Lock } from 'lucide-react';
import { useLiveParticle } from "@/hooks/use-particle"; import { useLiveParticle } from '@/hooks/use-particle';
import { particlePath } from "@/lib/particle-path"; import { particlePath } from '@/lib/particle-path';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { StreamView } from "@/features/particles/stream-view";
import { FolderView } from "@/features/particles/folder-view";
import { StreamView } from '@/features/particles/stream-view';
import { FolderView } from '@/features/particles/folder-view';
/** /**
* Route-level component for /:networkId/*. * Route-level component for /:networkId/*.
@@ -16,9 +15,10 @@ import { FolderView } from "@/features/particles/folder-view";
* the appropriate view based on particle type. * the appropriate view based on particle type.
*/ */
export default function ParticleViewResolver() { export default function ParticleViewResolver() {
const { networkId, "*": rest } = useParams(); const { networkId, '*': rest } = useParams();
if (!networkId) throw new Error("ParticleViewResolver requires a :networkId route param"); if (!networkId)
const segments = (rest ?? "").split("/").filter(Boolean); 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 path = particlePath(networkId, segments); // path of current container particle
const { particle, isLoading, error } = useLiveParticle(path); const { particle, isLoading, error } = useLiveParticle(path);
@@ -39,9 +39,9 @@ export default function ParticleViewResolver() {
} }
switch (particle.type) { switch (particle.type) {
case "stream": case 'stream':
return <StreamView streamParticle={particle} path={path} />; return <StreamView streamParticle={particle} path={path} />;
case "folder": case 'folder':
return <FolderView folderParticle={particle} path={path} />; return <FolderView folderParticle={particle} path={path} />;
default: default:
return ( return (
@@ -60,7 +60,7 @@ function InaccessibleParticle() {
useEffect(() => { useEffect(() => {
// Refresh the networks list so the home page reflects current access. // Refresh the networks list so the home page reflects current access.
queryClient.invalidateQueries({ queryKey: ["networks"] }); queryClient.invalidateQueries({ queryKey: ['networks'] });
}, [queryClient]); }, [queryClient]);
return ( return (
@@ -72,7 +72,7 @@ function InaccessibleParticle() {
It may have been deleted, or your access was removed. It may have been deleted, or your access was removed.
</p> </p>
</div> </div>
<Button size="sm" onClick={() => navigate("/", { replace: true })}> <Button size="sm" onClick={() => navigate('/', { replace: true })}>
Go home Go home
</Button> </Button>
</div> </div>
@@ -1,10 +1,10 @@
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from '@/components/ui/tooltip';
import type { HumanPresence } from "@/hooks/use-presence-positions"; import type { HumanPresence } from '@/hooks/use-presence-positions';
const MAX_VISIBLE_AVATARS = 3; const MAX_VISIBLE_AVATARS = 3;
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -18,7 +18,7 @@ interface PlaybackPageIndicatorProps {
/** Set of humanIds currently online in the stream channel. */ /** Set of humanIds currently online in the stream channel. */
onlineHumanIds?: Set<string>; onlineHumanIds?: Set<string>;
/** Render only avatars or only tracks. Omit to render both. */ /** Render only avatars or only tracks. Omit to render both. */
layer?: "avatars" | "tracks"; layer?: 'avatars' | 'tracks';
} }
export function PlaybackPageIndicator({ export function PlaybackPageIndicator({
@@ -32,8 +32,8 @@ export function PlaybackPageIndicator({
}: PlaybackPageIndicatorProps) { }: PlaybackPageIndicatorProps) {
if (total === 0) return null; if (total === 0) return null;
const showAvatars = layer !== "tracks"; const showAvatars = layer !== 'tracks';
const showTracks = layer !== "avatars"; const showTracks = layer !== 'avatars';
const paginated = total > PAGE_SIZE; const paginated = total > PAGE_SIZE;
const safeCurrent = current < 0 ? 0 : current; const safeCurrent = current < 0 ? 0 : current;
@@ -84,11 +84,12 @@ export function PlaybackPageIndicator({
style={{ style={{
width: width:
i < current i < current
? "100%" ? '100%'
: i === current : i === current
? `${progress * 100}%` ? `${progress * 100}%`
: "0%", : '0%',
transition: i === current ? "width 300ms linear" : "none", transition:
i === current ? 'width 300ms linear' : 'none',
}} }}
/> />
</button> </button>
@@ -160,7 +161,14 @@ function SegmentPresenceAvatars({
{visible.map((human) => ( {visible.map((human) => (
<Tooltip key={human.humanId}> <Tooltip key={human.humanId}>
<TooltipTrigger asChild> <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> <AvatarFallback>
{human.emailPrefix.slice(0, 2).toUpperCase()} {human.emailPrefix.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
@@ -172,9 +180,7 @@ function SegmentPresenceAvatars({
</Tooltip> </Tooltip>
))} ))}
{overflow > 0 && ( {overflow > 0 && (
<span className="text-[10px] text-white/70 pl-1"> <span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
+{overflow}
</span>
)} )}
</div> </div>
); );
@@ -1,10 +1,14 @@
import { useState } from "react"; import { useState } from 'react';
import { Plus, Type, X } from "lucide-react"; import { Plus, Type, X } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; Tooltip,
import { REACTION_EMOJIS, type Reactions, type Human } from "@/api/types"; TooltipContent,
import { cn } from "@/lib/utils"; TooltipTrigger,
import { resolveHumanDisplay } from "@/lib/humans"; } 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 { interface ReactionBarProps {
reactions: Reactions; reactions: Reactions;
@@ -19,7 +23,7 @@ const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
function getReactorNames(humanIds: string[], humans?: Human[]): string { function getReactorNames(humanIds: string[], humans?: Human[]): string {
return humanIds return humanIds
.map((id) => resolveHumanDisplay(id, humans).displayName) .map((id) => resolveHumanDisplay(id, humans).displayName)
.join(", "); .join(', ');
} }
function getReactorList( function getReactorList(
@@ -66,12 +70,15 @@ export function ReactionBar({
<Tooltip key={emoji}> <Tooltip key={emoji}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }} onClick={(e) => {
e.stopPropagation();
handleToggle(emoji);
}}
className={cn( 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 isMine
? "bg-white/20 ring-1 ring-white/40" ? 'bg-white/20 ring-1 ring-white/40'
: "bg-black/40 hover:bg-black/50", : 'bg-black/40 hover:bg-black/50',
)} )}
> >
<span className="text-sm">{emoji}</span> <span className="text-sm">{emoji}</span>
@@ -95,12 +102,15 @@ export function ReactionBar({
<Tooltip key={text}> <Tooltip key={text}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
onClick={(e) => { e.stopPropagation(); handleToggle(text); }} onClick={(e) => {
e.stopPropagation();
handleToggle(text);
}}
className={cn( 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 isMine
? "bg-white/20 ring-1 ring-white/40" ? 'bg-white/20 ring-1 ring-white/40'
: "bg-black/40 hover:bg-black/50", : 'bg-black/40 hover:bg-black/50',
)} )}
> >
<Avatar size="xs" className="shrink-0"> <Avatar size="xs" className="shrink-0">
@@ -110,22 +120,30 @@ export function ReactionBar({
</Avatar> </Avatar>
<span className="truncate text-white/90">{text}</span> <span className="truncate text-white/90">{text}</span>
{reactors.length > 1 && ( {reactors.length > 1 && (
<span className="shrink-0 text-white/60">{reactors.length}</span> <span className="shrink-0 text-white/60">
{reactors.length}
</span>
)} )}
</button> </button>
</TooltipTrigger> </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> <div className="font-medium">{text}</div>
<ul className="flex flex-col gap-0.5 opacity-80"> <ul className="flex flex-col gap-0.5 opacity-80">
{reactorList.map((r) => ( {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.label}
{r.isMine && <span className="ml-1 opacity-60">(you)</span>} {r.isMine && <span className="ml-1 opacity-60">(you)</span>}
</li> </li>
))} ))}
</ul> </ul>
<div className="border-t border-current/15 pt-1 text-[10px] opacity-60"> <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> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -140,7 +158,10 @@ export function ReactionBar({
return ( return (
<button <button
key={emoji} 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" className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
> >
{emoji} {emoji}
@@ -148,7 +169,10 @@ export function ReactionBar({
); );
})} })}
<button <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" className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
> >
<X className="size-3 text-white/60" /> <X className="size-3 text-white/60" />
@@ -159,18 +183,27 @@ export function ReactionBar({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <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" 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" /> <Type className="size-3 text-white/60" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left" className="text-xs"> <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> </TooltipContent>
</Tooltip> </Tooltip>
<button <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" 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" /> <Plus className="size-3 text-white/60" />
@@ -1,15 +1,15 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from 'react';
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom';
import { Input } from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { updateParticleProperties } from "@/lib/firestore-particles"; import { updateParticleProperties } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface RenameStreamOverlayProps { interface RenameStreamOverlayProps {
networkId: string; networkId: string;
streamParticle: Particle & { type: "stream" }; streamParticle: Particle & { type: 'stream' };
onClose: () => void; onClose: () => void;
} }
@@ -18,23 +18,23 @@ export function RenameStreamOverlay({
streamParticle, streamParticle,
onClose, onClose,
}: RenameStreamOverlayProps) { }: RenameStreamOverlayProps) {
useSuspendPlayback(true, "rename-stream"); useSuspendPlayback(true, 'rename-stream');
const [name, setName] = useState(streamParticle.properties.name); const [name, setName] = useState(streamParticle.properties.name);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const trimmed = name.trim(); const trimmed = name.trim();
const canSave = const canSave =
!saving && !saving && trimmed.length > 0 && trimmed !== streamParticle.properties.name;
trimmed.length > 0 &&
trimmed !== streamParticle.properties.name;
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!canSave) return; if (!canSave) return;
setSaving(true); setSaving(true);
try { try {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id])); const docPath = toFirestoreDocPath(
await updateParticleProperties<"stream">(docPath, { name: trimmed }); particlePath(networkId, [streamParticle.id]),
);
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
onClose(); onClose();
} finally { } finally {
setSaving(false); setSaving(false);
@@ -43,14 +43,15 @@ export function RenameStreamOverlay({
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onClose(); onClose();
} }
}; };
window.addEventListener("keydown", handler, { capture: true }); window.addEventListener('keydown', handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true }); return () =>
window.removeEventListener('keydown', handler, { capture: true });
}, [onClose]); }, [onClose]);
return createPortal( return createPortal(
@@ -65,7 +66,7 @@ export function RenameStreamOverlay({
<span className="text-xs text-white/30"> <span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" "} </kbd>{' '}
to close to close
</span> </span>
</div> </div>
@@ -77,7 +78,7 @@ export function RenameStreamOverlay({
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onFocus={(e) => e.currentTarget.select()} onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
handleSave(); handleSave();
} }
+154 -144
View File
@@ -1,169 +1,179 @@
import { forwardRef, useMemo } from "react"; import { forwardRef, useMemo } from 'react';
import { Headphones } from "lucide-react"; import { Headphones } from 'lucide-react';
import { cn, getInitials } from "@/lib/utils"; import { cn, getInitials } from '@/lib/utils';
import { useLiveLatestChild } from "@/hooks/use-particle"; import { useLiveLatestChild } from '@/hooks/use-particle';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { particlePath } from "@/lib/particle-path"; import { particlePath } from '@/lib/particle-path';
import type { Particle, StreamProperties } from "@/api/types"; import type { Particle, StreamProperties } from '@/api/types';
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay"; import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
import { ParticlePreview } from "@/features/particles/particle-preview"; import { ParticlePreview } from '@/features/particles/particle-preview';
import { useNetwork } from "@/hooks/use-networks"; import { useNetwork } from '@/hooks/use-networks';
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { RelativeTimestamp } from "@/components/relative-timestamp"; import { RelativeTimestamp } from '@/components/relative-timestamp';
import { Small } from "@/components/ui/typography"; import { Small } from '@/components/ui/typography';
interface StreamCardProps { interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties }; particle: Particle & { type: 'stream'; properties: StreamProperties };
networkId: string; networkId: string;
onClick: () => void; onClick: () => void;
isSelected?: boolean; isSelected?: boolean;
shortcutKey?: number; shortcutKey?: number;
} }
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function StreamCard({ particle, networkId, onClick, isSelected, shortcutKey }, ref) { export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
const streamPath = particlePath(networkId, [particle.id]); function StreamCard(
const { latestChild } = useLiveLatestChild(streamPath); { particle, networkId, onClick, isSelected, shortcutKey },
const userId = useAuthStore((s) => s.user?.id) ?? ""; ref,
const network = useNetwork(networkId); ) {
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 = const hasActiveHuddle =
particle.huddle_active_participants && particle.huddle_active_participants.length > 0; particle.huddle_active_participants &&
const huddleCount = particle.huddle_active_participants?.length ?? 0; particle.huddle_active_participants.length > 0;
const huddleCount = particle.huddle_active_participants?.length ?? 0;
const isDM = const isDM =
particle.visible_to.length === 2 && particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:")); particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => { const initials = useMemo(() => {
if (isDM) { if (isDM) {
const otherEntry = particle.visible_to.find( const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`, (v) => v !== `human:${userId}`,
); );
if (otherEntry) { if (otherEntry) {
const otherId = otherEntry.replace("human:", ""); const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId); const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email); if (otherHuman) return getInitials(otherHuman.email);
}
} }
}
if (latestChild) { if (latestChild) {
const creator = network?.humans?.find( const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id, (h) => h.id === latestChild.created_by_human_id,
); );
if (creator) return getInitials(creator.email); if (creator) return getInitials(creator.email);
} }
return particle.properties.name.slice(0, 2).toUpperCase(); return particle.properties.name.slice(0, 2).toUpperCase();
}, [ }, [
isDM, isDM,
particle.visible_to, particle.visible_to,
particle.properties.name, particle.properties.name,
userId, userId,
latestChild, latestChild,
network, network,
]); ]);
const isUnseen = useMemo(() => { const isUnseen = useMemo(() => {
if (!latestChild) return false; if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime(); const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition = const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0; particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition; return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]); }, [latestChild, particle.playback_markers, userId]);
// For media particles with a transcript, show it as an overlay on the preview // For media particles with a transcript, show it as an overlay on the preview
const transcript = const transcript =
latestChild?.type === "media" latestChild?.type === 'media'
? latestChild.properties.transcript?.transcript ? latestChild.properties.transcript?.transcript
: undefined; : undefined;
return ( return (
<div <div
ref={ref} ref={ref}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onClick(); if (e.key === 'Enter' || e.key === ' ') onClick();
}} }}
className={cn( className={cn(
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20", 'cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20',
isUnseen && "ring-2 ring-primary", isUnseen && 'ring-2 ring-primary',
isSelected && "ring-2 ring-ring", isSelected && 'ring-2 ring-ring',
hasActiveHuddle && "ring-2 ring-red-500/70", 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" />
)} )}
{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"> {/* Preview area */}
{shortcutKey} <div className="relative aspect-[4/3] overflow-hidden bg-muted">
</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">
{hasActiveHuddle && ( {hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5"> <div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
</span>
)} )}
{latestChild && ( {shortcutKey && (
<Small <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">
className={cn( {shortcutKey}
"text-[10px]", </kbd>
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)} )}
{isUnseen && ( {latestChild ? (
<span className="size-2 shrink-0 rounded-full bg-primary" /> <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> </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>
</div> );
); },
}); );
@@ -3,11 +3,11 @@ import {
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from '@/components/ui/context-menu';
import { CircleCheckBig, CircleDot } from "lucide-react"; import { CircleCheckBig, CircleDot } from 'lucide-react';
import { updateStreamStatus } from "@/lib/firestore-particles"; import { updateStreamStatus } from '@/lib/firestore-particles';
import { toFirestoreDocPath, particlePath } from "@/lib/particle-path"; import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
import type { StreamParticle } from "@/hooks/use-stream-particles"; import type { StreamParticle } from '@/hooks/use-stream-particles';
interface StreamContextMenuProps { interface StreamContextMenuProps {
particle: StreamParticle; particle: StreamParticle;
@@ -15,12 +15,16 @@ interface StreamContextMenuProps {
children: React.ReactNode; children: React.ReactNode;
} }
export function StreamContextMenu({ particle, networkId, children }: StreamContextMenuProps) { export function StreamContextMenu({
const isOpen = particle.status === "open"; particle,
networkId,
children,
}: StreamContextMenuProps) {
const isOpen = particle.status === 'open';
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id])); const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
const toggleStatus = async () => { const toggleStatus = async () => {
await updateStreamStatus(docPath, isOpen ? "closed" : "open"); await updateStreamStatus(docPath, isOpen ? 'closed' : 'open');
}; };
return ( return (
@@ -1,24 +1,24 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from 'react';
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom';
import { X, UserPlus, Globe, Users, Lock } from "lucide-react"; import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
buildCustomVisibility, buildCustomVisibility,
buildNetworkVisibility, buildNetworkVisibility,
parseVisibleTo, parseVisibleTo,
} from "@/lib/stream-visibility"; } from '@/lib/stream-visibility';
import { updateParticleVisibleTo } from "@/lib/firestore-particles"; import { updateParticleVisibleTo } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { useNetwork } from "@/hooks/use-networks"; import { useNetwork } from '@/hooks/use-networks';
import { cn, getInitials } from "@/lib/utils"; import { cn, getInitials } from '@/lib/utils';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface StreamMembersOverlayProps { interface StreamMembersOverlayProps {
networkId: string; networkId: string;
streamParticle: Particle & { type: "stream" }; streamParticle: Particle & { type: 'stream' };
isCreator: boolean; isCreator: boolean;
onClose: () => void; onClose: () => void;
} }
@@ -29,7 +29,7 @@ export function StreamMembersOverlay({
isCreator, isCreator,
onClose, onClose,
}: StreamMembersOverlayProps) { }: StreamMembersOverlayProps) {
useSuspendPlayback(true, "stream-members"); useSuspendPlayback(true, 'stream-members');
const network = useNetwork(networkId); const network = useNetwork(networkId);
const humans = network?.humans ?? []; const humans = network?.humans ?? [];
@@ -45,7 +45,7 @@ export function StreamMembersOverlay({
); );
const memberIds = const memberIds =
visibility.mode === "network" visibility.mode === 'network'
? humans.map((h) => h.id) ? humans.map((h) => h.id)
: visibility.humanIds; : visibility.humanIds;
const memberSet = new Set(memberIds); const memberSet = new Set(memberIds);
@@ -61,7 +61,7 @@ export function StreamMembersOverlay({
const removeMember = useCallback( const removeMember = useCallback(
(id: string) => { (id: string) => {
if (visibility.mode !== "custom") return; if (visibility.mode !== 'custom') return;
if (id === creatorId) return; if (id === creatorId) return;
const next = visibility.humanIds.filter((x) => x !== id); const next = visibility.humanIds.filter((x) => x !== id);
if (next.length === 0) return; if (next.length === 0) return;
@@ -72,7 +72,7 @@ export function StreamMembersOverlay({
const addMember = useCallback( const addMember = useCallback(
(id: string) => { (id: string) => {
if (visibility.mode !== "custom") return; if (visibility.mode !== 'custom') return;
void updateParticleVisibleTo( void updateParticleVisibleTo(
docPath, docPath,
buildCustomVisibility([...visibility.humanIds, id]), buildCustomVisibility([...visibility.humanIds, id]),
@@ -83,14 +83,15 @@ export function StreamMembersOverlay({
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onClose(); onClose();
} }
}; };
window.addEventListener("keydown", handler, { capture: true }); window.addEventListener('keydown', handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true }); return () =>
window.removeEventListener('keydown', handler, { capture: true });
}, [onClose]); }, [onClose]);
return createPortal( return createPortal(
@@ -106,7 +107,7 @@ export function StreamMembersOverlay({
<span className="text-xs text-white/30"> <span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" "} </kbd>{' '}
to close to close
</span> </span>
</div> </div>
@@ -119,13 +120,13 @@ export function StreamMembersOverlay({
{isCreator ? ( {isCreator ? (
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1"> <div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
<VisibilityPill <VisibilityPill
active={visibility.mode === "network"} active={visibility.mode === 'network'}
icon={<Globe className="size-3.5" />} icon={<Globe className="size-3.5" />}
label="Network-wide" label="Network-wide"
onClick={setNetworkWide} onClick={setNetworkWide}
/> />
<VisibilityPill <VisibilityPill
active={visibility.mode === "custom"} active={visibility.mode === 'custom'}
icon={<Lock className="size-3.5" />} icon={<Lock className="size-3.5" />}
label="Specific people" label="Specific people"
onClick={setCustomOnlyCreator} onClick={setCustomOnlyCreator}
@@ -133,10 +134,10 @@ export function StreamMembersOverlay({
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 text-sm text-white/70"> <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" /> <Globe className="size-3.5 text-white/40" />
<span>Everyone in {network?.name ?? "network"}</span> <span>Everyone in {network?.name ?? 'network'}</span>
</> </>
) : ( ) : (
<> <>
@@ -151,7 +152,7 @@ export function StreamMembersOverlay({
{/* Member list */} {/* Member list */}
<section className="flex min-h-0 flex-1 flex-col"> <section className="flex min-h-0 flex-1 flex-col">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30"> <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> <span className="ml-1 text-white/20">{memberIds.length}</span>
</h3> </h3>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
@@ -160,7 +161,7 @@ export function StreamMembersOverlay({
const display = resolveHumanDisplay(id, humans); const display = resolveHumanDisplay(id, humans);
const isCreatorRow = id === creatorId; const isCreatorRow = id === creatorId;
const canRemove = const canRemove =
isCreator && visibility.mode === "custom" && !isCreatorRow; isCreator && visibility.mode === 'custom' && !isCreatorRow;
return ( return (
<li <li
key={id} key={id}
@@ -173,8 +174,8 @@ export function StreamMembersOverlay({
</Avatar> </Avatar>
<span <span
className={cn( className={cn(
"flex-1 truncate", 'flex-1 truncate',
!display.exists && "italic text-white/40", !display.exists && 'italic text-white/40',
)} )}
> >
{display.displayName} {display.displayName}
@@ -202,44 +203,50 @@ export function StreamMembersOverlay({
</section> </section>
{/* Add */} {/* Add */}
{isCreator && visibility.mode === "custom" && availableToAdd.length > 0 && ( {isCreator &&
<section className="mt-4 border-t border-white/5 pt-4"> visibility.mode === 'custom' &&
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30"> availableToAdd.length > 0 && (
<UserPlus className="size-3" /> <section className="mt-4 border-t border-white/5 pt-4">
Add people <h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
</h3> <UserPlus className="size-3" />
<ScrollArea className="max-h-32"> Add people
<ul className="flex flex-col gap-0.5 pr-2"> </h3>
{availableToAdd.map((human) => ( <ScrollArea className="max-h-32">
<li key={human.id}> <ul className="flex flex-col gap-0.5 pr-2">
<button {availableToAdd.map((human) => (
type="button" <li key={human.id}>
onClick={() => addMember(human.id)} <button
className={cn( type="button"
"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", 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)} <Avatar size="sm">
</AvatarFallback> <AvatarFallback className="text-[10px]">
</Avatar> {getInitials(human.email)}
<span className="flex-1 truncate">{human.email_prefix}</span> </AvatarFallback>
<UserPlus className="size-3.5 text-white/30" /> </Avatar>
</button> <span className="flex-1 truncate">
</li> {human.email_prefix}
))} </span>
</ul> <UserPlus className="size-3.5 text-white/30" />
</ScrollArea> </button>
</section> </li>
)} ))}
</ul>
</ScrollArea>
</section>
)}
{isCreator && visibility.mode === "custom" && availableToAdd.length === 0 && ( {isCreator &&
<p className="mt-4 text-center text-xs text-white/30"> visibility.mode === 'custom' &&
<Users className="mr-1 inline size-3" /> availableToAdd.length === 0 && (
Everyone in the network is already a member <p className="mt-4 text-center text-xs text-white/30">
</p> <Users className="mr-1 inline size-3" />
)} Everyone in the network is already a member
</p>
)}
</div> </div>
</div>, </div>,
document.body, document.body,
@@ -262,10 +269,10 @@ function VisibilityPill({
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( 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 active
? "bg-white/10 text-white/90" ? 'bg-white/10 text-white/90'
: "text-white/50 hover:text-white/80", : 'text-white/50 hover:text-white/80',
)} )}
> >
{icon} {icon}
@@ -7,15 +7,15 @@ import {
useRef, useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from 'react';
import { useChannel } from "@/hooks/use-channel"; import { useChannel } from '@/hooks/use-channel';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type ComposingMode = "recording" | "typing" | "screen"; export type ComposingMode = 'recording' | 'typing' | 'screen';
export interface ComposingUser { export interface ComposingUser {
humanId: string; humanId: string;
@@ -92,14 +92,14 @@ export function StreamPresenceProvider({
// Skip own events // Skip own events
if (msg.humanId === currentUserId) continue; if (msg.humanId === currentUserId) continue;
if (payload.type === "composing_start" && payload.mode) { if (payload.type === 'composing_start' && payload.mode) {
map.set(msg.humanId, { map.set(msg.humanId, {
humanId: msg.humanId, humanId: msg.humanId,
mode: payload.mode as ComposingMode, mode: payload.mode as ComposingMode,
lastSeen: Date.now(), lastSeen: Date.now(),
}); });
changed = true; changed = true;
} else if (payload.type === "composing_stop") { } else if (payload.type === 'composing_stop') {
if (map.delete(msg.humanId)) changed = true; if (map.delete(msg.humanId)) changed = true;
} }
} }
@@ -155,14 +155,14 @@ export function StreamPresenceProvider({
const startComposing = useCallback( const startComposing = useCallback(
(mode: ComposingMode) => { (mode: ComposingMode) => {
// Send immediately // Send immediately
sendMessage({ type: "composing_start", mode }); sendMessage({ type: 'composing_start', mode });
// Clear any existing heartbeat // Clear any existing heartbeat
clearInterval(heartbeatRef.current); clearInterval(heartbeatRef.current);
// Start heartbeat // Start heartbeat
heartbeatRef.current = setInterval(() => { heartbeatRef.current = setInterval(() => {
sendMessage({ type: "composing_start", mode }); sendMessage({ type: 'composing_start', mode });
}, COMPOSING_HEARTBEAT_MS); }, COMPOSING_HEARTBEAT_MS);
}, },
[sendMessage], [sendMessage],
@@ -171,7 +171,7 @@ export function StreamPresenceProvider({
const stopComposing = useCallback(() => { const stopComposing = useCallback(() => {
clearInterval(heartbeatRef.current); clearInterval(heartbeatRef.current);
heartbeatRef.current = undefined; heartbeatRef.current = undefined;
sendMessage({ type: "composing_stop" }); sendMessage({ type: 'composing_stop' });
}, [sendMessage]); }, [sendMessage]);
// Cleanup heartbeat on unmount // Cleanup heartbeat on unmount
@@ -206,7 +206,7 @@ function useStreamPresenceContext() {
const ctx = useContext(StreamPresenceContext); const ctx = useContext(StreamPresenceContext);
if (!ctx) { if (!ctx) {
throw new Error( throw new Error(
"useStreamPresence must be used within a StreamPresenceProvider", 'useStreamPresence must be used within a StreamPresenceProvider',
); );
} }
return ctx; return ctx;
@@ -1,47 +1,65 @@
import { useState } from "react"; import { useState } from 'react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from "@/api/types"; import { isParticleDeleted, type Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import {
import { useNetwork } from "@/hooks/use-networks"; Tooltip,
import { Button } from "@/components/ui/button"; TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useNetwork } from '@/hooks/use-networks';
import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from '@/components/ui/dropdown-menu';
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe, Trash2 } from "lucide-react"; import {
import { updateStreamStatus } from "@/lib/firestore-particles"; Settings,
import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay"; CircleCheckBig,
import { DeleteParticleOverlay } from "@/features/particles/delete-particle-overlay"; CircleDot,
import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay"; EllipsisVertical,
import { parseVisibleTo } from "@/lib/stream-visibility"; Pencil,
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; Globe,
import { WindowControls } from "@/components/window-controls"; Trash2,
import { RelativeTimestamp } from "@/components/relative-timestamp"; } from 'lucide-react';
import { useStreamPresence } from "@/features/particles/stream-presence-context"; import { updateStreamStatus } from '@/lib/firestore-particles';
import { resolveHumanDisplay } from "@/lib/humans"; import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
import { platform } from "@/lib/platform"; import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
import { requireDesktop } from "@/lib/platform/desktop-only"; 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 { function getParticleDisplayName(particle: Particle): string {
switch (particle.type) { switch (particle.type) {
case "stream": case 'stream':
case "folder": case 'folder':
return particle.properties.name; return particle.properties.name;
case "quest": case 'quest':
return particle.properties.title; return particle.properties.title;
case "paper": case 'paper':
return particle.properties.title; return particle.properties.title;
case "file": case 'file':
return particle.properties.filename; return particle.properties.filename;
case "text": case 'text':
return particle.properties.content.slice(0, 30); return particle.properties.content.slice(0, 30);
case "media": case 'media':
return particle.type; return particle.type;
} }
} }
@@ -49,7 +67,7 @@ function getParticleDisplayName(particle: Particle): string {
interface TopBarProps { interface TopBarProps {
networkId: string; networkId: string;
particle: Particle | null; particle: Particle | null;
streamParticle: Particle & { type: "stream" }; streamParticle: Particle & { type: 'stream' };
} }
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) { export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
@@ -65,18 +83,20 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
!!particle && !!particle &&
!!userId && !!userId &&
particle.created_by_human_id === userId && particle.created_by_human_id === userId &&
particle.type !== "stream" && particle.type !== 'stream' &&
particle.type !== "folder" && particle.type !== 'folder' &&
!isParticleDeleted(particle); !isParticleDeleted(particle);
const huddleParticipants = streamParticle.huddle_active_participants ?? []; const huddleParticipants = streamParticle.huddle_active_participants ?? [];
const hasActiveHuddle = huddleParticipants.length > 0; const hasActiveHuddle = huddleParticipants.length > 0;
const handleJoinHuddle = () => { const handleJoinHuddle = () => {
if (!requireDesktop("Huddle")) return; if (!requireDesktop('Huddle')) return;
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { apiClient
platform.huddle.open({ token, serverUrl: server_url }); .getLivekitToken(networkId, streamParticle.id)
}); .then(({ token, server_url }) => {
platform.huddle.open({ token, serverUrl: server_url });
});
}; };
return ( return (
@@ -88,7 +108,9 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
{streamParticle && ( {streamParticle && (
<> <>
<BreadcrumbItem className="text-xs"> <BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage> <BreadcrumbPage>
{getParticleDisplayName(streamParticle)}
</BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
</> </>
)} )}
@@ -97,7 +119,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
<> <>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem className="text-xs"> <BreadcrumbItem className="text-xs">
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage> <BreadcrumbPage>
<ParticleBreadcrumbContent
particle={particle}
networkId={networkId}
/>
</BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
</> </>
)} )}
@@ -134,7 +161,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</button> </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"> <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" /> <CircleCheckBig className="size-3" />
Closed Closed
@@ -160,11 +187,16 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
onSelect={async () => { onSelect={async () => {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id])); const docPath = toFirestoreDocPath(
await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open"); particlePath(networkId, [streamParticle.id]),
);
await updateStreamStatus(
docPath,
streamParticle.status === 'open' ? 'closed' : 'open',
);
}} }}
> >
{streamParticle.status === "open" ? ( {streamParticle.status === 'open' ? (
<> <>
<CircleCheckBig className="size-4" /> <CircleCheckBig className="size-4" />
Close stream Close stream
@@ -191,7 +223,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
Delete particle Delete particle
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem onSelect={() => navigate("/settings")}> <DropdownMenuItem onSelect={() => navigate('/settings')}>
<Settings className="size-4" /> <Settings className="size-4" />
Settings Settings
</DropdownMenuItem> </DropdownMenuItem>
@@ -234,7 +266,7 @@ function MembersIndicator({
onClick, onClick,
}: { }: {
networkId: string; networkId: string;
streamParticle: Particle & { type: "stream" }; streamParticle: Particle & { type: 'stream' };
onClick: () => void; onClick: () => void;
}) { }) {
const network = useNetwork(networkId); const network = useNetwork(networkId);
@@ -242,7 +274,7 @@ function MembersIndicator({
const humans = network?.humans ?? []; const humans = network?.humans ?? [];
const memberIds = const memberIds =
visibility.mode === "network" visibility.mode === 'network'
? humans.map((h) => h.id) ? humans.map((h) => h.id)
: visibility.humanIds; : visibility.humanIds;
const shownMembers = memberIds const shownMembers = memberIds
@@ -258,7 +290,7 @@ function MembersIndicator({
onClick={onClick} 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" 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" /> <Globe className="size-3 text-white/50" />
<span>Everyone</span> <span>Everyone</span>
@@ -274,32 +306,43 @@ function MembersIndicator({
</Avatar> </Avatar>
))} ))}
</AvatarGroup> </AvatarGroup>
{overflow > 0 && <span className="text-white/50">+{overflow}</span>} {overflow > 0 && (
<span className="text-white/50">+{overflow}</span>
)}
</> </>
)} )}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{visibility.mode === "network" {visibility.mode === 'network'
? `Everyone in ${network?.name ?? "network"}` ? `Everyone in ${network?.name ?? 'network'}`
: `${memberIds.length} ${memberIds.length === 1 ? "member" : "members"}`} : `${memberIds.length} ${memberIds.length === 1 ? 'member' : 'members'}`}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
} }
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) { function ParticleBreadcrumbContent({
particle,
networkId,
}: {
particle: Particle;
networkId: string;
}) {
const network = useNetwork(networkId); const network = useNetwork(networkId);
const { onlineHumanIds } = useStreamPresence(); const { onlineHumanIds } = useStreamPresence();
const display = resolveHumanDisplay(particle.created_by_human_id, network?.humans); const display = resolveHumanDisplay(
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false; particle.created_by_human_id,
network?.humans,
);
const isOnline = particle.created_by_human_id
? onlineHumanIds.has(particle.created_by_human_id)
: false;
return ( return (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}> <Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
<AvatarFallback> <AvatarFallback>{display.initials}</AvatarFallback>
{display.initials}
</AvatarFallback>
</Avatar> </Avatar>
{display.displayName} - <RelativeTimestamp date={particle.created_at} /> {display.displayName} - <RelativeTimestamp date={particle.created_at} />
</span> </span>
+180 -119
View File
@@ -1,41 +1,73 @@
import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react"; import {
import { useNavigate } from "react-router-dom"; useState,
import { useAuthStore } from "@/stores/auth-store"; useEffect,
import { apiClient } from "@/api/client"; useEffectEvent,
import { isParticleDeleted, type Particle } from "@/api/types"; useCallback,
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; useRef,
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay"; } from 'react';
import { useComposeIntentStore } from "@/stores/compose-intent-store"; import { useNavigate } from 'react-router-dom';
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator"; import { useAuthStore } from '@/stores/auth-store';
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view"; import { apiClient } from '@/api/client';
import { TextParticleView } from "@/features/particles/text-particle-view"; import { isParticleDeleted, type Particle } from '@/api/types';
import { FallbackParticleView } from "@/features/particles/fallback-particle-view"; import {
import { DeletedParticleView } from "@/features/particles/deleted-particle-view"; parseParticlePath,
import { VideoAudioToggle } from "@/components/video-audio-toggle"; particlePath,
import { useMediaSettingsStore } from "@/stores/media-settings-store"; toFirestoreDocPath,
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay"; type ParticlePath,
import { useNetwork } from "@/hooks/use-networks"; } from '@/lib/particle-path';
import { toggleParticleReaction } from "@/lib/firestore-particles"; import {
import { ReactionBar } from "@/features/particles/reaction-bar"; ComposeOverlay,
import { TextReactionInput } from "@/features/particles/text-reaction-input"; type ComposeStep,
import { TopBar } from "@/features/particles/stream-top-bar"; } from '@/features/compose/compose-overlay';
import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media"; import { PlaybackPageIndicator } from '@/features/particles/playback-page-indicator';
import { usePresencePositions } from "@/hooks/use-presence-positions"; import {
import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStreamComposingBroadcast, type ComposingMode } from "@/features/particles/stream-presence-context"; MediaParticleView,
import { ComposingIndicator } from "@/components/composing-indicator"; type MediaParticleHandle,
import { cn } from "@/lib/utils"; } from '@/features/particles/media-particle-view';
import { useMount } from "react-use"; import { TextParticleView } from '@/features/particles/text-particle-view';
import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-store"; import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { usePlaybackKeys } from "@/hooks/use-playback-keys"; import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys"; import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { useStreamActionKeys } from "@/hooks/use-stream-action-keys"; import { useMediaSettingsStore } from '@/stores/media-settings-store';
import { platform } from "@/lib/platform"; import {
import { requireDesktop } from "@/lib/platform/desktop-only"; 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 (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; return undefined;
} }
@@ -44,7 +76,7 @@ function getReactions(particle: Particle): Record<string, string[]> | undefined
const EXIT_DELAY_MS = 5000; const EXIT_DELAY_MS = 5000;
const EXIT_TICK_MS = 100; const EXIT_TICK_MS = 100;
type PlaybackStatus = "idle" | "playing" | "ended"; type PlaybackStatus = 'idle' | 'playing' | 'ended';
function useExitCountdown( function useExitCountdown(
status: PlaybackStatus, status: PlaybackStatus,
@@ -52,7 +84,7 @@ function useExitCountdown(
onExit: () => void, onExit: () => void,
) { ) {
const [remainingMs, setRemainingMs] = useState<number | null>( const [remainingMs, setRemainingMs] = useState<number | null>(
status === "ended" ? EXIT_DELAY_MS : null, status === 'ended' ? EXIT_DELAY_MS : null,
); );
const [prevStatus, setPrevStatus] = useState(status); const [prevStatus, setPrevStatus] = useState(status);
@@ -63,7 +95,7 @@ function useExitCountdown(
// Start the countdown when playback ends; cancel it otherwise. // Start the countdown when playback ends; cancel it otherwise.
if (status !== prevStatus) { if (status !== prevStatus) {
setPrevStatus(status); setPrevStatus(status);
setRemainingMs(status === "ended" ? EXIT_DELAY_MS : null); setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null);
} }
const isCountingDown = remainingMs !== null && remainingMs > 0; const isCountingDown = remainingMs !== null && remainingMs > 0;
@@ -97,36 +129,36 @@ function useExitCountdown(
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
{ {
label: "Navigation", label: 'Navigation',
bindings: [ bindings: [
{ keys: ["←", "→", "↑", "↓"], description: "Previous / next particle" }, { keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
{ keys: ["Esc"], description: "Back to network" }, { keys: ['Esc'], description: 'Back to network' },
], ],
}, },
{ {
label: "Playback", label: 'Playback',
bindings: [ bindings: [
{ keys: ["Space"], description: "Toggle pause" }, { keys: ['Space'], description: 'Toggle pause' },
{ keys: ["Hold", "Space"], description: "Pause while held" }, { keys: ['Hold', 'Space'], description: 'Pause while held' },
{ keys: ["Hold", "Shift"], description: "1.5× speed" }, { keys: ['Hold', 'Shift'], description: '1.5× speed' },
{ keys: ["Shift", "←", "→"], description: "Seek ±5s" }, { keys: ['Shift', '←', '→'], description: 'Seek ±5s' },
], ],
}, },
{ {
label: "Compose", label: 'Compose',
bindings: [ bindings: [
{ keys: ["Hold", "`"], description: "Reply" }, { keys: ['Hold', '`'], description: 'Reply' },
{ keys: ["S"], description: "Screen record" }, { keys: ['S'], description: 'Screen record' },
{ keys: ["T"], description: "Text compose" }, { keys: ['T'], description: 'Text compose' },
{ keys: ["V"], description: "Toggle video / audio" }, { keys: ['V'], description: 'Toggle video / audio' },
{ keys: ["H"], description: "Join huddle" }, { keys: ['H'], description: 'Join huddle' },
], ],
}, },
{ {
label: "Reactions", label: 'Reactions',
bindings: [ bindings: [
{ keys: ["1-7"], description: "Toggle emoji reaction" }, { keys: ['1-7'], description: 'Toggle emoji reaction' },
{ keys: ["R"], description: "Quick text reply" }, { keys: ['R'], description: 'Quick text reply' },
], ],
}, },
]; ];
@@ -134,7 +166,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
// --- StreamView --- // --- StreamView ---
interface StreamViewProps { interface StreamViewProps {
streamParticle: Particle & { type: "stream" }; streamParticle: Particle & { type: 'stream' };
path: ParticlePath; path: ParticlePath;
} }
@@ -164,7 +196,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
next, next,
prev, prev,
goTo, goTo,
goToParticle goToParticle,
} = useStreamPlayback(streamParticle, path); } = useStreamPlayback(streamParticle, path);
usePrefetchAdjacentMedia(children, currentIndex); usePrefetchAdjacentMedia(children, currentIndex);
@@ -187,31 +219,43 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const mediaRef = useRef<MediaParticleHandle>(null); const mediaRef = useRef<MediaParticleHandle>(null);
const handleToggleReaction = useCallback((emoji: string) => { const handleToggleReaction = useCallback(
if (!authedUser || !currentParticle) return; (emoji: string) => {
if (isParticleDeleted(currentParticle)) return; 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 const reactions = getReactions(currentParticle);
? toFirestoreDocPath(particlePath(networkId, [streamParticle.id, currentParticle.id])) toggleParticleReaction(
: null; currentParticleDocPath,
if (!currentParticleDocPath) return; emoji,
authedUser.id,
const reactions = getReactions(currentParticle); reactions,
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions); );
}, [authedUser, currentParticle, networkId, streamParticle.id]); },
[authedUser, currentParticle, networkId, streamParticle.id],
);
const [composeActive, setComposeActive] = useState(false); const [composeActive, setComposeActive] = useState(false);
const [composeStep, setComposeStep] = useState<ComposeStep>("idle"); const [composeStep, setComposeStep] = useState<ComposeStep>('idle');
const paused = usePlaybackPauseStore(selectIsPaused); const paused = usePlaybackPauseStore(selectIsPaused);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id); const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
const [showKeybindings, setShowKeybindings] = useState(false); const [showKeybindings, setShowKeybindings] = useState(false);
const [textReactionOpen, setTextReactionOpen] = useState(false); const [textReactionOpen, setTextReactionOpen] = useState(false);
const handleSubmitTextReaction = useCallback((text: string) => { const handleSubmitTextReaction = useCallback(
handleToggleReaction(text); (text: string) => {
}, [handleToggleReaction]); handleToggleReaction(text);
},
[handleToggleReaction],
);
const { fastPlayback } = usePlaybackKeys({ mediaRef }); const { fastPlayback } = usePlaybackKeys({ mediaRef });
@@ -224,15 +268,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
}); });
const handleOpenHuddle = useCallback(() => { const handleOpenHuddle = useCallback(() => {
if (!requireDesktop("Huddle")) return; if (!requireDesktop('Huddle')) return;
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { apiClient
platform.huddle.open({ token, serverUrl: server_url }); .getLivekitToken(networkId, streamParticle.id)
}); .then(({ token, server_url }) => {
platform.huddle.open({ token, serverUrl: server_url });
});
navigate(`/${networkId}`); navigate(`/${networkId}`);
}, [networkId, streamParticle.id, navigate]); }, [networkId, streamParticle.id, navigate]);
const handleToggleRecordingMode = useCallback(() => { const handleToggleRecordingMode = useCallback(() => {
setRecordingMode(recordingMode === "video" ? "audio" : "video"); setRecordingMode(recordingMode === 'video' ? 'audio' : 'video');
}, [recordingMode, setRecordingMode]); }, [recordingMode, setRecordingMode]);
const handleToggleKeybindings = useCallback(() => { const handleToggleKeybindings = useCallback(() => {
@@ -252,11 +298,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const stepToMode: Record<string, ComposingMode | null> = { const stepToMode: Record<string, ComposingMode | null> = {
idle: null, idle: null,
submitting: null, submitting: null,
recording: "recording", recording: 'recording',
typing: "typing", typing: 'typing',
reviewing: "typing", reviewing: 'typing',
configuring: "typing", configuring: 'typing',
picking: "screen", picking: 'screen',
}; };
const mode = stepToMode[composeStep] ?? null; const mode = stepToMode[composeStep] ?? null;
if (mode) { if (mode) {
@@ -277,17 +323,13 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
useEffect(() => () => clearTimeout(idleTimerRef.current), []); useEffect(() => () => clearTimeout(idleTimerRef.current), []);
// Always show controls when compose is active or exit countdown is visible // 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(() => { const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`); navigate(`/${networkId}`);
}, [navigate, networkId]); }, [navigate, networkId]);
const exitRemainingMs = useExitCountdown( const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
status,
paused,
handleExitNavigate,
);
// Reset progress when the particle changes. // Reset progress when the particle changes.
if (currentParticle?.id !== prevParticleId) { if (currentParticle?.id !== prevParticleId) {
@@ -295,18 +337,21 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
setProgress(0); setProgress(0);
} }
const handleParticleCreated = useCallback((particleId: string) => { const handleParticleCreated = useCallback(
if (currentIndex === -1) return; (particleId: string) => {
if (currentIndex === -1) return;
// When local user is at children.length - 1, and they send a new particle, // 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. // 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), // 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. // 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. // 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) { if (currentIndex === children.length - 1) {
goToParticle(particleId); goToParticle(particleId);
} }
}, [children, goToParticle, currentIndex]); },
[children, goToParticle, currentIndex],
);
if (children.length === 0) { if (children.length === 0) {
return ( return (
@@ -322,7 +367,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
networkId={networkId} networkId={networkId}
targetPath={path} targetPath={path}
onActiveChange={setComposeActive} onActiveChange={setComposeActive}
disabled={streamParticle.status === "closed"} disabled={streamParticle.status === 'closed'}
onParticleCreated={handleParticleCreated} onParticleCreated={handleParticleCreated}
/> />
</div> </div>
@@ -343,7 +388,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
); );
} }
switch (particle.type) { switch (particle.type) {
case "media": case 'media':
return ( return (
<MediaParticleView <MediaParticleView
ref={mediaRef} ref={mediaRef}
@@ -355,7 +400,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
onProgress={setProgress} onProgress={setProgress}
/> />
); );
case "text": case 'text':
return ( return (
<TextParticleView <TextParticleView
key={particle.id} key={particle.id}
@@ -367,7 +412,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
/> />
); );
default: default:
return <FallbackParticleView particle={particle} networkId={networkId} />; return (
<FallbackParticleView particle={particle} networkId={networkId} />
);
} }
} }
@@ -382,7 +429,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
{/* TopBar — always visible */} {/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2"> <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> </div>
{/* Main playback area */} {/* Main playback area */}
@@ -412,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"> <div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
<ReactionBar <ReactionBar
reactions={getReactions(currentParticle)} reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ""} currentHumanId={authedUser?.id ?? ''}
humans={network?.humans} humans={network?.humans}
onToggle={handleToggleReaction} onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)} onOpenTextReaction={() => setTextReactionOpen(true)}
@@ -426,14 +477,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
)} )}
{/* Composing indicator — left edge, always visible */} {/* Composing indicator — left edge, always visible */}
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} /> <ComposingIndicator
users={composingUsers}
networkHumans={network?.humans}
/>
<ComposeOverlay <ComposeOverlay
networkId={networkId} networkId={networkId}
targetPath={path} targetPath={path}
onActiveChange={setComposeActive} onActiveChange={setComposeActive}
onStepChange={setComposeStep} onStepChange={setComposeStep}
disabled={streamParticle.status === "closed"} disabled={streamParticle.status === 'closed'}
onParticleCreated={handleParticleCreated} onParticleCreated={handleParticleCreated}
/> />
@@ -479,16 +533,23 @@ function BottomBar({
current: number; current: number;
progress: number; progress: number;
onGoTo: (index: number) => void; 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>; onlineHumanIds: Set<string>;
exitRemainingMs: number | null; exitRemainingMs: number | null;
onOpenKeybindings: () => void; onOpenKeybindings: () => void;
}) { }) {
return ( return (
<div className={cn( <div
"absolute inset-x-0 bottom-0 z-10 transition-all duration-300", className={cn(
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none", '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 */} {/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator <PlaybackPageIndicator
total={total} total={total}
@@ -540,37 +601,37 @@ function StreamViewControls({
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc Esc
</kbd>{" "} </kbd>{' '}
back back
</span> </span>
)} )}
<VideoAudioToggle /> <VideoAudioToggle />
<button <button
type="button" type="button"
onClick={() => requestIntent("record")} onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80" className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with a recording (or hold `)" title="Reply with a recording (or hold `)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold ` Hold `
</kbd>{" "} </kbd>{' '}
to reply to reply
</button> </button>
<button <button
type="button" type="button"
onClick={() => requestIntent("text")} onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80" className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with text (or press T)" title="Reply with text (or press T)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T T
</kbd>{" "} </kbd>{' '}
text text
</button> </button>
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H H
</kbd>{" "} </kbd>{' '}
huddle huddle
</span> </span>
<kbd <kbd
@@ -1,18 +1,18 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom';
import { toast } from "sonner"; import { toast } from 'sonner';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { import {
particlePath, particlePath,
parseParticlePath, parseParticlePath,
toFirestoreDocPath, toFirestoreDocPath,
type ParticlePath, type ParticlePath,
} from "@/lib/particle-path"; } from '@/lib/particle-path';
import { editTextParticleContent } from "@/lib/firestore-particles"; import { editTextParticleContent } from '@/lib/firestore-particles';
import { TextEditor } from "@/features/compose/text-editor"; import { TextEditor } from '@/features/compose/text-editor';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
type TextParticle = Extract<Particle, { type: "text" }>; type TextParticle = Extract<Particle, { type: 'text' }>;
interface TextEditOverlayProps { interface TextEditOverlayProps {
particle: TextParticle; particle: TextParticle;
@@ -25,7 +25,7 @@ export function TextEditOverlay({
streamPath, streamPath,
onClose, onClose,
}: TextEditOverlayProps) { }: TextEditOverlayProps) {
useSuspendPlayback(true, "text-edit"); useSuspendPlayback(true, 'text-edit');
const [textContent, setTextContent] = useState(particle.properties.content); const [textContent, setTextContent] = useState(particle.properties.content);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -48,10 +48,17 @@ export function TextEditOverlay({
await editTextParticleContent(docPath, trimmed); await editTextParticleContent(docPath, trimmed);
onClose(); onClose();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save"); toast.error(err instanceof Error ? err.message : 'Failed to save');
setSaving(false); setSaving(false);
} }
}, [saving, textContent, particle.properties.content, particle.id, streamPath, onClose]); }, [
saving,
textContent,
particle.properties.content,
particle.id,
streamPath,
onClose,
]);
return createPortal( return createPortal(
// React synthetic events bubble through the React tree (not the DOM tree), // React synthetic events bubble through the React tree (not the DOM tree),
@@ -1,22 +1,25 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { Pencil } from "lucide-react"; import { Pencil } from 'lucide-react';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import type { ParticlePath } from "@/lib/particle-path"; import type { ParticlePath } from '@/lib/particle-path';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata"; import {
import { extractUrls } from "@/lib/link-metadata"; useAllLinkMetadata,
type LinkPreviewEntry,
} from '@/hooks/use-link-metadata';
import { extractUrls } from '@/lib/link-metadata';
import { import {
LinkPreviewCard, LinkPreviewCard,
LinkPreviewCardSkeleton, LinkPreviewCardSkeleton,
} from "@/components/link-preview-card"; } from '@/components/link-preview-card';
import { useParticleAttachments } from "@/hooks/use-particle-attachments"; import { useParticleAttachments } from '@/hooks/use-particle-attachments';
import { ParticleAttachments } from "@/features/particles/particle-attachments"; import { ParticleAttachments } from '@/features/particles/particle-attachments';
import { TextEditOverlay } from "@/features/particles/text-edit-overlay"; import { TextEditOverlay } from '@/features/particles/text-edit-overlay';
import { RelativeTimestamp } from "@/components/relative-timestamp"; import { RelativeTimestamp } from '@/components/relative-timestamp';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { MarkdownEditor } from "@/features/compose/markdown-editor"; import { MarkdownEditor } from '@/features/compose/markdown-editor';
type TextParticle = Extract<Particle, { type: "text" }>; type TextParticle = Extract<Particle, { type: 'text' }>;
interface TextParticleViewProps { interface TextParticleViewProps {
particle: TextParticle; particle: TextParticle;
@@ -43,18 +46,21 @@ function computeReadDuration(
attachmentCount: number, attachmentCount: number,
): number { ): number {
const base = (text.length / CHARS_PER_MINUTE) * 60; 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); return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
} }
function getImmersiveTextStyle(length: number) { function getImmersiveTextStyle(length: number) {
if (length < 30) return { size: "text-5xl", weight: "font-semibold" }; if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' };
if (length < 70) return { size: "text-3xl", weight: "font-semibold" }; if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' };
return { size: "text-2xl", weight: "font-normal" }; return { size: 'text-2xl', weight: 'font-normal' };
} }
function hasMarkdownFormatting(content: string): boolean { 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[] }) { function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
@@ -93,7 +99,11 @@ export function TextParticleView({
const hasAttachments = attachments.length > 0; const hasAttachments = attachments.length > 0;
const hasEnrichments = hasLinks || hasAttachments; const hasEnrichments = hasLinks || hasAttachments;
const durationS = computeReadDuration(content, urls.length, attachments.length); const durationS = computeReadDuration(
content,
urls.length,
attachments.length,
);
const elapsedRef = useRef(0); const elapsedRef = useRef(0);
// Reset elapsed when particle changes // Reset elapsed when particle changes
@@ -120,8 +130,10 @@ export function TextParticleView({
// Content is just bare URLs with no surrounding text // Content is just bare URLs with no surrounding text
const contentTrimmed = content.trim(); const contentTrimmed = content.trim();
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) && const linksOnly =
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === ""; hasLinks &&
urls.every((url) => contentTrimmed.includes(url)) &&
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, '').trim() === '';
const editButton = isCreator && !isEditing && ( const editButton = isCreator && !isEditing && (
<button <button
@@ -170,13 +182,17 @@ export function TextParticleView({
} }
// Mode 2: short plain text, no enrichments — immersive centered display // 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); const style = getImmersiveTextStyle(content.length);
return ( 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)]"> <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 <p
className={cn( 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.size,
style.weight, 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="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 <div
className={cn( 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", '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]:w-2',
"[&::-webkit-scrollbar]:p-2", '[&::-webkit-scrollbar]:p-2',
"[&::-webkit-scrollbar-track]:bg-transparent", '[&::-webkit-scrollbar-track]:bg-transparent',
"[&::-webkit-scrollbar-thumb]:rounded-full", '[&::-webkit-scrollbar-thumb]:rounded-full',
"[&::-webkit-scrollbar-thumb]:bg-white/30", '[&::-webkit-scrollbar-thumb]:bg-white/30',
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50", '[&::-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} />} {hasLinks && <LinkPreviews entries={linkPreviews} />}
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { Send } from "lucide-react"; import { Send } from 'lucide-react';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { sanitizeReactionText } from "@/lib/firestore-particles"; import { sanitizeReactionText } from '@/lib/firestore-particles';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const MAX_LENGTH = 40; const MAX_LENGTH = 40;
@@ -12,17 +12,21 @@ interface TextReactionInputProps {
onClose: () => void; onClose: () => void;
} }
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) { export function TextReactionInput({
const [value, setValue] = useState(""); open,
onSubmit,
onClose,
}: TextReactionInputProps) {
const [value, setValue] = useState('');
const [prevOpen, setPrevOpen] = useState(open); const [prevOpen, setPrevOpen] = useState(open);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useSuspendPlayback(open, "text-reaction"); useSuspendPlayback(open, 'text-reaction');
// Clear the input each time the popup opens. // Clear the input each time the popup opens.
if (open !== prevOpen) { if (open !== prevOpen) {
setPrevOpen(open); setPrevOpen(open);
if (open) setValue(""); if (open) setValue('');
} }
useEffect(() => { useEffect(() => {
@@ -56,10 +60,10 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
} }
onBlur={onClose} onBlur={onClose}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
} else if (e.key === "Escape") { } else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
onClose(); onClose();
} }
@@ -70,8 +74,8 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
/> />
<span <span
className={cn( className={cn(
"min-w-[1.5ch] text-right text-[10px] tabular-nums", 'min-w-[1.5ch] text-right text-[10px] tabular-nums',
remaining <= 8 ? "text-amber-300/80" : "text-white/30", remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
)} )}
> >
{remaining} {remaining}
@@ -81,10 +85,10 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}
className={cn( 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 canSubmit
? "bg-white/20 text-white hover:bg-white/30" ? 'bg-white/20 text-white hover:bg-white/30'
: "text-white/30", : 'text-white/30',
)} )}
aria-label="Send reaction" aria-label="Send reaction"
> >
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from 'react';
import type { Transcript } from "@/api/types"; import type { Transcript } from '@/api/types';
type Sentence = Transcript["paragraphs"][number]["sentences"][number]; type Sentence = Transcript['paragraphs'][number]['sentences'][number];
type Word = Transcript["words"][number]; type Word = Transcript['words'][number];
const CHUNK_SIZE = 9; const CHUNK_SIZE = 9;
@@ -78,10 +78,13 @@ export function TranscriptOverlay({
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null; if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
return ( return (
<div className={centered <div
? "absolute inset-0 flex items-center justify-center px-6" className={
: "absolute bottom-15 left-0 right-0 flex justify-center px-6" 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"> <p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
{activeChunk.map((word, i) => { {activeChunk.map((word, i) => {
const isSpoken = const isSpoken =
@@ -92,11 +95,11 @@ export function TranscriptOverlay({
key={`${word.start}-${i}`} key={`${word.start}-${i}`}
className={ className={
isSpoken isSpoken
? "text-white font-medium transition-colors duration-150" ? 'text-white font-medium transition-colors duration-150'
: "text-white/40 transition-colors duration-150" : 'text-white/40 transition-colors duration-150'
} }
> >
{i > 0 ? " " : ""} {i > 0 ? ' ' : ''}
{word.word} {word.word}
</span> </span>
); );
+40 -27
View File
@@ -1,21 +1,32 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { ChevronRight, LogOut, Info, Shield, Mail, Mic, LifeBuoy, FileText, Volume2, ArrowLeft } from "lucide-react"; import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; ChevronRight,
import { Separator } from "@/components/ui/separator"; LogOut,
import { Switch } from "@/components/ui/switch"; Info,
import { WindowControls } from "@/components/window-controls"; Shield,
import { Button } from "@/components/ui/button"; Mail,
import { Muted } from "@/components/ui/typography"; Mic,
import { ScrollArea } from "@/components/ui/scroll-area"; LifeBuoy,
import { CopyableEmail } from "@/components/copyable-email"; FileText,
import { useAuthStore } from "@/stores/auth-store"; Volume2,
import { useSoundEffectsStore } from "@/stores/sound-effects-store"; ArrowLeft,
import { apiClient } from "@/api/client"; } from 'lucide-react';
import { logError, toUserMessage } from "@/lib/errors"; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { toast } from "sonner"; import { Separator } from '@/components/ui/separator';
import { PRIVACY_URL, SUPPORT_EMAIL, TERMS_URL } from "@/lib/constants"; import { Switch } from '@/components/ui/switch';
import { platform } from "@/lib/platform"; 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 { interface SettingsRowProps {
icon: React.ReactNode; icon: React.ReactNode;
@@ -36,7 +47,7 @@ function SettingsRow({
<button <button
type="button" type="button"
onClick={onClick} 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"> <span className="text-muted-foreground flex size-5 items-center justify-center">
{icon} {icon}
@@ -85,21 +96,25 @@ export default function SettingsPage() {
const handleToggleEmailNotifications = async (checked: boolean) => { const handleToggleEmailNotifications = async (checked: boolean) => {
setEmailNotifications(checked); setEmailNotifications(checked);
useAuthStore.setState((state) => ({ useAuthStore.setState((state) => ({
user: state.user ? { ...state.user, email_notifications_enabled: checked } : null, user: state.user
? { ...state.user, email_notifications_enabled: checked }
: null,
})); }));
try { try {
await apiClient.updateSettings({ email_notifications_enabled: checked }); await apiClient.updateSettings({ email_notifications_enabled: checked });
} catch (err) { } catch (err) {
setEmailNotifications(!checked); setEmailNotifications(!checked);
useAuthStore.setState((state) => ({ 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)); 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 ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@@ -126,9 +141,7 @@ export default function SettingsPage() {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium"> <p className="truncate text-sm font-medium">{user?.email_prefix}</p>
{user?.email_prefix}
</p>
<Muted className="text-xs">{user?.email}</Muted> <Muted className="text-xs">{user?.email}</Muted>
</div> </div>
</div> </div>
@@ -170,7 +183,7 @@ export default function SettingsPage() {
<SettingsRow <SettingsRow
icon={<Mic className="size-4" />} icon={<Mic className="size-4" />}
label="Audio & Video" label="Audio & Video"
onClick={() => navigate("/settings/audio-video")} onClick={() => navigate('/settings/audio-video')}
/> />
</SettingsGroup> </SettingsGroup>
@@ -1,30 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { ArrowLeft, VideoOff } from "lucide-react"; import { ArrowLeft, VideoOff } from 'lucide-react';
import { WindowControls } from "@/components/window-controls"; import { WindowControls } from '@/components/window-controls';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from '@/components/ui/scroll-area';
import { Muted } from "@/components/ui/typography"; import { Muted } from '@/components/ui/typography';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from '@/components/ui/select';
import { AudioLevelBars } from "@/components/audio/audio-level-bars"; import { AudioLevelBars } from '@/components/audio/audio-level-bars';
import { useAudioSource } from "@/components/audio/use-audio-source"; import { useAudioSource } from '@/components/audio/use-audio-source';
import { useMediaDevices } from "@/hooks/use-media-devices"; import { useMediaDevices } from '@/hooks/use-media-devices';
import { import {
resolveEffectiveDeviceId, resolveEffectiveDeviceId,
isSavedDeviceAvailable, isSavedDeviceAvailable,
} from "@/hooks/use-effective-device-id"; } from '@/hooks/use-effective-device-id';
import { import {
useMediaDevicesStore, useMediaDevicesStore,
type SavedDevice, type SavedDevice,
} from "@/stores/media-devices-store"; } from '@/stores/media-devices-store';
const SYSTEM_DEFAULT = "__system_default__"; const SYSTEM_DEFAULT = '__system_default__';
function usePreviewStream( function usePreviewStream(
enabled: boolean, enabled: boolean,
@@ -75,7 +75,9 @@ function usePreviewStream(
.catch((err: unknown) => { .catch((err: unknown) => {
if (cancelled) return; if (cancelled) return;
setStream(null); setStream(null);
setError(err instanceof Error ? err.message : "Unable to access devices"); setError(
err instanceof Error ? err.message : 'Unable to access devices',
);
}); });
return () => { return () => {
@@ -89,7 +91,7 @@ function usePreviewStream(
function deviceLabel(d: MediaDeviceInfo, index: number): string { function deviceLabel(d: MediaDeviceInfo, index: number): string {
if (d.label) return d.label; if (d.label) return d.label;
const kind = d.kind === "audioinput" ? "Microphone" : "Camera"; const kind = d.kind === 'audioinput' ? 'Microphone' : 'Camera';
return `${kind} ${index + 1}`; return `${kind} ${index + 1}`;
} }
@@ -215,7 +217,7 @@ export default function AudioVideoSettingsPage() {
); );
const cameraAvailable = videoInputs.length > 0; const cameraAvailable = videoInputs.length > 0;
const permissionGranted = permissionState === "granted"; const permissionGranted = permissionState === 'granted';
const { stream, error: previewError } = usePreviewStream( const { stream, error: previewError } = usePreviewStream(
permissionGranted, permissionGranted,
@@ -288,7 +290,7 @@ export default function AudioVideoSettingsPage() {
saved={camera} saved={camera}
onChange={setCamera} onChange={setCamera}
placeholder={ placeholder={
videoInputs.length === 0 ? "No cameras found" : "System default" videoInputs.length === 0 ? 'No cameras found' : 'System default'
} }
/> />
<CameraPreview stream={permissionGranted ? stream : null} /> <CameraPreview stream={permissionGranted ? stream : null} />
@@ -299,7 +301,7 @@ export default function AudioVideoSettingsPage() {
)} )}
</div> </div>
{(previewError || (deviceError && permissionState === "denied")) && ( {(previewError || (deviceError && permissionState === 'denied')) && (
<Muted className="text-destructive text-[11px]"> <Muted className="text-destructive text-[11px]">
{previewError ?? deviceError} {previewError ?? deviceError}
</Muted> </Muted>
+3 -3
View File
@@ -1,7 +1,7 @@
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { getAuth } from "firebase/auth"; import { getAuth } from 'firebase/auth';
import { getFirestore } from "firebase/firestore"; import { getFirestore } from 'firebase/firestore';
import { appConfig } from "@/config/env"; import { appConfig } from '@/config/env';
export const firebaseApp = initializeApp(appConfig.firebase); export const firebaseApp = initializeApp(appConfig.firebase);
+5 -5
View File
@@ -1,10 +1,10 @@
import { useQuery, useMutation, skipToken } from "@tanstack/react-query"; import { useQuery, useMutation, skipToken } from '@tanstack/react-query';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import type { BillingCadence } from "@/api/types"; import type { BillingCadence } from '@/api/types';
export function useNetworkBilling(networkId: string | undefined) { export function useNetworkBilling(networkId: string | undefined) {
return useQuery({ return useQuery({
queryKey: ["network-billing", networkId], queryKey: ['network-billing', networkId],
queryFn: networkId queryFn: networkId
? () => apiClient.getNetworkBilling(networkId) ? () => apiClient.getNetworkBilling(networkId)
: skipToken, : skipToken,
@@ -12,7 +12,7 @@ export function useNetworkBilling(networkId: string | undefined) {
// from Stripe Checkout (webhook may land a second or two later). // from Stripe Checkout (webhook may land a second or two later).
// FIX: doesn't work with electron // FIX: doesn't work with electron
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchInterval: 10000 refetchInterval: 10000,
}); });
} }
+11 -11
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from 'react';
import { usePusherClient } from "@/lib/pusher-provider"; import { usePusherClient } from '@/lib/pusher-provider';
import type { ChannelMessage } from "@/lib/pusher-client"; import type { ChannelMessage } from '@/lib/pusher-client';
interface UseChannelResult { interface UseChannelResult {
/** Current set of humanIds present in the channel */ /** Current set of humanIds present in the channel */
@@ -51,16 +51,16 @@ export function useChannel(channelId: string | null): UseChannelResult {
setMessages((prev) => [...prev, { humanId, payload: msg.payload }]); setMessages((prev) => [...prev, { humanId, payload: msg.payload }]);
}; };
client.on(channelId, "subscribed", onSubscribed); client.on(channelId, 'subscribed', onSubscribed);
client.on(channelId, "join", onJoin); client.on(channelId, 'join', onJoin);
client.on(channelId, "leave", onLeave); client.on(channelId, 'leave', onLeave);
client.on(channelId, "message", onMessage); client.on(channelId, 'message', onMessage);
return () => { return () => {
client.off(channelId, "subscribed", onSubscribed); client.off(channelId, 'subscribed', onSubscribed);
client.off(channelId, "join", onJoin); client.off(channelId, 'join', onJoin);
client.off(channelId, "leave", onLeave); client.off(channelId, 'leave', onLeave);
client.off(channelId, "message", onMessage); client.off(channelId, 'message', onMessage);
client.unsubscribe(channelId); client.unsubscribe(channelId);
// Clear on teardown so a new channel doesn't briefly show stale data. // Clear on teardown so a new channel doesn't briefly show stale data.
setPresence([]); setPresence([]);
+24 -9
View File
@@ -1,14 +1,27 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createParticle, createStreamParticle } from "@/lib/firestore-particles"; import {
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types"; createParticle,
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path"; createStreamParticle,
import { QuotaExceededError } from "@/lib/errors"; } 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 { import {
isUsageExhausted, isUsageExhausted,
networkUsageQueryKey, networkUsageQueryKey,
useBumpNetworkUsage, useBumpNetworkUsage,
useInvalidateNetworkUsage, useInvalidateNetworkUsage,
} from "./use-network-usage"; } from './use-network-usage';
interface CreateParticleParams<T extends ParticleType = ParticleType> { interface CreateParticleParams<T extends ParticleType = ParticleType> {
// Path to which the new particle will be added as a child // 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 // Containers aren't counted server-side, so we block them here
if (!CONTAINER_TYPES.has(params.type)) { if (!CONTAINER_TYPES.has(params.type)) {
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId)); const cached = qc.getQueryData<NetworkUsage>(
networkUsageQueryKey(networkId),
);
if (isUsageExhausted(cached)) { if (isUsageExhausted(cached)) {
throw new QuotaExceededError(networkId); throw new QuotaExceededError(networkId);
} }
@@ -58,7 +73,7 @@ export function useCreateParticle() {
type CreateStreamParticleParams = { type CreateStreamParticleParams = {
networkId: string; networkId: string;
properties: ParticlePropertiesMap["stream"]; properties: ParticlePropertiesMap['stream'];
createdByHumanId: string; createdByHumanId: string;
visibleTo?: string[]; visibleTo?: string[];
}; };
@@ -74,6 +89,6 @@ export function useCreateStreamParticle() {
params.createdByHumanId, params.createdByHumanId,
params.visibleTo, params.visibleTo,
); );
} },
}); });
} }
+15 -12
View File
@@ -1,14 +1,17 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from 'react';
import { where } from "firebase/firestore"; import { where } from 'firebase/firestore';
import { useLiveParticleChildren } from "@/hooks/use-particle"; import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { particlePath } from "@/lib/particle-path"; import { particlePath } from '@/lib/particle-path';
import type { Particle, StreamProperties } from "@/api/types"; import type { Particle, StreamProperties } from '@/api/types';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties }; type StreamParticle = Particle & {
type: 'stream';
properties: StreamProperties;
};
const openStatusFilter = where("status", "==", "open"); const openStatusFilter = where('status', '==', 'open');
/** /**
* Self-contained hook that syncs the macOS dock badge with the count of * Self-contained hook that syncs the macOS dock badge with the count of
@@ -31,8 +34,8 @@ export function useDockBadge(networkId: string | undefined) {
const path = networkId ? particlePath(networkId, []) : undefined; const path = networkId ? particlePath(networkId, []) : undefined;
const { children } = useLiveParticleChildren(path, { const { children } = useLiveParticleChildren(path, {
orderByField: "last_child_created_at", orderByField: 'last_child_created_at',
orderDirection: "desc", orderDirection: 'desc',
visibilityScopes, visibilityScopes,
whereFilter: openStatusFilter, whereFilter: openStatusFilter,
}); });
@@ -40,7 +43,7 @@ export function useDockBadge(networkId: string | undefined) {
const unseenCount = useMemo(() => { const unseenCount = useMemo(() => {
if (!userId) return 0; if (!userId) return 0;
return children.filter((c): c is StreamParticle => { return children.filter((c): c is StreamParticle => {
if (c.type !== "stream") return false; if (c.type !== 'stream') return false;
const lastActivity = c.last_child_created_at?.getTime(); const lastActivity = c.last_child_created_at?.getTime();
if (!lastActivity) return false; if (!lastActivity) return false;
const marker = c.playback_markers?.[userId]?.getTime(); const marker = c.playback_markers?.[userId]?.getTime();
+3 -3
View File
@@ -1,9 +1,9 @@
import { useQuery, skipToken } from "@tanstack/react-query"; import { useQuery, skipToken } from '@tanstack/react-query';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
export function useDownloadUrl(objectId?: string) { export function useDownloadUrl(objectId?: string) {
return useQuery({ return useQuery({
queryKey: ["download-url", objectId], queryKey: ['download-url', objectId],
queryFn: objectId queryFn: objectId
? () => apiClient.getParticleDownloadUrl(objectId) ? () => apiClient.getParticleDownloadUrl(objectId)
: skipToken, : skipToken,
@@ -1,4 +1,4 @@
import type { SavedDevice } from "@/stores/media-devices-store"; import type { SavedDevice } from '@/stores/media-devices-store';
/** /**
* Resolves a saved device preference against the currently available * Resolves a saved device preference against the currently available
+12 -9
View File
@@ -1,11 +1,14 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from 'react';
interface UseFileInputOptions { interface UseFileInputOptions {
onFilesSelected: (files: File[]) => void; onFilesSelected: (files: File[]) => void;
enabled: boolean; enabled: boolean;
} }
export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions) { export function useFileInput({
onFilesSelected,
enabled,
}: UseFileInputOptions) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const dragCountRef = useRef(0); const dragCountRef = useRef(0);
@@ -19,14 +22,14 @@ export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions)
// Hidden file input element // Hidden file input element
useEffect(() => { useEffect(() => {
const input = document.createElement("input"); const input = document.createElement('input');
input.type = "file"; input.type = 'file';
input.multiple = true; input.multiple = true;
input.style.display = "none"; input.style.display = 'none';
input.addEventListener("change", () => { input.addEventListener('change', () => {
if (input.files?.length) { if (input.files?.length) {
onFilesRef.current(Array.from(input.files)); onFilesRef.current(Array.from(input.files));
input.value = ""; input.value = '';
} }
}); });
document.body.appendChild(input); document.body.appendChild(input);
@@ -53,8 +56,8 @@ export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions)
} }
}; };
window.addEventListener("paste", handlePaste); window.addEventListener('paste', handlePaste);
return () => window.removeEventListener("paste", handlePaste); return () => window.removeEventListener('paste', handlePaste);
}, [enabled]); }, [enabled]);
// Drag and drop handlers // Drag and drop handlers
+5 -5
View File
@@ -1,10 +1,10 @@
import { useQueries, useQuery, skipToken } from "@tanstack/react-query"; import { useQueries, useQuery, skipToken } from '@tanstack/react-query';
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata"; import { extractUrls, type LinkMetadata } from '@/lib/link-metadata';
import { platform } from "@/lib/platform"; import { platform } from '@/lib/platform';
export function useLinkMetadata(url: string | null) { export function useLinkMetadata(url: string | null) {
return useQuery<LinkMetadata | null>({ return useQuery<LinkMetadata | null>({
queryKey: ["link-metadata", url], queryKey: ['link-metadata', url],
queryFn: url ? () => platform.link.fetchMetadata(url) : skipToken, queryFn: url ? () => platform.link.fetchMetadata(url) : skipToken,
staleTime: Infinity, staleTime: Infinity,
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,
@@ -29,7 +29,7 @@ export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
const results = useQueries({ const results = useQueries({
queries: urls.map((url) => ({ queries: urls.map((url) => ({
queryKey: ["link-metadata", url], queryKey: ['link-metadata', url],
queryFn: () => platform.link.fetchMetadata(url), queryFn: () => platform.link.fetchMetadata(url),
staleTime: Infinity, staleTime: Infinity,
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,

Some files were not shown because too many files have changed in this diff Show More