improve window management and title bar
@@ -91,6 +91,8 @@ type Stream struct {
|
||||
Members []string `json:"members,omitempty"`
|
||||
Particles []*StreamParticle `json:"particles"`
|
||||
UnseenCount int `json:"unseen_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type StreamStatus string
|
||||
@@ -381,6 +383,8 @@ func (h *Handler) StartupData(w http.ResponseWriter, r *http.Request) {
|
||||
Status: status,
|
||||
Members: h.getStreamMembers(r.Context(), sp, streamMembersMap),
|
||||
UnseenCount: unseenCounts[sp.ID],
|
||||
UpdatedAt: sp.UpdatedAt,
|
||||
CreatedAt: sp.CreatedAt,
|
||||
}
|
||||
|
||||
// Fetch child particles for this stream
|
||||
@@ -534,6 +538,8 @@ func (h *Handler) CreateStream(w http.ResponseWriter, r *http.Request) {
|
||||
Members: h.getStreamMembers(r.Context(), created, membersMap),
|
||||
Particles: []*StreamParticle{},
|
||||
UnseenCount: 0,
|
||||
UpdatedAt: created.UpdatedAt,
|
||||
CreatedAt: created.CreatedAt,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -603,6 +609,8 @@ func (h *Handler) GetStream(w http.ResponseWriter, r *http.Request) {
|
||||
Status: parseStreamStatus(streamData.Status),
|
||||
Members: h.getStreamMembers(r.Context(), sp, membersMap),
|
||||
UnseenCount: unseenCounts[sp.ID],
|
||||
UpdatedAt: sp.UpdatedAt,
|
||||
CreatedAt: sp.CreatedAt,
|
||||
}
|
||||
|
||||
// Fetch child particles
|
||||
@@ -760,6 +768,8 @@ func (h *Handler) UpdateStream(w http.ResponseWriter, r *http.Request) {
|
||||
Members: h.getStreamMembers(r.Context(), updated, membersMap),
|
||||
Particles: []*StreamParticle{},
|
||||
UnseenCount: unseenCounts[updated.ID],
|
||||
UpdatedAt: updated.UpdatedAt,
|
||||
CreatedAt: updated.CreatedAt,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 667 B |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -94,6 +94,8 @@ export interface Stream {
|
||||
members?: string[];
|
||||
particles: StreamParticle[];
|
||||
unseen_count: number;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Network {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export function WindowControls() {
|
||||
return (
|
||||
<div className="no-drag flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => window.electronWindow.close()}
|
||||
className="group flex h-3 w-3 items-center justify-center rounded-full bg-[#FF5F57] transition-colors hover:bg-[#FF5F57]/80"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="h-1.5 w-1.5 opacity-0 group-hover:opacity-100" viewBox="0 0 6 6" fill="none">
|
||||
<path d="M1 1L5 5M5 1L1 5" stroke="rgba(0,0,0,0.5)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.electronWindow.minimize()}
|
||||
className="group flex h-3 w-3 items-center justify-center rounded-full bg-[#FEBC2E] transition-colors hover:bg-[#FEBC2E]/80"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<svg className="h-1.5 w-1.5 opacity-0 group-hover:opacity-100" viewBox="0 0 6 6" fill="none">
|
||||
<path d="M1 3H5" stroke="rgba(0,0,0,0.5)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.electronWindow.maximize()}
|
||||
className="group flex h-3 w-3 items-center justify-center rounded-full bg-[#28C840] transition-colors hover:bg-[#28C840]/80"
|
||||
aria-label="Maximize"
|
||||
>
|
||||
<svg className="h-1.5 w-1.5 opacity-0 group-hover:opacity-100" viewBox="0 0 6 6" fill="none">
|
||||
<path d="M1 1.5L3 0.5L5 1.5V4.5L3 5.5L1 4.5V1.5Z" stroke="rgba(0,0,0,0.5)" strokeWidth="0.8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
interface Window {
|
||||
electronWindow: {
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
}
|
||||
@@ -14,13 +14,14 @@ function AudioLevelBars({ mediaStream }: { mediaStream: MediaStream }) {
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = new AudioContext();
|
||||
ctx.resume();
|
||||
const source = ctx.createMediaStreamSource(mediaStream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
audioRef.current = { analyser, ctx };
|
||||
|
||||
const dataArray = new Uint8Array(analyser.fftSize);
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
|
||||
function tick() {
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { StreamParticle } from "@/api/types";
|
||||
import { getParticleData } from "@/api/types";
|
||||
import {
|
||||
MessageSquare,
|
||||
Video,
|
||||
Mic,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ParticlePreviewProps {
|
||||
particle: StreamParticle;
|
||||
}
|
||||
|
||||
export function ParticlePreview({ particle }: ParticlePreviewProps) {
|
||||
switch (particle.type) {
|
||||
case "text": {
|
||||
const data = getParticleData(particle, "text");
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquare className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{data.content}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "media": {
|
||||
const data = getParticleData(particle, "media");
|
||||
const isVideo = data.mime_type.startsWith("video");
|
||||
const Icon = isVideo ? Video : Mic;
|
||||
const label = isVideo ? "Video" : "Audio";
|
||||
const seconds = Math.round(data.duration_ms / 1000);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{label} · {seconds}s
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "quest": {
|
||||
const data = getParticleData(particle, "quest");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{data.title}
|
||||
{data.status && ` · ${data.status}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "paper": {
|
||||
const data = getParticleData(particle, "paper");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{data.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "file": {
|
||||
const data = getParticleData(particle, "file");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{data.filename}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "folder": {
|
||||
const data = getParticleData(particle, "folder");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderIcon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { flattenStreams } from "@/lib/stream-utils";
|
||||
import { formatDistanceToNow } from "@/lib/time-utils";
|
||||
import { ParticlePreview } from "./particle-preview";
|
||||
|
||||
export function StreamList() {
|
||||
const networks = useAppStore((s) => s.networks);
|
||||
@@ -20,22 +22,35 @@ export function StreamList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-2 gap-3 p-4">
|
||||
{streams.map((stream) => {
|
||||
const lastParticle =
|
||||
stream.particles.length > 0
|
||||
? stream.particles[stream.particles.length - 1]
|
||||
: null;
|
||||
|
||||
const timeSource = lastParticle?.created_at ?? stream.updated_at;
|
||||
|
||||
return (
|
||||
<button
|
||||
<Card
|
||||
key={stream.id}
|
||||
size="sm"
|
||||
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/streams/${stream.id}`)}
|
||||
className="hover:bg-accent/50 flex items-center gap-3 border-b px-4 py-3 text-left transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardContent className="min-h-[3rem]">
|
||||
{lastParticle ? (
|
||||
<ParticlePreview particle={lastParticle} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
No messages yet
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{stream.name}
|
||||
</span>
|
||||
{stream.unseen_count > 0 && (
|
||||
@@ -44,18 +59,20 @@ export function StreamList() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{stream.description && (
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||
{stream.description}
|
||||
</p>
|
||||
)}
|
||||
{lastParticle && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{formatDistanceToNow(lastParticle.created_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CardContent>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{lastParticle && (
|
||||
<>
|
||||
{lastParticle.created_by_email.split("@")[0]}
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
{timeSource && formatDistanceToNow(timeSource)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,9 @@ export function flattenStreams(
|
||||
}
|
||||
|
||||
function getLatestParticleTime(stream: Stream): number {
|
||||
if (stream.particles.length === 0) return 0;
|
||||
if (stream.particles.length === 0) {
|
||||
return new Date(stream.updated_at).getTime() || 0;
|
||||
}
|
||||
const last = stream.particles[stream.particles.length - 1];
|
||||
return new Date(last.created_at).getTime();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, session } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, session } from 'electron';
|
||||
import path from 'node:path';
|
||||
import started from 'electron-squirrel-startup';
|
||||
|
||||
@@ -12,6 +12,8 @@ const createWindow = () => {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
resizable: false,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
@@ -30,6 +32,22 @@ const createWindow = () => {
|
||||
mainWindow.webContents.openDevTools();
|
||||
};
|
||||
|
||||
// Window control IPC handlers
|
||||
ipcMain.on('window:minimize', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize();
|
||||
});
|
||||
ipcMain.on('window:maximize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win?.isMaximized()) {
|
||||
win.unmaximize();
|
||||
} else {
|
||||
win?.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.on('window:close', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close();
|
||||
});
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useAppStore } from "@/stores/app-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { StreamList } from "@/features/streams/stream-list";
|
||||
import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
|
||||
export function StreamsPage() {
|
||||
const networks = useAppStore((s) => s.networks);
|
||||
@@ -28,13 +29,15 @@ export function StreamsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
{/* Top bar — draggable for frameless window */}
|
||||
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
|
||||
<WindowControls />
|
||||
|
||||
<Select
|
||||
value={selectedNetworkId ?? undefined}
|
||||
onValueChange={(value) => setSelectedNetwork(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="no-drag">
|
||||
<SelectValue placeholder="Select a network" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -67,7 +70,7 @@ export function StreamsPage() {
|
||||
|
||||
{selectedNetworkId && (
|
||||
<CreateStreamDialog networkId={selectedNetworkId}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" className="no-drag">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New Stream
|
||||
</Button>
|
||||
@@ -81,7 +84,7 @@ export function StreamsPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
className="no-drag text-muted-foreground"
|
||||
onClick={signOut}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
// See the Electron documentation for details on how to use preload scripts:
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronWindow', {
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
maximize: () => ipcRenderer.send('window:maximize'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
});
|
||||
|
||||
@@ -120,3 +120,11 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Frameless window drag regions */
|
||||
.drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||