improve window management and title bar

This commit is contained in:
talksik
2026-02-21 12:42:43 -08:00
parent 59b973802d
commit 8546260493
26 changed files with 229 additions and 25 deletions
+10
View File
@@ -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")
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

+2
View File
@@ -94,6 +94,8 @@ export interface Stream {
members?: string[];
particles: StreamParticle[];
unseen_count: number;
updated_at: string;
created_at: string;
}
export interface Network {
+33
View File
@@ -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>
);
}
+7
View File
@@ -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;
}
}
+34 -17
View File
@@ -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>
+3 -1
View File
@@ -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();
}
+19 -1
View File
@@ -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.
+8 -5
View File
@@ -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" />
+7
View File
@@ -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'),
});
+8
View File
@@ -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;
}