improve window management and title bar
@@ -91,6 +91,8 @@ type Stream struct {
|
|||||||
Members []string `json:"members,omitempty"`
|
Members []string `json:"members,omitempty"`
|
||||||
Particles []*StreamParticle `json:"particles"`
|
Particles []*StreamParticle `json:"particles"`
|
||||||
UnseenCount int `json:"unseen_count"`
|
UnseenCount int `json:"unseen_count"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamStatus string
|
type StreamStatus string
|
||||||
@@ -381,6 +383,8 @@ func (h *Handler) StartupData(w http.ResponseWriter, r *http.Request) {
|
|||||||
Status: status,
|
Status: status,
|
||||||
Members: h.getStreamMembers(r.Context(), sp, streamMembersMap),
|
Members: h.getStreamMembers(r.Context(), sp, streamMembersMap),
|
||||||
UnseenCount: unseenCounts[sp.ID],
|
UnseenCount: unseenCounts[sp.ID],
|
||||||
|
UpdatedAt: sp.UpdatedAt,
|
||||||
|
CreatedAt: sp.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch child particles for this stream
|
// 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),
|
Members: h.getStreamMembers(r.Context(), created, membersMap),
|
||||||
Particles: []*StreamParticle{},
|
Particles: []*StreamParticle{},
|
||||||
UnseenCount: 0,
|
UnseenCount: 0,
|
||||||
|
UpdatedAt: created.UpdatedAt,
|
||||||
|
CreatedAt: created.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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),
|
Status: parseStreamStatus(streamData.Status),
|
||||||
Members: h.getStreamMembers(r.Context(), sp, membersMap),
|
Members: h.getStreamMembers(r.Context(), sp, membersMap),
|
||||||
UnseenCount: unseenCounts[sp.ID],
|
UnseenCount: unseenCounts[sp.ID],
|
||||||
|
UpdatedAt: sp.UpdatedAt,
|
||||||
|
CreatedAt: sp.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch child particles
|
// Fetch child particles
|
||||||
@@ -760,6 +768,8 @@ func (h *Handler) UpdateStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
Members: h.getStreamMembers(r.Context(), updated, membersMap),
|
Members: h.getStreamMembers(r.Context(), updated, membersMap),
|
||||||
Particles: []*StreamParticle{},
|
Particles: []*StreamParticle{},
|
||||||
UnseenCount: unseenCounts[updated.ID],
|
UnseenCount: unseenCounts[updated.ID],
|
||||||
|
UpdatedAt: updated.UpdatedAt,
|
||||||
|
CreatedAt: updated.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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[];
|
members?: string[];
|
||||||
particles: StreamParticle[];
|
particles: StreamParticle[];
|
||||||
unseen_count: number;
|
unseen_count: number;
|
||||||
|
updated_at: string;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Network {
|
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(() => {
|
useEffect(() => {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
|
ctx.resume();
|
||||||
const source = ctx.createMediaStreamSource(mediaStream);
|
const source = ctx.createMediaStreamSource(mediaStream);
|
||||||
const analyser = ctx.createAnalyser();
|
const analyser = ctx.createAnalyser();
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = 256;
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
audioRef.current = { analyser, ctx };
|
audioRef.current = { analyser, ctx };
|
||||||
|
|
||||||
const dataArray = new Uint8Array(analyser.fftSize);
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { flattenStreams } from "@/lib/stream-utils";
|
import { flattenStreams } from "@/lib/stream-utils";
|
||||||
import { formatDistanceToNow } from "@/lib/time-utils";
|
import { formatDistanceToNow } from "@/lib/time-utils";
|
||||||
|
import { ParticlePreview } from "./particle-preview";
|
||||||
|
|
||||||
export function StreamList() {
|
export function StreamList() {
|
||||||
const networks = useAppStore((s) => s.networks);
|
const networks = useAppStore((s) => s.networks);
|
||||||
@@ -20,22 +22,35 @@ export function StreamList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="grid grid-cols-2 gap-3 p-4">
|
||||||
{streams.map((stream) => {
|
{streams.map((stream) => {
|
||||||
const lastParticle =
|
const lastParticle =
|
||||||
stream.particles.length > 0
|
stream.particles.length > 0
|
||||||
? stream.particles[stream.particles.length - 1]
|
? stream.particles[stream.particles.length - 1]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const timeSource = lastParticle?.created_at ?? stream.updated_at;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Card
|
||||||
key={stream.id}
|
key={stream.id}
|
||||||
|
size="sm"
|
||||||
|
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
||||||
onClick={() => navigate(`/streams/${stream.id}`)}
|
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">
|
<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}
|
{stream.name}
|
||||||
</span>
|
</span>
|
||||||
{stream.unseen_count > 0 && (
|
{stream.unseen_count > 0 && (
|
||||||
@@ -44,18 +59,20 @@ export function StreamList() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{stream.description && (
|
</CardContent>
|
||||||
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
|
||||||
{stream.description}
|
<CardContent className="pt-0">
|
||||||
</p>
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
)}
|
{lastParticle && (
|
||||||
{lastParticle && (
|
<>
|
||||||
<div className="text-muted-foreground mt-1 text-xs">
|
{lastParticle.created_by_email.split("@")[0]}
|
||||||
{formatDistanceToNow(lastParticle.created_at)}
|
{" · "}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
{timeSource && formatDistanceToNow(timeSource)}
|
||||||
</button>
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export function flattenStreams(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLatestParticleTime(stream: Stream): number {
|
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];
|
const last = stream.particles[stream.particles.length - 1];
|
||||||
return new Date(last.created_at).getTime();
|
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 path from 'node:path';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ const createWindow = () => {
|
|||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
|
resizable: false,
|
||||||
|
frame: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
},
|
},
|
||||||
@@ -30,6 +32,22 @@ const createWindow = () => {
|
|||||||
mainWindow.webContents.openDevTools();
|
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
|
// This method will be called when Electron has finished
|
||||||
// initialization and is ready to create browser windows.
|
// initialization and is ready to create browser windows.
|
||||||
// Some APIs can only be used after this event occurs.
|
// 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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { StreamList } from "@/features/streams/stream-list";
|
import { StreamList } from "@/features/streams/stream-list";
|
||||||
import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
|
import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
|
||||||
|
import { WindowControls } from "@/components/window-controls";
|
||||||
|
|
||||||
export function StreamsPage() {
|
export function StreamsPage() {
|
||||||
const networks = useAppStore((s) => s.networks);
|
const networks = useAppStore((s) => s.networks);
|
||||||
@@ -28,13 +29,15 @@ export function StreamsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
{/* Top bar */}
|
{/* Top bar — draggable for frameless window */}
|
||||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
|
||||||
|
<WindowControls />
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedNetworkId ?? undefined}
|
value={selectedNetworkId ?? undefined}
|
||||||
onValueChange={(value) => setSelectedNetwork(value)}
|
onValueChange={(value) => setSelectedNetwork(value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="no-drag">
|
||||||
<SelectValue placeholder="Select a network" />
|
<SelectValue placeholder="Select a network" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -67,7 +70,7 @@ export function StreamsPage() {
|
|||||||
|
|
||||||
{selectedNetworkId && (
|
{selectedNetworkId && (
|
||||||
<CreateStreamDialog networkId={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" />
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
New Stream
|
New Stream
|
||||||
</Button>
|
</Button>
|
||||||
@@ -81,7 +84,7 @@ export function StreamsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-muted-foreground"
|
className="no-drag text-muted-foreground"
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
>
|
>
|
||||||
<LogOut className="h-3.5 w-3.5" />
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -1,2 +1,9 @@
|
|||||||
// See the Electron documentation for details on how to use preload scripts:
|
// See the Electron documentation for details on how to use preload scripts:
|
||||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#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;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Frameless window drag regions */
|
||||||
|
.drag-region {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|||||||