Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 94 additions & 57 deletions src/components/ui/FileUpload.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ let isDragging = $state(false);
let fileInputElement = $state<HTMLInputElement>();
let loadingExampleId: string | null = $state(null);

const EXAMPLE_CATEGORY_ORDER = ["photonics", "digital", "demo"] as const;
const EXAMPLE_CATEGORY_LABELS: Record<(typeof EXAMPLE_CATEGORY_ORDER)[number], string> = {
photonics: "Photonics",
digital: "Digital",
demo: "Demo",
};

const examplesByCategory = $derived(
EXAMPLE_CATEGORY_ORDER.map((category) => ({
category,
label: EXAMPLE_CATEGORY_LABELS[category],
examples: EXAMPLES.filter((example) => example.category === category),
})).filter((group) => group.examples.length > 0),
);

/**
* Sync file to collaboration session
* - If in session as host: upload file to session for immediate sync
Expand Down Expand Up @@ -220,65 +235,70 @@ function triggerFileInput() {
<!-- Examples section -->
<div class="examples-section">
<h3 class="examples-title">Or try an example:</h3>
<div class="examples-grid">
{#each EXAMPLES as example (example.id)}
<button
class="example-card"
class:loading={loadingExampleId === example.id}
disabled={loadingExampleId !== null}
onclick={(e) => handleExampleClick(example, e)}
>
{#if example.previewOverviewUrl}
<div class="example-preview">
<img
src={example.previewOverviewUrl}
alt={example.name}
loading="lazy"
/>
</div>
{:else}
<div class="example-icon">
{#if example.category === 'photonics'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="3" stroke-width="2"/>
<path stroke-width="2" d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
{:else if example.category === 'digital'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2" stroke-width="2"/>
<path stroke-width="2" d="M9 9h6M9 12h6M9 15h4"/>
</svg>
{#each examplesByCategory as group (group.category)}
<div class="example-category-group">
<h4 class="example-category-title">{group.label}</h4>
<div class="examples-grid">
{#each group.examples as example (example.id)}
<button
class="example-card"
class:loading={loadingExampleId === example.id}
disabled={loadingExampleId !== null}
onclick={(e) => handleExampleClick(example, e)}
>
{#if example.previewOverviewUrl}
<div class="example-preview">
<img
src={example.previewOverviewUrl}
alt={example.name}
loading="lazy"
/>
</div>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2"/>
<path stroke-width="2" d="M3 9h18M9 21V9"/>
</svg>
<div class="example-icon">
{#if example.category === 'photonics'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="3" stroke-width="2"/>
<path stroke-width="2" d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
{:else if example.category === 'digital'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2" stroke-width="2"/>
<path stroke-width="2" d="M9 9h6M9 12h6M9 15h4"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2"/>
<path stroke-width="2" d="M3 9h18M9 21V9"/>
</svg>
{/if}
</div>
{/if}
</div>
{/if}
<div class="example-info">
<span class="example-name">{example.name}</span>
<span class="example-desc">{example.description}</span>
<span class="example-meta">
{example.fileSizeMB < 1 ? `${Math.round(example.fileSizeMB * 1000)} KB` : `${example.fileSizeMB.toFixed(1)} MB`}
·
<a
href={example.sourceUrl}
target="_blank"
rel="noopener noreferrer"
class="example-source-link"
onclick={(e) => e.stopPropagation()}
>{example.attribution}</a>
</span>
</div>
{#if loadingExampleId === example.id}
<div class="example-loading">
<div class="loading-spinner"></div>
</div>
{/if}
</button>
{/each}
</div>
<div class="example-info">
<span class="example-name">{example.name}</span>
<span class="example-desc">{example.description}</span>
<span class="example-meta">
{example.fileSizeMB < 1 ? `${Math.round(example.fileSizeMB * 1000)} KB` : `${example.fileSizeMB.toFixed(1)} MB`}
·
<a
href={example.sourceUrl}
target="_blank"
rel="noopener noreferrer"
class="example-source-link"
onclick={(e) => e.stopPropagation()}
>{example.attribution}</a>
</span>
</div>
{#if loadingExampleId === example.id}
<div class="example-loading">
<div class="loading-spinner"></div>
</div>
{/if}
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/if}
Expand Down Expand Up @@ -377,6 +397,23 @@ function triggerFileInput() {
letter-spacing: 0.05em;
}

.example-category-group {
margin-top: 1rem;
}

.example-category-group:first-of-type {
margin-top: 0;
}

.example-category-title {
margin: 0 0 0.5rem 0;
font-size: 0.75rem;
font-weight: 600;
color: #9aa0a6;
text-transform: uppercase;
letter-spacing: 0.04em;
}

.examples-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
Expand Down
29 changes: 18 additions & 11 deletions src/lib/collaboration/CommentSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class CommentSync {

setCallbacks(callbacks: CommentSyncCallbacks): void {
this.callbacks = callbacks;
this.emitCurrentState();
}

/**
Expand Down Expand Up @@ -68,18 +69,8 @@ export class CommentSync {

// Trigger initial callbacks
const initialComments = this.commentsArray.toArray();
const initialPermissions = this.sessionMap.get("commentPermissions") as
| CommentPermissions
| undefined;

this.lastCommentCount = initialComments.length;

if (initialComments.length > 0) {
this.callbacks.onCommentsChanged?.(initialComments);
}
if (initialPermissions) {
this.callbacks.onPermissionsChanged?.(initialPermissions);
}
this.emitCurrentState();

// Start heartbeat polling as fallback for missed Y.js observer events
this.startHeartbeat();
Expand Down Expand Up @@ -119,6 +110,22 @@ export class CommentSync {
}
}

/**
* Emit current comments and permissions to callbacks.
* Used on initialization and when callbacks are set after sync has already happened.
*/
private emitCurrentState(): void {
if (!this.commentsArray || !this.sessionMap) return;

const comments = this.commentsArray.toArray();
this.callbacks.onCommentsChanged?.(comments);

const permissions = this.sessionMap.get("commentPermissions") as CommentPermissions | undefined;
if (permissions) {
this.callbacks.onPermissionsChanged?.(permissions);
}
}

/**
* Add a comment to the shared array
*/
Expand Down
54 changes: 40 additions & 14 deletions src/lib/collaboration/ParticipantManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const ANIMALS = [
const PARTICIPANT_HEARTBEAT_INTERVAL = 5000; // 5 seconds

// Grace period before considering a participant stale (milliseconds)
const PARTICIPANT_STALE_THRESHOLD = 15000; // 15 seconds
const PARTICIPANT_STALE_THRESHOLD = 30000; // 30 seconds

export class ParticipantManager {
private yjsProvider: YjsProvider;
Expand Down Expand Up @@ -163,18 +163,23 @@ export class ParticipantManager {
private updateLastSeen(): void {
const sessionMap = this.yjsProvider.getMap<any>("session");
const participants = (sessionMap.get("participants") as YjsParticipant[]) || [];
const now = Date.now();

let foundSelf = false;
const updatedParticipants = participants.map((p) => {
if (p.userId === this.userId) {
return { ...p, lastSeen: Date.now() };
foundSelf = true;
return { ...p, lastSeen: now };
}
return p;
});

// Only update if we found ourselves
if (updatedParticipants.some((p) => p.userId === this.userId)) {
sessionMap.set("participants", updatedParticipants);
}
// Self-heal participant list if our entry was dropped by a concurrent write.
const participantsWithSelf = foundSelf
? updatedParticipants
: [...updatedParticipants, this.createLocalParticipant(now, now)];

sessionMap.set("participants", participantsWithSelf);
}

/**
Expand Down Expand Up @@ -204,10 +209,21 @@ export class ParticipantManager {
const sessionMap = this.yjsProvider.getMap<any>("session");
const participants = (sessionMap.get("participants") as YjsParticipant[]) || [];
const now = Date.now();
const awareness = this.yjsProvider.getAwareness();
const awarenessUserIds = new Set<string>();

for (const [, state] of awareness.getStates()) {
const userId = (state as { userId?: string } | null | undefined)?.userId;
if (typeof userId === "string" && userId.length > 0) {
awarenessUserIds.add(userId);
}
}

const activeParticipants = participants.filter((p) => {
// Don't remove self
if (p.userId === this.userId) return true;
// Keep participants that are still present in awareness.
if (awarenessUserIds.has(p.userId)) return true;

// Check if stale
const elapsed = now - (p.lastSeen || p.joinedAt);
Expand Down Expand Up @@ -314,15 +330,8 @@ export class ParticipantManager {
const displayName = this.generateUniqueDisplayName(this.userId, existingNames);
this.localDisplayName = displayName;

// Create participant entry
const now = Date.now();
const participant: YjsParticipant = {
userId: this.userId,
displayName,
joinedAt: now,
lastSeen: now,
color: this.localColor,
};
const participant = this.createLocalParticipant(now, now, displayName);

// Add to participants array
this.yjsProvider.getDoc().transact(() => {
Expand All @@ -331,6 +340,23 @@ export class ParticipantManager {
});
}

/**
* Create a participant record for the local user.
*/
private createLocalParticipant(
joinedAt: number,
lastSeen: number,
displayName: string | null = this.localDisplayName,
): YjsParticipant {
return {
userId: this.userId,
displayName: displayName || this.generateBaseName(this.userId),
joinedAt,
lastSeen,
color: this.localColor,
};
}

/**
* Re-register existing participant (for host reclaim after refresh)
* Finds existing participant entry and updates local state without Y.js write
Expand Down
11 changes: 9 additions & 2 deletions src/lib/collaboration/ViewportSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
AwarenessState,
CollaborativeViewportState,
ParticipantViewport,
YjsParticipant,
YjsSessionData,
} from "./types";
import type { YjsProvider } from "./YjsProvider";
Expand Down Expand Up @@ -338,6 +339,9 @@ export class ViewportSync {
const states = awareness.getStates();
const broadcastHostId = this.getBroadcastHostId();
const isFollowing = this.shouldFollowHost();
const sessionMap = this.getSessionMap();
const participants = (sessionMap.get("participants") as YjsParticipant[] | undefined) ?? [];
const participantById = new Map(participants.map((p) => [p.userId, p]));

const viewports: ParticipantViewport[] = [];

Expand All @@ -354,11 +358,14 @@ export class ViewportSync {
}

const isFollowed = isFollowing && awarenessState.userId === broadcastHostId;
const participant = participantById.get(awarenessState.userId);
const displayName = awarenessState.displayName || participant?.displayName || "Unknown";
const color = awarenessState.color || participant?.color || "#888888";

viewports.push({
userId: awarenessState.userId,
displayName: awarenessState.displayName || "Unknown",
color: awarenessState.color || "#888888",
displayName,
color,
viewport: awarenessState.viewport,
isFollowed,
});
Expand Down