infra: add linting and formatting for js projects #230
@@ -4,6 +4,21 @@
|
|||||||
"es6": true,
|
"es6": true,
|
||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"import/resolver": {
|
||||||
|
"typescript": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"caughtErrorsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
@@ -11,7 +26,8 @@
|
|||||||
"plugin:import/recommended",
|
"plugin:import/recommended",
|
||||||
"plugin:import/electron",
|
"plugin:import/electron",
|
||||||
"plugin:import/typescript",
|
"plugin:import/typescript",
|
||||||
"plugin:react-hooks/recommended"
|
"plugin:react-hooks/recommended",
|
||||||
|
"prettier"
|
||||||
],
|
],
|
||||||
"parser": "@typescript-eslint/parser"
|
"parser": "@typescript-eslint/parser"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"jsxSingleQuote": false
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { MakerRpm } from '@electron-forge/maker-rpm';
|
|||||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||||
|
import type { SignToolOptions } from '@electron/windows-sign';
|
||||||
|
|
||||||
// Windows code signing via Azure Trusted Signing. Dormant unless the workflow
|
// Windows code signing via Azure Trusted Signing. Dormant unless the workflow
|
||||||
// provisions the dlib + metadata file and exports these env vars, so local
|
// provisions the dlib + metadata file and exports these env vars, so local
|
||||||
@@ -16,7 +17,7 @@ import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
|||||||
const windowsSign = process.env.AZURE_METADATA_JSON_PATH ? {
|
const windowsSign = process.env.AZURE_METADATA_JSON_PATH ? {
|
||||||
debug: true,
|
debug: true,
|
||||||
signToolPath: process.env.SIGNTOOL_PATH,
|
signToolPath: process.env.SIGNTOOL_PATH,
|
||||||
hashes: ['sha256' as any],
|
hashes: ['sha256'] as SignToolOptions['hashes'],
|
||||||
timestampServer: 'http://timestamp.acs.microsoft.com',
|
timestampServer: 'http://timestamp.acs.microsoft.com',
|
||||||
signWithParams: [
|
signWithParams: [
|
||||||
'/v',
|
'/v',
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64",
|
"publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64",
|
||||||
"invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES",
|
"invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES",
|
||||||
"lint": "eslint --ext .ts,.tsx .",
|
"lint": "eslint --ext .ts,.tsx .",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||||
"compile": "npx tsc --noEmit 2>&1 | grep '^src/'",
|
"compile": "npx tsc --noEmit 2>&1 | grep '^src/'",
|
||||||
"web:dev": "cross-env APP_ENV=dev vite --config vite.web.config.mts",
|
"web:dev": "cross-env APP_ENV=dev vite --config vite.web.config.mts",
|
||||||
"web:build": "cross-env APP_ENV=prod vite build --config vite.web.config.mts",
|
"web:build": "cross-env APP_ENV=prod vite build --config vite.web.config.mts",
|
||||||
@@ -44,15 +46,17 @@
|
|||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "40.6.0",
|
"electron": "40.6.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.5",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.60.0",
|
||||||
"vite": "^5.4.21"
|
"vite": "^5.4.21"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+42
-25
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+72
-32
@@ -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(),
|
||||||
@@ -25,12 +25,12 @@ export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
|
|||||||
|
|
||||||
// --- Network request/response types ---
|
// --- Network request/response types ---
|
||||||
|
|
||||||
const CreateNetworkRequestSchema = z.object({
|
export const CreateNetworkRequestSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
||||||
|
|
||||||
const AddMembersRequestSchema = z.object({
|
export const AddMembersRequestSchema = z.object({
|
||||||
email_addresses: z.array(z.string().email()),
|
email_addresses: z.array(z.string().email()),
|
||||||
});
|
});
|
||||||
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
||||||
@@ -52,7 +52,7 @@ export type RevokeInvitationRequest = { email: string };
|
|||||||
|
|
||||||
// --- Depot types ---
|
// --- Depot types ---
|
||||||
|
|
||||||
const PrepareUploadRequestSchema = z.object({
|
export const PrepareUploadRequestSchema = z.object({
|
||||||
network_id: z.string(),
|
network_id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
content_type: z.string(),
|
content_type: 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,16 +283,18 @@ 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 ---
|
||||||
|
|
||||||
const RequestCodeRequestSchema = z.object({
|
export const RequestCodeRequestSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
});
|
});
|
||||||
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
||||||
|
|
||||||
const SignInRequestSchema = z.object({
|
export const SignInRequestSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
});
|
});
|
||||||
@@ -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(),
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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);
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ import '@/styles/globals.css';
|
|||||||
|
|
||||||
initSentryRenderer();
|
initSentryRenderer();
|
||||||
|
|
||||||
const root = createRoot(document.getElementById('root')!);
|
const container = document.getElementById('root');
|
||||||
|
if (!container) throw new Error('Root element #root not found');
|
||||||
|
const root = createRoot(container);
|
||||||
root.render(<AutoplayApp />);
|
root.render(<AutoplayApp />);
|
||||||
|
|||||||
@@ -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,46 +17,52 @@ 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(() => {
|
||||||
if (!source) {
|
// Build the source node (creating an AudioContext as needed) plus optional
|
||||||
setAudioSource(null);
|
// teardown for contexts we own; the branches converge on one setState.
|
||||||
return;
|
let result: AudioSource | null = null;
|
||||||
}
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
if (source instanceof MediaStream) {
|
if (source instanceof MediaStream) {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
ctx.resume();
|
ctx.resume();
|
||||||
const sourceNode = ctx.createMediaStreamSource(source);
|
const sourceNode = ctx.createMediaStreamSource(source);
|
||||||
setAudioSource({ sourceNode, ctx });
|
result = { sourceNode, ctx };
|
||||||
|
cleanup = () => ctx.close();
|
||||||
return () => {
|
} else if (source) {
|
||||||
ctx.close();
|
// HTMLAudioElement — createMediaElementSource can only be called once per
|
||||||
};
|
// element, so reuse a cached context/node when we have one.
|
||||||
|
const cached = elementSourceCache.current.get(source);
|
||||||
|
if (cached) {
|
||||||
|
cached.ctx.resume();
|
||||||
|
result = cached;
|
||||||
|
} else {
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
ctx.resume();
|
||||||
|
const sourceNode = ctx.createMediaElementSource(source);
|
||||||
|
// Connect element source to destination so audio is still audible
|
||||||
|
sourceNode.connect(ctx.destination);
|
||||||
|
elementSourceCache.current.set(source, { sourceNode, ctx });
|
||||||
|
result = { sourceNode, ctx };
|
||||||
|
cleanup = () => {
|
||||||
|
ctx.close();
|
||||||
|
elementSourceCache.current.delete(source);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTMLAudioElement — createMediaElementSource can only be called once per element
|
// Publishing an imperatively-created Web Audio node — external-resource
|
||||||
const cached = elementSourceCache.current.get(source);
|
// sync, not a re-render cascade.
|
||||||
if (cached) {
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
cached.ctx.resume();
|
setAudioSource(result);
|
||||||
setAudioSource(cached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = new AudioContext();
|
return cleanup;
|
||||||
ctx.resume();
|
|
||||||
const sourceNode = ctx.createMediaElementSource(source);
|
|
||||||
// Connect element source to destination so audio is still audible
|
|
||||||
sourceNode.connect(ctx.destination);
|
|
||||||
elementSourceCache.current.set(source, { sourceNode, ctx });
|
|
||||||
setAudioSource({ sourceNode, ctx });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ctx.close();
|
|
||||||
elementSourceCache.current.delete(source);
|
|
||||||
};
|
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
return audioSource;
|
return audioSource;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,9 +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 { cn } from "@/lib/utils";
|
import { platform } from '@/lib/platform';
|
||||||
import { platform } from "@/lib/platform";
|
|
||||||
|
|
||||||
interface LinkPreviewCardProps {
|
interface LinkPreviewCardProps {
|
||||||
metadata: LinkMetadata;
|
metadata: LinkMetadata;
|
||||||
@@ -35,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';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -48,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,
|
||||||
@@ -23,18 +23,15 @@ export function ScreenSourcePicker({
|
|||||||
getSources().then((result) => {
|
getSources().then((result) => {
|
||||||
setSources(result);
|
setSources(result);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
// Auto-select if there's only one source.
|
||||||
|
if (result.length === 1) {
|
||||||
|
setSelectedId(result[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [getSources]);
|
}, [getSources]);
|
||||||
|
|
||||||
// Auto-select if there's only one source
|
const screens = sources.filter((s) => s.id.startsWith('screen:'));
|
||||||
useEffect(() => {
|
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
||||||
if (!loading && sources.length === 1) {
|
|
||||||
setSelectedId(sources[0].id);
|
|
||||||
}
|
|
||||||
}, [loading, sources]);
|
|
||||||
|
|
||||||
const screens = sources.filter((s) => s.id.startsWith("screen:"));
|
|
||||||
const windows = sources.filter((s) => s.id.startsWith("window:"));
|
|
||||||
|
|
||||||
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">
|
||||||
@@ -119,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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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__;
|
||||||
|
|||||||
Vendored
+6
-2
@@ -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 { useEffect, useState } 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,20 +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 { Button } from "@/components/ui/button";
|
import { useObjectUrl } from '@/hooks/use-object-url';
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { Button } from '@/components/ui/button';
|
||||||
import { platform } from "@/lib/platform";
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
|
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 {
|
||||||
@@ -36,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 {
|
||||||
@@ -62,44 +63,39 @@ export function AttachmentLightbox({
|
|||||||
: null;
|
: null;
|
||||||
const isOpen = current !== null;
|
const isOpen = current !== null;
|
||||||
const hasMultiple = items.length > 1;
|
const hasMultiple = items.length > 1;
|
||||||
|
const positionIndicator = openIndex !== null ? openIndex + 1 : null;
|
||||||
|
|
||||||
// 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 get a fresh blob URL per item, revoked on change/close.
|
// Local items resolve to a blob URL; remote items use the signed-URL cache.
|
||||||
const [localUrl, setLocalUrl] = useState<string | null>(null);
|
const localFile =
|
||||||
useEffect(() => {
|
current?.source.kind === 'local' ? current.source.file : null;
|
||||||
if (current?.source.kind !== "local") {
|
const localUrl = useObjectUrl(localFile);
|
||||||
setLocalUrl(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = URL.createObjectURL(current.source.file);
|
|
||||||
setLocalUrl(url);
|
|
||||||
return () => URL.revokeObjectURL(url);
|
|
||||||
}, [current?.id, current?.source.kind]);
|
|
||||||
|
|
||||||
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 = (delta: number) => {
|
const goTo = useCallback(
|
||||||
if (openIndex === null || items.length === 0) return;
|
(delta: number) => {
|
||||||
const next = (openIndex + delta + items.length) % items.length;
|
if (openIndex === null || items.length === 0) return;
|
||||||
onOpenChange(next);
|
const next = (openIndex + delta + items.length) % items.length;
|
||||||
};
|
onOpenChange(next);
|
||||||
|
},
|
||||||
|
[openIndex, items.length, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDownload = () => {
|
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]);
|
||||||
|
|
||||||
const handleRemove = () => {
|
const handleRemove = useCallback(() => {
|
||||||
if (!current || !onRemove) return;
|
if (!current || !onRemove) return;
|
||||||
const wasLast = items.length <= 1;
|
const wasLast = items.length <= 1;
|
||||||
const wasAtEnd = openIndex === items.length - 1;
|
const wasAtEnd = openIndex === items.length - 1;
|
||||||
@@ -107,12 +103,12 @@ export function AttachmentLightbox({
|
|||||||
if (wasLast) {
|
if (wasLast) {
|
||||||
onOpenChange(null);
|
onOpenChange(null);
|
||||||
} else if (wasAtEnd) {
|
} else if (wasAtEnd) {
|
||||||
onOpenChange(openIndex! - 1);
|
onOpenChange(items.length - 2);
|
||||||
}
|
}
|
||||||
// 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]);
|
||||||
|
|
||||||
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, ⌫)
|
||||||
@@ -123,8 +119,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;
|
||||||
@@ -133,29 +129,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, openIndex, items, url, onOpenChange, onRemove, hasMultiple, canDownload]);
|
}, [
|
||||||
|
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 (
|
||||||
@@ -187,7 +192,7 @@ export function AttachmentLightbox({
|
|||||||
)}
|
)}
|
||||||
{hasMultiple && (
|
{hasMultiple && (
|
||||||
<span className="border-l border-white/10 pl-2 text-xs text-white/40">
|
<span className="border-l border-white/10 pl-2 text-xs text-white/40">
|
||||||
{openIndex! + 1} / {items.length}
|
{positionIndicator} / {items.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +283,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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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, useEffectEvent, 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);
|
||||||
@@ -80,87 +94,95 @@ export function ComposeOverlay({
|
|||||||
const invalidateUsage = useInvalidateNetworkUsage();
|
const invalidateUsage = useInvalidateNetworkUsage();
|
||||||
const quotaExhausted = isUsageExhausted(usage);
|
const quotaExhausted = isUsageExhausted(usage);
|
||||||
|
|
||||||
// Refs for synchronous reads in keyboard handlers
|
// Latest props/state for synchronous reads in keyboard handlers.
|
||||||
const stepRef = useRef(step);
|
const stepRef = useRef(step);
|
||||||
const recordStartRef = useRef(0);
|
const recordStartRef = useRef(0);
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
disabledRef.current = disabled;
|
|
||||||
const quotaExhaustedRef = useRef(quotaExhausted);
|
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||||
quotaExhaustedRef.current = quotaExhausted;
|
|
||||||
const recordingSourceRef = useRef(recordingSource);
|
const recordingSourceRef = useRef(recordingSource);
|
||||||
recordingSourceRef.current = recordingSource;
|
useEffect(() => {
|
||||||
|
disabledRef.current = disabled;
|
||||||
|
quotaExhaustedRef.current = quotaExhausted;
|
||||||
|
recordingSourceRef.current = recordingSource;
|
||||||
|
}, [disabled, quotaExhausted, recordingSource]);
|
||||||
|
|
||||||
const setStepSync = useCallback((next: ComposeStep) => {
|
const setStepSync = useCallback((next: ComposeStep) => {
|
||||||
stepRef.current = next;
|
stepRef.current = next;
|
||||||
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("");
|
|
||||||
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 available = MAX_ATTACHMENTS - attachments.length;
|
||||||
if (available <= 0) {
|
if (available <= 0) {
|
||||||
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`);
|
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`);
|
||||||
return;
|
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) => {
|
||||||
@@ -172,7 +194,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({
|
||||||
@@ -182,7 +204,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);
|
||||||
@@ -197,7 +219,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);
|
||||||
@@ -212,7 +234,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 } =
|
||||||
@@ -224,7 +246,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,
|
||||||
});
|
});
|
||||||
@@ -242,12 +264,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,
|
||||||
});
|
});
|
||||||
@@ -270,7 +292,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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -278,11 +302,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,
|
||||||
@@ -290,9 +314,11 @@ export function ComposeOverlay({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const failed = results.filter((r) => r.status === "rejected");
|
const failed = results.filter((r) => r.status === 'rejected');
|
||||||
if (failed.length > 0) {
|
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],
|
||||||
@@ -306,7 +332,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,
|
||||||
});
|
});
|
||||||
@@ -316,17 +342,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,
|
||||||
@@ -348,36 +377,49 @@ 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 = useEffectEvent(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();
|
||||||
|
setTextContent('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleQuotaError(err)) throw err;
|
if (!handleQuotaError(err)) throw err;
|
||||||
}
|
}
|
||||||
});
|
}, [
|
||||||
|
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({
|
||||||
@@ -391,13 +433,21 @@ export function ComposeOverlay({
|
|||||||
|
|
||||||
const streamChildrenPath = particlePath(networkId, [streamId]);
|
const streamChildrenPath = particlePath(networkId, [streamId]);
|
||||||
await createChildParticle(streamChildrenPath);
|
await createChildParticle(streamChildrenPath);
|
||||||
|
|
||||||
cancel();
|
cancel();
|
||||||
|
setTextContent('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleQuotaError(err)) throw err;
|
if (!handleQuotaError(err)) throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
|
[
|
||||||
|
networkId,
|
||||||
|
userId,
|
||||||
|
createStream,
|
||||||
|
createChildParticle,
|
||||||
|
cancel,
|
||||||
|
handleQuotaError,
|
||||||
|
setStepSync,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Compose intent handlers ---
|
// --- Compose intent handlers ---
|
||||||
@@ -406,13 +456,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;
|
||||||
@@ -421,19 +473,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();
|
||||||
@@ -442,24 +494,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]);
|
||||||
|
|
||||||
@@ -468,20 +520,42 @@ export function ComposeOverlay({
|
|||||||
// executes the matching handler and clears the intent. Keyboard handlers
|
// executes the matching handler and clears the intent. Keyboard handlers
|
||||||
// call the same handlers directly without a store round-trip.
|
// call the same handlers directly without a store round-trip.
|
||||||
|
|
||||||
const intent = useComposeIntentStore((s) => s.intent);
|
|
||||||
const clearIntent = useComposeIntentStore((s) => s.clear);
|
const clearIntent = useComposeIntentStore((s) => s.clear);
|
||||||
|
|
||||||
|
// Consume fire-and-forget intents from the external store. Reacting in the
|
||||||
|
// store subscription (not an effect body) keeps these state-updating handlers
|
||||||
|
// off the render path and avoids an extra dispatch→render bounce.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!intent) return;
|
return useComposeIntentStore.subscribe((state, prev) => {
|
||||||
switch (intent.kind) {
|
const intent = state.intent;
|
||||||
case "record": handleRecordIntent(); break;
|
if (!intent || intent === prev.intent) return;
|
||||||
case "text": handleTextIntent(); break;
|
switch (intent.kind) {
|
||||||
case "stop": handleStopIntent(); break;
|
case 'record':
|
||||||
case "cancel": handleCancelIntent(); break;
|
handleRecordIntent();
|
||||||
case "send": handleSendIntent(); break;
|
break;
|
||||||
}
|
case 'text':
|
||||||
clearIntent();
|
handleTextIntent();
|
||||||
}, [intent, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
|
break;
|
||||||
|
case 'stop':
|
||||||
|
handleStopIntent();
|
||||||
|
break;
|
||||||
|
case 'cancel':
|
||||||
|
handleCancelIntent();
|
||||||
|
break;
|
||||||
|
case 'send':
|
||||||
|
handleSendIntent();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
clearIntent();
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
handleRecordIntent,
|
||||||
|
handleTextIntent,
|
||||||
|
handleStopIntent,
|
||||||
|
handleCancelIntent,
|
||||||
|
handleSendIntent,
|
||||||
|
clearIntent,
|
||||||
|
]);
|
||||||
|
|
||||||
// --- Keyboard handling ---
|
// --- Keyboard handling ---
|
||||||
|
|
||||||
@@ -489,8 +563,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();
|
||||||
}
|
}
|
||||||
@@ -499,52 +577,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();
|
||||||
}
|
}
|
||||||
@@ -554,30 +635,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],
|
||||||
@@ -585,15 +682,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"
|
||||||
@@ -602,24 +699,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">
|
||||||
@@ -636,7 +734,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
|
||||||
@@ -647,13 +745,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}
|
||||||
@@ -670,7 +768,7 @@ export function ComposeOverlay({
|
|||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === "typing" && (
|
{step === 'typing' && (
|
||||||
<TextComposeStep
|
<TextComposeStep
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
onTextChange={setTextContent}
|
onTextChange={setTextContent}
|
||||||
@@ -683,16 +781,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
|
||||||
@@ -117,7 +119,7 @@ export function ConfigureStreamStep({
|
|||||||
{!everyone && members.length > 0 && (
|
{!everyone && members.length > 0 && (
|
||||||
<ScrollArea className="max-h-48">
|
<ScrollArea className="max-h-48">
|
||||||
<div className="space-y-0.5 p-1">
|
<div className="space-y-0.5 p-1">
|
||||||
{members.map((member, index) => {
|
{members.map((member) => {
|
||||||
const isSelected = selectedIds.has(member.id);
|
const isSelected = selectedIds.has(member.id);
|
||||||
const initials = member.email_prefix
|
const initials = member.email_prefix
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
@@ -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,16 +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 { AttachmentStrip } from "@/features/compose/attachment-strip";
|
import { useObjectUrl } from '@/hooks/use-object-url';
|
||||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
import { cn } from "@/lib/utils";
|
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||||
import { Button } from "@/components/ui/button";
|
import { cn } from '@/lib/utils';
|
||||||
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
import { Button } from '@/components/ui/button';
|
||||||
|
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;
|
||||||
@@ -29,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() {
|
||||||
@@ -44,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">
|
||||||
@@ -58,30 +59,18 @@ 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 urlRef = useRef<string | null>(null);
|
const objectUrl = useObjectUrl(blob);
|
||||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
|
||||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||||
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
urlRef.current = url;
|
|
||||||
setObjectUrl(url);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
urlRef.current = null;
|
|
||||||
};
|
|
||||||
}, [blob]);
|
|
||||||
|
|
||||||
if (!objectUrl) return null;
|
if (!objectUrl) return null;
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
@@ -91,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' : ''}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,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]);
|
||||||
@@ -148,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 : {})}
|
||||||
>
|
>
|
||||||
@@ -165,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
|
||||||
@@ -187,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}
|
||||||
/>
|
/>
|
||||||
@@ -221,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>
|
||||||
@@ -262,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>
|
||||||
@@ -299,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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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,28 +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');
|
||||||
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,
|
||||||
@@ -55,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>
|
||||||
@@ -68,7 +79,7 @@ export default function NetworkRoot() {
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
|
||||||
<ParticleListView
|
<ParticleListView
|
||||||
streams={streams}
|
streams={streams}
|
||||||
networkId={networkId!}
|
networkId={networkId}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
canLoadMore={canLoadMore}
|
canLoadMore={canLoadMore}
|
||||||
@@ -76,10 +87,10 @@ export default function NetworkRoot() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
<ComposeOverlay networkId={networkId} onActiveChange={setComposeActive} />
|
||||||
{!composeActive && (
|
{!composeActive && (
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
|
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
|
||||||
<ComposeQuotaIndicator networkId={networkId!} />
|
<ComposeQuotaIndicator networkId={networkId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||||
@@ -98,39 +109,39 @@ function NetworkRootControls() {
|
|||||||
<span>
|
<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">
|
||||||
1–9
|
1–9
|
||||||
</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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,24 +179,30 @@ 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');
|
||||||
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);
|
||||||
const removeMember = useRemoveMember(networkId!);
|
const removeMember = useRemoveMember(networkId);
|
||||||
|
|
||||||
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();
|
||||||
@@ -227,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>
|
||||||
@@ -254,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" />
|
||||||
@@ -279,7 +287,7 @@ export default function NetworkSettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
<Separator />
|
||||||
<InviteForm networkId={networkId!} />
|
<InviteForm networkId={networkId} />
|
||||||
{invitationsError && (
|
{invitationsError && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -288,7 +296,7 @@ export default function NetworkSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pendingCount > 0 && (
|
{invitations && invitations.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="px-4 pb-1 pt-3">
|
<div className="px-4 pb-1 pt-3">
|
||||||
@@ -296,13 +304,13 @@ export default function NetworkSettingsPage() {
|
|||||||
Pending
|
Pending
|
||||||
</Muted>
|
</Muted>
|
||||||
</div>
|
</div>
|
||||||
{invitations!.map((inv, index) => (
|
{invitations.map((inv, index) => (
|
||||||
<div key={inv.email}>
|
<div key={inv.email}>
|
||||||
<PendingInvitationRow
|
<PendingInvitationRow
|
||||||
email={inv.email}
|
email={inv.email}
|
||||||
networkId={networkId!}
|
networkId={networkId}
|
||||||
/>
|
/>
|
||||||
{index < invitations!.length - 1 && (
|
{index < invitations.length - 1 && (
|
||||||
<Separator className="mx-4" />
|
<Separator className="mx-4" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -319,12 +327,12 @@ 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."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
<Separator />
|
||||||
<BillingSection networkId={networkId!} />
|
<BillingSection networkId={networkId} />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -342,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,7 +1,6 @@
|
|||||||
import { Particle } from "@/api/types";
|
import { Particle } from '@/api/types';
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
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;
|
||||||
@@ -9,7 +8,6 @@ interface FolderViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FolderView({ path, folderParticle }: FolderViewProps) {
|
export function FolderView({ path, folderParticle }: FolderViewProps) {
|
||||||
const { children, error, isLoading } = useLiveParticleChildren(path);
|
|
||||||
const { networkId } = parseParticlePath(path);
|
const { networkId } = parseParticlePath(path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,50 +32,55 @@ 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
|
// Prefer the worker-produced iOS-playable variant when present so desktop and
|
||||||
// and mobile read the same canonical asset. Falls back to the original.
|
// mobile read the same canonical asset, falling back to the original. Pinned
|
||||||
// Pin the choice for the lifetime of this particle: if a transcoded variant
|
// on mount (the parent keys this component by particle.id, so a new particle
|
||||||
// arrives via Firestore mid-playback, swapping the <video> src would restart
|
// remounts and re-picks): if a transcoded variant arrives via Firestore for
|
||||||
// playback from 0. Keep whatever we picked first; the original plays fine in
|
// the same particle, swapping the <video> src would restart playback from 0.
|
||||||
// Electron, and the transcoded variant will be picked up on the next view.
|
const [pickedSource] = useState(() => ({
|
||||||
const pickedSourceRef = useRef<{ id: string; objectId: string; mime: string } | null>(null);
|
objectId:
|
||||||
if (pickedSourceRef.current?.id !== particle.id) {
|
particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
||||||
pickedSourceRef.current = {
|
mime:
|
||||||
id: particle.id,
|
particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
||||||
objectId: particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
}));
|
||||||
mime: particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
const activeObjectId = pickedSource.objectId;
|
||||||
};
|
const activeMime = pickedSource.mime;
|
||||||
}
|
|
||||||
const activeObjectId = pickedSourceRef.current.objectId;
|
|
||||||
const activeMime = pickedSourceRef.current.mime;
|
|
||||||
const { data: url, error } = useDownloadUrl(activeObjectId);
|
const { data: url, error } = useDownloadUrl(activeObjectId);
|
||||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||||
|
|
||||||
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;
|
||||||
@@ -91,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]);
|
||||||
@@ -108,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 } 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,25 +19,28 @@ import {
|
|||||||
Headphones,
|
Headphones,
|
||||||
Trash2,
|
Trash2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { cn } 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 { getInitials } from "@/lib/utils";
|
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 {
|
||||||
import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/types";
|
isParticleDeleted,
|
||||||
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
type Particle,
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
type StreamProperties,
|
||||||
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
} from '@/api/types';
|
||||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
import type { StreamParticle } from '@/hooks/use-stream-particles';
|
||||||
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
|
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,
|
||||||
@@ -43,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 && (
|
||||||
@@ -64,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;
|
||||||
@@ -85,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;
|
||||||
@@ -117,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;
|
||||||
@@ -126,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) {
|
||||||
@@ -145,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;
|
||||||
@@ -171,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]);
|
||||||
|
|
||||||
@@ -187,13 +208,15 @@ const StreamRow = memo(function StreamRow({
|
|||||||
? getMessagePreview(latestChild)
|
? getMessagePreview(latestChild)
|
||||||
: particle.properties.name;
|
: particle.properties.name;
|
||||||
|
|
||||||
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
// Rendered via createElement below: a call-result used directly as a JSX tag
|
||||||
|
// is flagged as a dynamically-created component.
|
||||||
|
const typeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
||||||
|
|
||||||
const videoThumbObjectId =
|
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;
|
||||||
|
|
||||||
@@ -202,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 && (
|
||||||
@@ -217,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>
|
||||||
@@ -227,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}
|
||||||
@@ -239,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} />
|
||||||
@@ -255,18 +282,18 @@ const StreamRow = memo(function StreamRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<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 && (
|
||||||
@@ -276,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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -313,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]);
|
||||||
|
|
||||||
@@ -327,7 +356,8 @@ export function ParticleListView({
|
|||||||
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
|
<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>
|
||||||
);
|
);
|
||||||
@@ -336,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,11 @@ 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();
|
||||||
const segments = (rest ?? "").split("/").filter(Boolean);
|
if (!networkId)
|
||||||
const path = particlePath(networkId!, segments); // path of current container particle
|
throw new Error('ParticleViewResolver requires a :networkId route param');
|
||||||
|
const segments = (rest ?? '').split('/').filter(Boolean);
|
||||||
|
const path = particlePath(networkId, segments); // path of current container particle
|
||||||
|
|
||||||
const { particle, isLoading, error } = useLiveParticle(path);
|
const { particle, isLoading, error } = useLiveParticle(path);
|
||||||
|
|
||||||
@@ -38,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 (
|
||||||
@@ -59,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 (
|
||||||
@@ -71,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,11 +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 } from "@/api/types";
|
TooltipContent,
|
||||||
import { cn } from "@/lib/utils";
|
TooltipTrigger,
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
} from '@/components/ui/tooltip';
|
||||||
import type { Human } from "@/api/types";
|
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;
|
||||||
@@ -20,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(
|
||||||
@@ -61,18 +64,21 @@ export function ReactionBar({
|
|||||||
<div className="flex flex-col items-end gap-1.5">
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
{/* Emoji reaction pills */}
|
{/* Emoji reaction pills */}
|
||||||
{activeEmojis.map((emoji) => {
|
{activeEmojis.map((emoji) => {
|
||||||
const reactors = reactions![emoji];
|
const reactors = reactions?.[emoji] ?? [];
|
||||||
const isMine = reactors.includes(currentHumanId);
|
const isMine = reactors.includes(currentHumanId);
|
||||||
return (
|
return (
|
||||||
<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>
|
||||||
@@ -88,7 +94,7 @@ export function ReactionBar({
|
|||||||
|
|
||||||
{/* Text reaction pills */}
|
{/* Text reaction pills */}
|
||||||
{activeTextReactions.map((text) => {
|
{activeTextReactions.map((text) => {
|
||||||
const reactors = reactions![text];
|
const reactors = reactions?.[text] ?? [];
|
||||||
const isMine = reactors.includes(currentHumanId);
|
const isMine = reactors.includes(currentHumanId);
|
||||||
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
||||||
const reactorList = getReactorList(reactors, humans, currentHumanId);
|
const reactorList = getReactorList(reactors, humans, currentHumanId);
|
||||||
@@ -96,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">
|
||||||
@@ -111,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>
|
||||||
@@ -141,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}
|
||||||
@@ -149,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" />
|
||||||
@@ -160,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +29,15 @@ 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 ?? [];
|
||||||
const creatorId = streamParticle.created_by_human_id;
|
const creatorId = streamParticle.created_by_human_id;
|
||||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
const visibility = useMemo(
|
||||||
|
() => parseVisibleTo(streamParticle.visible_to, networkId),
|
||||||
|
[streamParticle.visible_to, networkId],
|
||||||
|
);
|
||||||
|
|
||||||
const docPath = useMemo(
|
const docPath = useMemo(
|
||||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
||||||
@@ -42,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);
|
||||||
@@ -58,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;
|
||||||
@@ -69,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]),
|
||||||
@@ -80,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(
|
||||||
@@ -103,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>
|
||||||
@@ -116,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}
|
||||||
@@ -130,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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -148,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">
|
||||||
@@ -157,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}
|
||||||
@@ -170,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}
|
||||||
@@ -199,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,
|
||||||
@@ -259,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,16 +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';
|
||||||
import type { ChannelMessage } from "@/lib/pusher-client";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type ComposingMode = "recording" | "typing" | "screen";
|
export type ComposingMode = 'recording' | 'typing' | 'screen';
|
||||||
|
|
||||||
export interface ComposingUser {
|
export interface ComposingUser {
|
||||||
humanId: string;
|
humanId: string;
|
||||||
@@ -93,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,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],
|
||||||
@@ -172,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
|
||||||
@@ -207,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>
|
||||||
|
|||||||
@@ -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,31 +76,33 @@ 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,
|
||||||
disabled: boolean,
|
disabled: boolean,
|
||||||
onExit: () => void,
|
onExit: () => void,
|
||||||
) {
|
) {
|
||||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
const [remainingMs, setRemainingMs] = useState<number | null>(
|
||||||
|
status === 'ended' ? EXIT_DELAY_MS : null,
|
||||||
|
);
|
||||||
|
const [prevStatus, setPrevStatus] = useState(status);
|
||||||
|
|
||||||
const handleExit = useEffectEvent(() => {
|
const handleExit = useEffectEvent(() => {
|
||||||
onExit();
|
onExit();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start/cancel countdown based on playback status
|
// Start the countdown when playback ends; cancel it otherwise.
|
||||||
useEffect(() => {
|
if (status !== prevStatus) {
|
||||||
if (status === "ended") {
|
setPrevStatus(status);
|
||||||
setRemainingMs(EXIT_DELAY_MS);
|
setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null);
|
||||||
} else {
|
}
|
||||||
setRemainingMs(null);
|
|
||||||
}
|
const isCountingDown = remainingMs !== null && remainingMs > 0;
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
// Tick the countdown down (pauses when compose is active)
|
// Tick the countdown down (pauses when compose is active)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (remainingMs === null || remainingMs <= 0 || disabled) return;
|
if (!isCountingDown || disabled) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setRemainingMs((prev) => {
|
setRemainingMs((prev) => {
|
||||||
@@ -79,7 +113,7 @@ function useExitCountdown(
|
|||||||
}, EXIT_TICK_MS);
|
}, EXIT_TICK_MS);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [remainingMs !== null && remainingMs > 0, disabled]);
|
}, [isCountingDown, disabled]);
|
||||||
|
|
||||||
// Navigate once countdown hits zero
|
// Navigate once countdown hits zero
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -132,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,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);
|
||||||
@@ -185,30 +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]);
|
},
|
||||||
|
[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 [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 });
|
||||||
|
|
||||||
@@ -221,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(() => {
|
||||||
@@ -249,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) {
|
||||||
@@ -274,35 +323,35 @@ 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 particle changes
|
// Reset progress when the particle changes.
|
||||||
useEffect(() => {
|
if (currentParticle?.id !== prevParticleId) {
|
||||||
|
setPrevParticleId(currentParticle?.id);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
}, [currentParticle?.id]);
|
}
|
||||||
|
|
||||||
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 (
|
||||||
@@ -318,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>
|
||||||
@@ -339,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}
|
||||||
@@ -351,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}
|
||||||
@@ -363,7 +412,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return <FallbackParticleView particle={particle} networkId={networkId} />;
|
return (
|
||||||
|
<FallbackParticleView particle={particle} networkId={networkId} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +429,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
{/* TopBar — always visible */}
|
{/* 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 */}
|
||||||
@@ -408,7 +463,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
|
<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)}
|
||||||
@@ -422,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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -475,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}
|
||||||
@@ -536,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,15 +12,28 @@ 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 inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useSuspendPlayback(open, "text-reaction");
|
useSuspendPlayback(open, 'text-reaction');
|
||||||
|
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
|
|
||||||
|
// NOTE: perform side effects here when opening
|
||||||
|
if (open) {
|
||||||
|
setValue('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setValue("");
|
|
||||||
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -34,6 +47,7 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
onSubmit(trimmed);
|
onSubmit(trimmed);
|
||||||
|
setValue('');
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,10 +64,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();
|
||||||
}
|
}
|
||||||
@@ -64,8 +78,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}
|
||||||
@@ -75,10 +89,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, useRef } 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;
|
||||||
|
|
||||||
@@ -41,42 +41,50 @@ export function TranscriptOverlay({
|
|||||||
const activeWord =
|
const activeWord =
|
||||||
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
||||||
|
|
||||||
// Remember the last spoken word so highlights hold during pauses
|
// Remember the last spoken word so highlights hold during pauses.
|
||||||
const lastSpokenWordRef = useRef<Word | null>(null);
|
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
|
||||||
if (activeWord) {
|
if (activeWord && activeWord !== lastSpokenWord) {
|
||||||
lastSpokenWordRef.current = activeWord;
|
setLastSpokenWord(activeWord);
|
||||||
}
|
}
|
||||||
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
const highlightWord = activeWord ?? lastSpokenWord;
|
||||||
|
|
||||||
const lastChunkRef = useRef<Word[] | null>(null);
|
// The chunk currently being spoken (null during a pause or if not found).
|
||||||
|
const spokenChunk = useMemo(() => {
|
||||||
// Find which chunk contains the active word, holding the last one during pauses
|
if (!activeWord) return null;
|
||||||
const activeChunk = useMemo(() => {
|
return (
|
||||||
if (activeWord) {
|
chunks.find((chunk) =>
|
||||||
for (const chunk of chunks) {
|
chunk.some(
|
||||||
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
|
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
||||||
lastChunkRef.current = chunk;
|
),
|
||||||
return chunk;
|
) ?? null
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
// No active word (speaker pausing) — hold the last chunk
|
|
||||||
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
|
|
||||||
return lastChunkRef.current;
|
|
||||||
}
|
|
||||||
// Sentence changed, last chunk no longer valid — use first chunk
|
|
||||||
const fallback = chunks[0] ?? null;
|
|
||||||
lastChunkRef.current = fallback;
|
|
||||||
return fallback;
|
|
||||||
}, [chunks, activeWord]);
|
}, [chunks, activeWord]);
|
||||||
|
|
||||||
|
// Resolve which chunk to display: the spoken one, else hold the last one while
|
||||||
|
// it's still part of the current sentence, else fall back to the first chunk.
|
||||||
|
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
|
||||||
|
let activeChunk: Word[] | null;
|
||||||
|
if (spokenChunk) {
|
||||||
|
activeChunk = spokenChunk;
|
||||||
|
} else if (lastChunk && chunks.includes(lastChunk)) {
|
||||||
|
activeChunk = lastChunk;
|
||||||
|
} else {
|
||||||
|
activeChunk = chunks[0] ?? null;
|
||||||
|
}
|
||||||
|
if (activeChunk !== lastChunk) {
|
||||||
|
setLastChunk(activeChunk);
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
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 =
|
||||||
@@ -87,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +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, User, Info, Shield, Mail, Mic, LifeBuoy, FileText, Volume2 } 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 { ArrowLeft } from "lucide-react";
|
import { WindowControls } from '@/components/window-controls';
|
||||||
import { platform } from "@/lib/platform";
|
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;
|
||||||
@@ -37,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}
|
||||||
@@ -86,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">
|
||||||
@@ -127,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>
|
||||||
@@ -171,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,
|
||||||
@@ -34,12 +34,19 @@ function usePreviewStream(
|
|||||||
): { stream: MediaStream | null; error: string | null } {
|
): { stream: MediaStream | null; error: string | null } {
|
||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [prevEnabled, setPrevEnabled] = useState(enabled);
|
||||||
|
|
||||||
useEffect(() => {
|
// Clear the preview when disabled
|
||||||
|
if (enabled !== prevEnabled) {
|
||||||
|
setPrevEnabled(enabled);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setStream(null);
|
setStream(null);
|
||||||
return;
|
setError(null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let active: MediaStream | null = null;
|
let active: MediaStream | null = null;
|
||||||
@@ -67,7 +74,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 () => {
|
||||||
@@ -81,7 +90,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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +216,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,
|
||||||
@@ -280,7 +289,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} />
|
||||||
@@ -291,7 +300,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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useQuery, useMutation } 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: () => apiClient.getNetworkBilling(networkId!),
|
queryFn: networkId
|
||||||
enabled: !!networkId,
|
? () => apiClient.getNetworkBilling(networkId)
|
||||||
|
: skipToken,
|
||||||
// Refetch on window focus so the UI catches up after the user returns
|
// Refetch on window focus so the UI catches up after the user returns
|
||||||
// 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } 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 */
|
||||||
@@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
|||||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !channelId) {
|
if (!client || !channelId) return;
|
||||||
setPresence([]);
|
|
||||||
setMessages([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.subscribe(channelId);
|
client.subscribe(channelId);
|
||||||
|
|
||||||
@@ -36,11 +32,11 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onJoin = (msg: { humanId?: string }) => {
|
const onJoin = (msg: { humanId?: string }) => {
|
||||||
if (msg.humanId) {
|
const humanId = msg.humanId;
|
||||||
setPresence((prev) =>
|
if (!humanId) return;
|
||||||
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
|
setPresence((prev) =>
|
||||||
);
|
prev.includes(humanId) ? prev : [...prev, humanId],
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLeave = (msg: { humanId?: string }) => {
|
const onLeave = (msg: { humanId?: string }) => {
|
||||||
@@ -50,25 +46,24 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
|
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
|
||||||
if (msg.humanId) {
|
const humanId = msg.humanId;
|
||||||
setMessages((prev) => [
|
if (!humanId) return;
|
||||||
...prev,
|
setMessages((prev) => [...prev, { humanId, payload: msg.payload }]);
|
||||||
{ humanId: msg.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);
|
||||||
|
setPresence([]);
|
||||||
|
setMessages([]);
|
||||||
};
|
};
|
||||||
}, [client, channelId]);
|
}, [client, channelId]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user
Useless conditional
This use of variable 'currentParticle' always evaluates to true.
The best fix is to remove the unnecessary conditional around
currentParticleDocPathconstruction and always compute it directly after the existing early return checks.In
js/desktop/src/features/particles/stream-view.tsx, insidehandleToggleReaction(around lines 227–233), replace:currentParticleDocPathusingcurrentParticle ? ... : nullif (!currentParticleDocPath) return;with a direct assignment using
currentParticle.id. This keeps functionality unchanged becausecurrentParticleis already guaranteed non-null by the earlier guard.No imports, new methods, or new dependencies are required.