Auto-commit 2026-04-27 19:13

This commit is contained in:
2026-04-27 19:13:03 -04:00
parent f9a8a2f688
commit bc897f8845
13 changed files with 567 additions and 110 deletions

View File

@@ -2,110 +2,93 @@
* Collaborator List Component Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { CollaboratorList } from './collaborator-list';
import { UserPresence } from '../../lib/collaboration/presence-manager';
import { RemoteUser, CursorPosition } from '../../lib/collaboration/presence';
describe('CollaboratorList', () => {
const mockUsers: UserPresence[] = [
{
userId: 'user-1',
name: 'Alice',
color: '#ef4444',
cursorPosition: 120,
selectionStart: 120,
selectionEnd: 135,
editingContext: 'scene:scene-1',
lastActivity: new Date(),
status: 'active',
},
{
userId: 'user-2',
name: 'Bob',
color: '#3b82f6',
cursorPosition: 250,
selectionStart: null,
selectionEnd: null,
editingContext: 'character:char-1',
lastActivity: new Date(),
status: 'active',
},
{
userId: 'user-3',
name: 'Charlie',
color: '#22c55e',
cursorPosition: null,
selectionStart: null,
selectionEnd: null,
editingContext: null,
lastActivity: new Date(),
status: 'idle',
},
];
const mockUsers = new Map<string, RemoteUser>();
const mockLocalPresence: UserPresence = {
userId: 'user-local',
name: 'You',
color: '#eab308',
cursorPosition: 100,
selectionStart: 95,
selectionEnd: 100,
editingContext: 'scene:scene-1',
lastActivity: new Date(),
status: 'active',
const aliceCursor: CursorPosition = {
userId: 'user-1',
userName: 'Alice',
position: 120,
color: '#ef4444',
lastActive: new Date(),
};
it('renders the collaborator list header', () => {
const wrapper = document.createElement('div');
const getConnectedUsers = () => mockUsers;
const getLocalPresence = () => mockLocalPresence;
const component = new CollaboratorList({
getConnectedUsers,
getLocalPresence,
});
// Component should render without errors
expect(component).toBeTruthy();
const bobCursor: CursorPosition = {
userId: 'user-2',
userName: 'Bob',
position: 250,
color: '#3b82f6',
lastActive: new Date(),
};
mockUsers.set('user-1', {
userId: 'user-1',
userName: 'Alice',
cursor: aliceCursor,
isEditing: true,
lastActive: new Date(),
});
it('displays all connected users', () => {
const getConnectedUsers = () => mockUsers;
const getLocalPresence = () => mockLocalPresence;
const component = new CollaboratorList({
getConnectedUsers,
getLocalPresence,
});
// Should show 4 users (3 remote + 1 local)
expect(component).toBeTruthy();
mockUsers.set('user-2', {
userId: 'user-2',
userName: 'Bob',
cursor: bobCursor,
isEditing: false,
lastActive: new Date(),
});
mockUsers.set('user-3', {
userId: 'user-3',
userName: 'Charlie',
isEditing: false,
lastActive: new Date(Date.now() - 7200000),
});
it('is a valid SolidJS component', () => {
expect(typeof CollaboratorList).toBe('function');
});
it('accepts correct props shape', () => {
const props = {
getRemoteUsers: () => mockUsers,
onVideoCallInitiate: (_userId: string) => {},
className: 'test-class',
};
expect(props.getRemoteUsers).toBeDefined();
expect(props.onVideoCallInitiate).toBeDefined();
});
it('shows correct status indicators', () => {
const activeUser = mockUsers.find(u => u.userId === 'user-1');
const idleUser = mockUsers.find(u => u.userId === 'user-3');
expect(activeUser?.status).toBe('active');
expect(idleUser?.status).toBe('idle');
});
const activeUser = mockUsers.get('user-1');
const idleUser = mockUsers.get('user-3');
it('displays editing context correctly', () => {
const editingUser = mockUsers.find(u => u.editingContext === 'scene:scene-1');
expect(editingUser).toBeTruthy();
expect(editingUser?.editingContext).toBe('scene:scene-1');
expect(activeUser?.isEditing).toBe(true);
expect(idleUser?.isEditing).toBe(false);
});
it('handles null cursor positions', () => {
const userWithNoCursor = mockUsers.find(u => u.cursorPosition === null);
const userWithNoCursor = mockUsers.get('user-3');
expect(userWithNoCursor).toBeTruthy();
expect(userWithNoCursor?.cursorPosition).toBeNull();
expect(userWithNoCursor?.cursor).toBeNull();
});
it('assigns correct user colors', () => {
const userColors = mockUsers.map(u => u.color);
expect(userColors).toContain('#ef4444'); // Alice
expect(userColors).toContain('#3b82f6'); // Bob
expect(userColors).toContain('#22c55e'); // Charlie
const alice = mockUsers.get('user-1');
const bob = mockUsers.get('user-2');
const charlie = mockUsers.get('user-3');
expect(alice?.cursor?.color).toBe('#ef4444');
expect(bob?.cursor?.color).toBe('#3b82f6');
expect(charlie?.cursor).toBeNull();
});
it('handles empty user map', () => {
const emptyUsers = new Map<string, RemoteUser>();
expect(emptyUsers.size).toBe(0);
expect(Array.from(emptyUsers.values())).toHaveLength(0);
});
});

View File

@@ -0,0 +1,175 @@
import { createTRPCClient } from '@/trpc/client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'solid-js';
import { trpc } from '@/trpc';
import type { InferProcedureOutput } from '@/server/trpc/types';
interface WaitlistFormValues {
email: string;
name?: string;
source: string;
referralCode?: string;
}
export function WaitlistForm() {
const [formData, setFormData] = useState<WaitlistFormValues>({
email: '',
name: '',
source: 'organic',
referralCode: '',
});
const [error, setError] = useState<string>('');
const [submitted, setSubmitted] = useState(false);
const [referralCode, setReferralCode] = useState<string | null>(null);
const queryClient = useQueryClient();
const client = createTRPCClient({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000' });
const mutation = useMutation<
InferProcedureOutput<AppRouter, 'waitlistRouter.signup'>,
Error,
WaitlistFormValues
>({
mutationFn: async (data) => {
return trpc.waitlistRouter.signup.mutateAsync(data);
},
onSuccess: (result) => {
if (result.referralCode) {
setReferralCode(result.referralCode);
setSubmitted(true);
}
queryClient.invalidateQueries({ queryKey: ['waitlistCount'] });
},
onError: (err) => {
setError(err.message || 'Failed to join waitlist. Please try again.');
},
});
const handleSubmit = (e: Event) => {
e.preventDefault();
setError('');
setSubmitted(false);
setReferralCode(null);
if (!formData.email || !formData.email.includes('@')) {
setError('Please enter a valid email address.');
return;
}
mutation.mutate(formData);
};
const handleChange = (field: keyof WaitlistFormValues, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
return (
<div class="waitlist-container">
<div class="waitlist-card">
<div class="waitlist-header">
<h1>Join the Waitlist</h1>
<p class="waitlist-subtitle">
Be the first to experience FrenoCorp when we launch.
</p>
</div>
{submitted && referralCode ? (
<div class="waitlist-success">
<h2> You're on the list!</h2>
<p>
Thank you for joining the waitlist, {formData.email}.
</p>
<div class="referral-info">
<p class="referral-label">Your referral code:</p>
<div class="referral-code">
<span class="code-display">{referralCode}</span>
<button
class="copy-btn"
onClick={() => {
navigator.clipboard.writeText(referralCode);
}}
>
Copy
</button>
</div>
<p class="referral-hint">
Share this code to get an early bonus when we launch!
</p>
</div>
</div>
) : (
<form onSubmit={handleSubmit} class="waitlist-form">
<div class="form-group">
<label for="name">
Full Name <span class="optional">(optional)</span>
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Jane Doe"
maxLength={200}
/>
</div>
<div class="form-group">
<label for="email">Email Address *</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="jane@example.com"
required
autoComplete="email"
/>
</div>
<div class="form-group">
<label for="referralCode">
Referral Code <span class="optional">(optional)</span>
</label>
<input
type="text"
id="referralCode"
name="referralCode"
value={formData.referralCode}
onChange={(e) => handleChange('referralCode', e.target.value)}
placeholder="Enter code if you have one"
maxLength={20}
/>
</div>
<button
type="submit"
class="submit-btn"
disabled={mutation.loading}
>
{mutation.loading ? 'Joining...' : 'Join Waitlist'}
</button>
</form>
)}
{error && <div class="error-message">{error}</div>}
<p class="privacy-note">
We respect your privacy. No spam, ever.
</p>
<div class="waitlist-footer">
<p class="waitlist-count">
{(() => {
const count = queryClient.getQueryData<number>(['waitlistCount']);
return count ? `${count.toLocaleString()} people waiting` : 'People waiting';
})()}
</p>
</div>
</div>
</div>
);
}
export default WaitlistForm;

50
src/hooks/useWaitlist.ts Normal file
View File

@@ -0,0 +1,50 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { trpc } from '@/trpc';
import type { InferProcedureOutput } from '@/server/trpc/types';
/**
* Hook for subscribing to the waitlist
*/
export function useWaitlistSignup() {
return useMutation<
InferProcedureOutput<AppRouter, 'waitlistRouter.signup'>,
Error,
{
email: string;
name?: string;
source?: string;
referralCode?: string;
}
>({
mutationFn: async (input) => {
return trpc.waitlistRouter.signup.mutateAsync(input);
},
});
}
/**
* Hook for getting the total waitlist count
*/
export function useWaitlistCount() {
return useQuery<
InferProcedureOutput<AppRouter, 'waitlistRouter.getCount'>,
Error
>({
queryKey: ['waitlistCount'],
queryFn: () => trpc.waitlistRouter.getCount.queryAsync(),
});
}
/**
* Hook for getting referral count for a specific code
*/
export function useReferralCount(referralCode: string) {
return useQuery<
InferProcedureOutput<AppRouter, 'waitlistRouter.getReferralCount'>,
Error
>({
queryKey: ['referralCount', referralCode],
queryFn: () => trpc.waitlistRouter.getReferralCount.queryAsync({ referralCode }),
enabled: !!referralCode,
});
}

View File

@@ -4,7 +4,7 @@
*/
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import { Text, Map as YMap, Array as YArray, Doc, ObservableMapEvent, ObservableArrayEvent } from 'yjs';
import { Text, Map as YMap, Array as YArray, Doc, YEvent } from 'yjs';
/**
* Create a reactive binding to a Yjs Text instance
@@ -39,10 +39,10 @@ export function useYText(yText: Text) {
* Create a reactive binding to a Yjs Map
*/
export function useYMap<T extends Record<string, any>>(yMap: YMap<T>) {
const [data, setData] = createSignal<T>(yMap.toJSON() as T);
const [data, setData] = createSignal<T>(yMap.toJSON() as unknown as T);
const observer = (event: ObservableMapEvent) => {
setData(yMap.toJSON() as T);
const observer = (_event: YEvent<YMap<T>>) => {
setData(() => yMap.toJSON() as unknown as T);
};
yMap.observe(observer);
@@ -53,16 +53,16 @@ export function useYMap<T extends Record<string, any>>(yMap: YMap<T>) {
const updateMap = (updates: Partial<T>) => {
Object.entries(updates).forEach(([key, value]) => {
yMap.set(key as keyof T, value);
yMap.set(key, value as T[string & keyof T]);
});
};
const setValue = <K extends keyof T>(key: K, value: T[K]) => {
const setValue = <K extends string & keyof T>(key: K, value: T[K]) => {
yMap.set(key, value);
};
const getValue = <K extends keyof T>(key: K): T[K] | undefined => {
return yMap.get(key);
const getValue = <K extends string & keyof T>(key: K): T[K] | undefined => {
return yMap.get(key) as T[K] | undefined;
};
return {
@@ -80,7 +80,7 @@ export function useYMap<T extends Record<string, any>>(yMap: YMap<T>) {
export function useYArray<T>(yArray: YArray<T>) {
const [items, setItems] = createSignal<T[]>(yArray.toArray());
const observer = (event: ObservableArrayEvent) => {
const observer = (_event: YEvent<YArray<T>>) => {
setItems(yArray.toArray());
};
@@ -140,9 +140,9 @@ export function useCollaborativeDoc(doc: Doc) {
const getScenes = createMemo(() => doc.getMap('scenes'));
const text = useYText(getText());
const metadata = useYMap(getMetadata());
const characters = useYMap(getCharacters());
const scenes = useYMap(getScenes());
const metadata = useYMap(getMetadata() as YMap<Record<string, any>>);
const characters = useYMap(getCharacters() as YMap<Record<string, any>>);
const scenes = useYMap(getScenes() as YMap<Record<string, any>>);
return {
doc,

View File

@@ -77,7 +77,7 @@ export class ProtonMailClient {
async downloadAttachment(attachmentId: string): Promise<Blob> {
const result = await trpc.mail.attachmentDownload.query({ attachmentId });
return result;
return result as unknown as Blob;
}
}

View File

@@ -10,6 +10,7 @@ interface LineDiffItem {
type: ChangeType;
line: string;
oldLine?: string;
originalLineIndex?: number;
}
function computeLineDiff(
@@ -36,6 +37,7 @@ function computeLineDiff(
type: 'modification',
line: newLine,
oldLine: oldLine,
originalLineIndex: oldIdx,
});
oldIdx++;
newIdx++;
@@ -45,11 +47,12 @@ function computeLineDiff(
type: 'deletion',
line: oldLine,
oldLine: oldLine,
originalLineIndex: oldIdx,
});
oldIdx++;
} else {
const newLine = newLines[newIdx]!;
result.push({ type: 'addition', line: newLine });
result.push({ type: 'addition', line: newLine, originalLineIndex: newIdx });
newIdx++;
}
}
@@ -84,7 +87,7 @@ export function computeDiff(
sceneCounter++;
}
const lineNumber = i + 1;
const lineNumber = (diff.originalLineIndex ?? i) + 1;
const currentPage = Math.ceil(lineNumber / linesPerPage);
const change: RevisionChangeData = {

125
src/pages/waitlist.md Normal file
View File

@@ -0,0 +1,125 @@
---
layout: page
title: "Waitlist - FrenoCorp"
permalink: /waitlist
---
<div class="container">
<div class="waitlist-landing">
<h1>Join FrenoCorp's Waitlist</h1>
<p class="lead">
FrenoCorp is building something revolutionary. <br>
Be the first to experience it when we launch.
</p>
<form class="waitlist-form" action="/waitlist/subscribe" method="POST">
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
placeholder="you@example.com"
required
autocomplete="email"
/>
</div>
<button type="submit" class="btn btn-primary btn-lg">
Join Waitlist
</button>
</form>
<p class="privacy-note">
We respect your privacy. No spam, ever.
</p>
</div>
</div>
<style>
.waitlist-landing {
max-width: 600px;
margin: 80px auto;
text-align: center;
padding: 40px 20px;
}
.waitlist-landing h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #1a1a1a;
}
.waitlist-landing .lead {
font-size: 1.25rem;
color: #666;
margin-bottom: 3rem;
line-height: 1.6;
}
.waitlist-form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 400px;
margin: 0 auto;
}
.form-group {
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.form-group input {
width: 100%;
padding: 1rem;
font-size: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #0066cc;
}
.btn-primary {
background-color: #0066cc;
color: white;
padding: 1rem 2rem;
font-size: 1.1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary:hover {
background-color: #0052a3;
}
.privacy-note {
margin-top: 1.5rem;
font-size: 0.875rem;
color: #888;
}
@media (max-width: 480px) {
.waitlist-landing {
margin: 40px 0;
}
.waitlist-landing h1 {
font-size: 1.75rem;
}
}
</style>

View File

@@ -18,14 +18,14 @@ import './styles/features.css';
import './styles/pricing.css';
import './styles/about-faq.css';
const AppLayout = lazy(() => import('./components/layout/AppLayout'));
const Dashboard = lazy(() => import('./components/dashboard/Dashboard'));
const KPIDashboard = lazy(() => import('./components/dashboard/KPIDashboard'));
const ProjectList = lazy(() => import('./components/projects/ProjectList'));
const ProjectDetail = lazy(() => import('./components/projects/ProjectDetail'));
const ProjectForm = lazy(() => import('./components/projects/ProjectForm'));
const UserProfile = lazy(() => import('./components/auth/UserProfile'));
const TeamManagement = lazy(() => import('./components/teams/TeamManagement'));
const AppLayout = lazy(() => import('./components/layout/AppLayout').then(m => ({ default: m.AppLayout })));
const Dashboard = lazy(() => import('./components/dashboard/Dashboard').then(m => ({ default: m.Dashboard })));
const KPIDashboard = lazy(() => import('./components/dashboard/KPIDashboard').then(m => ({ default: m.KPIDashboard })));
const ProjectList = lazy(() => import('./components/projects/ProjectList').then(m => ({ default: m.ProjectList })));
const ProjectDetail = lazy(() => import('./components/projects/ProjectDetail').then(m => ({ default: m.ProjectDetail })));
const ProjectForm = lazy(() => import('./components/projects/ProjectForm').then(m => ({ default: m.ProjectForm })));
const UserProfile = lazy(() => import('./components/auth/UserProfile').then(m => ({ default: m.UserProfile })));
const TeamManagement = lazy(() => import('./components/teams/TeamManagement').then(m => ({ default: m.TeamManagement })));
const Redirect = () => <Navigate href="/dashboard" />;

19
src/trpc/client.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/trpc/router';
import { loggerLink } from '@trpc/server/http';
export function createTRPCClient(url: string) {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({ url }),
loggerLink({
enabled: (op) =>
op.type === 'query' ||
op.type === 'mutation' ||
process.env.NODE_ENV === 'development',
}),
],
});
}
export { createQueryClient, QueryClient, QueryClientProvider } from '@tanstack/react-query';

13
src/trpc/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/trpc/router';
export const trpc = createTRPCReact<AppRouter>({
/**
* Options that will be used for the client.
* If you change this, you need to update the server configuration as well.
*/
transformer: undefined,
});
// Re-export the types
export type { AppRouter };