598 lines
20 KiB
TypeScript
598 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
import Image from "next/image";
|
|
import { APPS, APP_BY_ID, createWebsiteApp, type AppDef } from "./apps";
|
|
import WinWindow, { type WinState } from "./WinWindow";
|
|
import Clock from "./Clock";
|
|
import { useTheme } from "./useTheme";
|
|
import { useDesktopIcons } from "./useDesktopIcons";
|
|
import { WALLPAPER, friends } from "../data";
|
|
|
|
// The panel sits at `bottom: 10px` with a height of 44px, so its top edge is
|
|
// 54px above the viewport bottom. Windows clamp 5px above that (59), so a
|
|
// dragged or maximized window stops just above the taskbar.
|
|
const TASKBAR_HEIGHT = 59;
|
|
|
|
const LEFT_APPS = APPS.filter((a) => a.id !== "services" && a.id !== "friends");
|
|
|
|
interface Shortcut {
|
|
id: string;
|
|
icon: ReactNode;
|
|
label: string;
|
|
color: string;
|
|
url?: string;
|
|
appId?: string;
|
|
desc?: string;
|
|
image?: string;
|
|
/** Show the real site in an iframe. Only for sites that allow framing. */
|
|
embed?: boolean;
|
|
/** Frame src when it differs from `url` (e.g. a local mirror of a blocking site). */
|
|
embedUrl?: string;
|
|
}
|
|
|
|
interface IconEntry {
|
|
id: string;
|
|
label: string;
|
|
title: string;
|
|
glyph: ReactNode;
|
|
color?: string;
|
|
image?: string;
|
|
onOpen: () => void;
|
|
}
|
|
|
|
// `embed: true` renders the real site in an iframe, which the browser refuses
|
|
// (blank window) when the server sends X-Frame-Options / frame-ancestors.
|
|
// Llama.cpp and Jellyfin allow framing out of the box; Navidrome and Forgejo
|
|
// need to be told to stop sending a restrictive value:
|
|
// - Navidrome: `ND_HTTPHEADERS_FRAMEOPTIONS=ALLOWALL`
|
|
// - Forgejo: `[cors] X_FRAME_OPTIONS = unset` (Forgejo reads this from
|
|
// [cors], NOT [security]) then restart Forgejo.
|
|
// Any X-Frame-Options value a browser doesn't recognise is ignored, so the
|
|
// frame is allowed. These live on the service hosts — without them the windows
|
|
// go blank again.
|
|
const SERVICE_SHORTCUTS: Shortcut[] = [
|
|
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty", color: "#1257a0", desc: "Server Management" },
|
|
{ id: "svc-ai", icon: "🦾", label: "Llama.cpp", url: "https://ai.dvorakmortonxd.dev/", color: "#157347", desc: "Local AI Server", embed: true },
|
|
{ id: "svc-jellyfin", icon: "🎬", label: "Jellyfin", url: "https://jelly.dvorakmortonxd.dev/", color: "#6a4fb3", desc: "Local Media Server", embed: true },
|
|
{ id: "svc-smp", icon: "🎮", label: "SMP", appId: "smp", color: "#c0392b", desc: "My Survival Minecraft Server" },
|
|
{ id: "svc-navidrome", icon: "🎸", label: "Navidrome", url: "https://navidrome.dvorakmortonxd.dev/", color: "#b3005f", desc: "Music Streaming", embed: true },
|
|
{ id: "svc-gitdxd", icon: "🐙", label: "GitDXD", url: "https://git.dvorakmortonxd.dev", color: "#6a4fb3", desc: "Best Git Hosting OAT", embed: true },
|
|
];
|
|
|
|
// Friends' sites, opened in a live window when they allow framing. bukovinafilip
|
|
// and vlcak.me send no framing headers, so `url` (== `realUrl`) is framed
|
|
// directly. skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so
|
|
// `proxy: true` routes it through /api/frame, which strips the framing headers
|
|
// and serves the live page from our origin. The www button always opens the live
|
|
// site (`realUrl`).
|
|
const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({
|
|
id: `friend-${i}`,
|
|
label: f.name,
|
|
url: f.realUrl || f.url,
|
|
embedUrl:
|
|
f.proxy && f.realUrl
|
|
? `/api/frame?url=${encodeURIComponent(f.realUrl)}`
|
|
: f.url,
|
|
embed: f.iframe,
|
|
color: "#c0392b",
|
|
icon: "🫂",
|
|
image: f.img,
|
|
desc: f.desc,
|
|
}));
|
|
|
|
/** Stable list of every desktop icon, used to seed the placement grid. */
|
|
const ICON_KEYS = [
|
|
...LEFT_APPS.map((a) => a.id),
|
|
...SERVICE_SHORTCUTS.map((s) => s.id),
|
|
...FRIEND_SHORTCUTS.map((f) => f.id),
|
|
];
|
|
|
|
export default function WindowsDesktop() {
|
|
const { theme, toggle } = useTheme();
|
|
|
|
const [wins, setWins] = useState<WinState[]>(() => [
|
|
{
|
|
id: 1,
|
|
appId: "welcome",
|
|
x: 110,
|
|
y: 64,
|
|
w: 620,
|
|
h: 430,
|
|
z: 10,
|
|
minimized: false,
|
|
maximized: false,
|
|
centered: true,
|
|
},
|
|
]);
|
|
const [activeId, setActiveId] = useState<number | null>(1);
|
|
const [startOpen, setStartOpen] = useState(false);
|
|
const [startClosing, setStartClosing] = useState(false);
|
|
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
|
|
const [closingIds, setClosingIds] = useState<number[]>([]);
|
|
const [minimizingIds, setMinimizingIds] = useState<number[]>([]);
|
|
|
|
const leftApps = LEFT_APPS;
|
|
const serviceItems = SERVICE_SHORTCUTS;
|
|
const friendItems = FRIEND_SHORTCUTS;
|
|
|
|
const desktopRef = useRef<HTMLDivElement>(null);
|
|
const tbRef = useRef<HTMLDivElement>(null);
|
|
const { positions, dragId, onPointerDown, consumeDrag } =
|
|
useDesktopIcons(desktopRef, ICON_KEYS);
|
|
|
|
// When the taskbar buttons overflow, keep the focused window's button visible
|
|
// so opening an app doesn't leave its button off the (scrollable) right edge.
|
|
useEffect(() => {
|
|
const bar = tbRef.current;
|
|
const active = bar?.querySelector<HTMLElement>(".win-tb-btn.active");
|
|
if (!bar || !active) return;
|
|
const barRect = bar.getBoundingClientRect();
|
|
const btnRect = active.getBoundingClientRect();
|
|
if (btnRect.left < barRect.left) {
|
|
bar.scrollLeft += btnRect.left - barRect.left;
|
|
} else if (btnRect.right > barRect.right) {
|
|
bar.scrollLeft += btnRect.right - barRect.right;
|
|
}
|
|
}, [activeId]);
|
|
|
|
const [dynamicApps, setDynamicApps] = useState<Record<string, AppDef>>({});
|
|
const getApp = (id: string): AppDef | undefined => APP_BY_ID[id] ?? dynamicApps[id];
|
|
|
|
const idRef = useRef(2);
|
|
const zRef = useRef(11);
|
|
|
|
const focus = (id: number) => {
|
|
setActiveId(id);
|
|
setWins((prev) =>
|
|
prev.map((w) => (w.id === id ? { ...w, z: ++zRef.current } : w))
|
|
);
|
|
};
|
|
|
|
// Desktop icons behave like a real desktop: the first click selects the
|
|
// icon, a second click on the same icon launches it.
|
|
const activateIcon = (id: string, action: () => void) => {
|
|
if (selectedIcon === id) {
|
|
setSelectedIcon(null);
|
|
action();
|
|
} else {
|
|
setSelectedIcon(id);
|
|
}
|
|
};
|
|
|
|
const openAppDef = (app: AppDef) => {
|
|
if (startOpen) setStartClosing(true);
|
|
const existing = wins.find((w) => w.appId === app.id);
|
|
if (existing) {
|
|
setWins((prev) =>
|
|
prev.map((w) =>
|
|
w.id === existing.id ? { ...w, minimized: false, z: ++zRef.current } : w
|
|
)
|
|
);
|
|
setActiveId(existing.id);
|
|
return;
|
|
}
|
|
const id = idRef.current++;
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight - TASKBAR_HEIGHT;
|
|
// Fit the window to the viewport so it never opens off-screen or under the
|
|
// panel on a narrow/short monitor.
|
|
const w = Math.min(app.width, Math.max(260, vw - 20));
|
|
const h = Math.min(app.height, Math.max(150, vh - 20));
|
|
const cascade = Math.min(wins.length, 8) * 30;
|
|
let x = Math.round((vw - w) / 2) + cascade;
|
|
let y = Math.round((vh - h) / 2) + cascade;
|
|
x = Math.max(10, Math.min(x, vw - w - 10));
|
|
y = Math.max(10, Math.min(y, vh - h - 10));
|
|
setWins((prev) => [
|
|
...prev,
|
|
{
|
|
id,
|
|
appId: app.id,
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
z: ++zRef.current,
|
|
minimized: false,
|
|
maximized: false,
|
|
centered: false,
|
|
},
|
|
]);
|
|
setActiveId(id);
|
|
};
|
|
|
|
const openApp = (appId: string) => {
|
|
const app = getApp(appId);
|
|
if (app) openAppDef(app);
|
|
};
|
|
|
|
// Service and friend shortcuts either open an app (Proxmox) or an in-desktop
|
|
// window representing the external website, with a www button to actually go
|
|
// there.
|
|
const openShortcut = (s: Shortcut) => {
|
|
if (s.appId) {
|
|
openApp(s.appId);
|
|
return;
|
|
}
|
|
if (!s.url) return;
|
|
const id = `web:${s.url}`;
|
|
const app =
|
|
getApp(id) ??
|
|
createWebsiteApp({
|
|
id,
|
|
title: s.label,
|
|
url: s.url,
|
|
desc: s.desc,
|
|
icon: s.icon,
|
|
image: s.image,
|
|
embed: s.embed,
|
|
embedUrl: s.embedUrl,
|
|
});
|
|
setDynamicApps((prev) => (prev[id] ? prev : { ...prev, [id]: app }));
|
|
openAppDef(app);
|
|
};
|
|
|
|
const closeStart = () => {
|
|
if (startOpen && !startClosing) setStartClosing(true);
|
|
};
|
|
|
|
const toggleStart = () => {
|
|
if (!startOpen) {
|
|
setStartClosing(false);
|
|
setStartOpen(true);
|
|
} else if (startClosing) {
|
|
setStartClosing(false);
|
|
} else {
|
|
setStartClosing(true);
|
|
}
|
|
};
|
|
|
|
// Animate the window out first, then drop it from state.
|
|
const closeWin = (id: number) =>
|
|
setClosingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
|
const finishClose = (id: number) => {
|
|
setWins((prev) => prev.filter((w) => w.id !== id));
|
|
setClosingIds((prev) => prev.filter((x) => x !== id));
|
|
};
|
|
// Minimize plays the same animation as closing, then hides the window.
|
|
const minimizeWin = (id: number) =>
|
|
setMinimizingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
|
const finishMinimize = (id: number) => {
|
|
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
|
|
setMinimizingIds((prev) => prev.filter((x) => x !== id));
|
|
};
|
|
const toggleMax = (id: number) =>
|
|
setWins((prev) =>
|
|
prev.map((w) => (w.id === id ? { ...w, maximized: !w.maximized } : w))
|
|
);
|
|
const moveWin = (id: number, x: number, y: number) => {
|
|
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
|
|
const vh = (typeof window !== "undefined" ? window.innerHeight : 800) - TASKBAR_HEIGHT;
|
|
setWins((prev) =>
|
|
prev.map((w) => {
|
|
if (w.id !== id) return w;
|
|
// Keep the whole window on-screen: no dragging it off any edge or under
|
|
// the panel. (max() guards the case where the window is bigger than the
|
|
// viewport, which the open/resize clamps normally prevent.)
|
|
const maxX = Math.max(0, vw - w.w);
|
|
const maxY = Math.max(0, vh - w.h);
|
|
return {
|
|
...w,
|
|
centered: false,
|
|
x: Math.max(0, Math.min(maxX, x)),
|
|
y: Math.max(0, Math.min(maxY, y)),
|
|
};
|
|
})
|
|
);
|
|
};
|
|
const resizeWin = (id: number, x: number, y: number, w: number, h: number) => {
|
|
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
|
|
const vh = (typeof window !== "undefined" ? window.innerHeight : 800) - TASKBAR_HEIGHT;
|
|
const nw = Math.min(w, Math.max(260, vw - 20));
|
|
const nh = Math.min(h, Math.max(150, vh - 20));
|
|
setWins((prev) =>
|
|
prev.map((win) =>
|
|
win.id === id ? { ...win, centered: false, x, y, w: nw, h: nh } : win
|
|
)
|
|
);
|
|
};
|
|
|
|
// Keep windows on-screen when the viewport shrinks (narrow monitor, or the
|
|
// user resizing the browser): clamp size to the viewport and pull anything
|
|
// that would fall off the edge back into view.
|
|
useEffect(() => {
|
|
const clampToViewport = () => {
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight - TASKBAR_HEIGHT;
|
|
setWins((prev) =>
|
|
prev.map((w) => {
|
|
if (w.maximized) return w;
|
|
const nw = Math.min(w.w, Math.max(260, vw - 20));
|
|
const nh = Math.min(w.h, Math.max(150, vh - 20));
|
|
if (w.centered) {
|
|
return nw === w.w && nh === w.h ? w : { ...w, w: nw, h: nh };
|
|
}
|
|
const nx = Math.max(10, Math.min(w.x, vw - nw - 10));
|
|
const ny = Math.max(10, Math.min(w.y, vh - nh - 10));
|
|
if (nw === w.w && nh === w.h && nx === w.x && ny === w.y) return w;
|
|
return { ...w, x: nx, y: ny, w: nw, h: nh };
|
|
})
|
|
);
|
|
};
|
|
window.addEventListener("resize", clampToViewport);
|
|
return () => window.removeEventListener("resize", clampToViewport);
|
|
}, []);
|
|
|
|
const taskbarClick = (w: WinState) => {
|
|
if (w.minimized) {
|
|
setWins((prev) =>
|
|
prev.map((x) => (x.id === w.id ? { ...x, minimized: false, z: ++zRef.current } : x))
|
|
);
|
|
setActiveId(w.id);
|
|
} else if (activeId === w.id) {
|
|
minimizeWin(w.id);
|
|
} else {
|
|
focus(w.id);
|
|
}
|
|
};
|
|
|
|
const wallpaper = `url('${WALLPAPER}')`;
|
|
|
|
const iconClick = (id: string, action: () => void) => {
|
|
if (consumeDrag()) return;
|
|
activateIcon(id, action);
|
|
};
|
|
|
|
const leftEntries: IconEntry[] = leftApps.map((app) => ({
|
|
id: app.id,
|
|
label: app.title,
|
|
title: `Open ${app.title}`,
|
|
glyph: app.icon,
|
|
color: app.color,
|
|
onOpen: () => openApp(app.id),
|
|
}));
|
|
const serviceEntries: IconEntry[] = serviceItems.map((s) => ({
|
|
id: s.id,
|
|
label: s.label,
|
|
title: s.label,
|
|
glyph: s.icon,
|
|
onOpen: () => openShortcut(s),
|
|
}));
|
|
const friendEntries: IconEntry[] = friendItems.map((f) => ({
|
|
id: f.id,
|
|
label: f.label,
|
|
title: f.label,
|
|
glyph: "🫂",
|
|
image: f.image,
|
|
onOpen: () => openShortcut(f),
|
|
}));
|
|
const allEntries = [...leftEntries, ...serviceEntries, ...friendEntries];
|
|
|
|
const renderIcon = (entry: IconEntry, interactive = true) => {
|
|
const pos = positions[entry.id];
|
|
return (
|
|
<button
|
|
key={entry.id}
|
|
type="button"
|
|
data-drag-id={entry.id}
|
|
className={
|
|
"win-icon" +
|
|
(selectedIcon === entry.id ? " selected" : "") +
|
|
(dragId === entry.id ? " dragging" : "")
|
|
}
|
|
style={interactive && pos ? { left: pos.x, top: pos.y } : undefined}
|
|
onPointerDown={interactive ? onPointerDown(entry.id) : undefined}
|
|
onClick={interactive ? () => iconClick(entry.id, entry.onOpen) : undefined}
|
|
tabIndex={interactive ? undefined : -1}
|
|
title={entry.title}
|
|
>
|
|
<span
|
|
className="win-icon-glyph"
|
|
style={entry.color ? { background: entry.color } : undefined}
|
|
>
|
|
{entry.image ? (
|
|
<Image
|
|
src={entry.image}
|
|
alt={entry.label}
|
|
width={38}
|
|
height={38}
|
|
className="win-icon-img"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
entry.glyph
|
|
)}
|
|
</span>
|
|
<span className="win-icon-label">{entry.label}</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={desktopRef}
|
|
className="win-desktop"
|
|
style={{ backgroundImage: wallpaper }}
|
|
onPointerDown={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
closeStart();
|
|
setSelectedIcon(null);
|
|
}
|
|
}}
|
|
>
|
|
<div className="win-wallpaper-tint" aria-hidden="true" />
|
|
|
|
{/* Desktop icons. The three stacks below are rendered once so the grid
|
|
can be measured from them, then the same icons are shown as an
|
|
absolutely positioned grid. */}
|
|
<div className="win-icons win-measure" aria-hidden="true">
|
|
{leftEntries.map((e) => renderIcon(e, false))}
|
|
</div>
|
|
<div className="win-icons win-icons-tr win-measure" aria-hidden="true">
|
|
{serviceEntries.map((e) => renderIcon(e, false))}
|
|
</div>
|
|
<div className="win-icons win-icons-br win-measure" aria-hidden="true">
|
|
{friendEntries.map((e) => renderIcon(e, false))}
|
|
</div>
|
|
|
|
<div className="win-icon-layer">{allEntries.map((e) => renderIcon(e))}</div>
|
|
|
|
{/* Windows */}
|
|
<div className="win-layer">
|
|
{wins.map((w) => (
|
|
<WinWindow
|
|
key={w.id}
|
|
win={w}
|
|
app={getApp(w.appId) ?? APP_BY_ID.welcome}
|
|
active={activeId === w.id}
|
|
closing={closingIds.includes(w.id)}
|
|
minimizing={minimizingIds.includes(w.id)}
|
|
onFocus={() => focus(w.id)}
|
|
onClose={() => closeWin(w.id)}
|
|
onCloseAnimationEnd={() => finishClose(w.id)}
|
|
onMinimize={() => minimizeWin(w.id)}
|
|
onMinimizeAnimationEnd={() => finishMinimize(w.id)}
|
|
onToggleMaximize={() => toggleMax(w.id)}
|
|
onMove={(x, y) => moveWin(w.id, x, y)}
|
|
onResize={(x, y, width, height) => resizeWin(w.id, x, y, width, height)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Application launcher */}
|
|
{startOpen && (
|
|
<div
|
|
className={"kickoff" + (startClosing ? " closing" : "")}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onAnimationEnd={(e) => {
|
|
if (e.target !== e.currentTarget) return;
|
|
if (startClosing) {
|
|
setStartOpen(false);
|
|
setStartClosing(false);
|
|
}
|
|
}}
|
|
>
|
|
<div className="kickoff-content">
|
|
<div className="kickoff-section-title">Applications</div>
|
|
<div className="kickoff-list">
|
|
{leftApps.map((app) => (
|
|
<button
|
|
key={app.id}
|
|
type="button"
|
|
className="kickoff-item"
|
|
onClick={() => openApp(app.id)}
|
|
>
|
|
<span className="kickoff-icon">{app.icon}</span>
|
|
<span className="kickoff-label">{app.title}</span>
|
|
</button>
|
|
))}
|
|
{serviceItems.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
className="kickoff-item"
|
|
onClick={() => openShortcut(s)}
|
|
>
|
|
<span className="kickoff-icon">{s.icon}</span>
|
|
<span className="kickoff-label">{s.label}</span>
|
|
</button>
|
|
))}
|
|
{friendItems.map((f) => (
|
|
<button
|
|
key={f.id}
|
|
type="button"
|
|
className="kickoff-item"
|
|
onClick={() => openShortcut(f)}
|
|
>
|
|
<span className="kickoff-icon">
|
|
{f.image && (
|
|
<Image
|
|
src={f.image}
|
|
alt={f.label}
|
|
width={20}
|
|
height={20}
|
|
className="kickoff-icon-img"
|
|
/>
|
|
)}
|
|
</span>
|
|
<span className="kickoff-label">{f.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Plasma panel */}
|
|
<div className="win-taskbar" onPointerDown={(e) => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
className={"win-start" + (startOpen ? " open" : "")}
|
|
onClick={() => toggleStart()}
|
|
aria-label="Application Launcher"
|
|
title="Application Launcher"
|
|
>
|
|
<span className="win-start-orb">
|
|
<Image src="/gentoo.svg" alt="Gentoo" width={26} height={27} className="gentoo-logo" />
|
|
</span>
|
|
</button>
|
|
|
|
<div
|
|
className="win-tb-buttons"
|
|
ref={tbRef}
|
|
onWheel={(e) => {
|
|
const bar = tbRef.current;
|
|
// Translate a vertical mouse wheel into horizontal scrolling while
|
|
// the button strip overflows, so every open app stays reachable.
|
|
if (bar && bar.scrollWidth > bar.clientWidth) bar.scrollLeft += e.deltaY;
|
|
}}
|
|
>
|
|
{wins.map((w) => {
|
|
const app = getApp(w.appId) ?? APP_BY_ID.welcome;
|
|
return (
|
|
<button
|
|
key={w.id}
|
|
type="button"
|
|
className={
|
|
"win-tb-btn" +
|
|
(activeId === w.id && !w.minimized ? " active" : "") +
|
|
(w.minimized ? " minimized" : "") +
|
|
(closingIds.includes(w.id) ? " closing" : "")
|
|
}
|
|
onClick={() => taskbarClick(w)}
|
|
title={app.title}
|
|
>
|
|
<span className="win-tb-btn-icon" aria-hidden="true">
|
|
{app.icon}
|
|
</span>
|
|
<span className="win-tb-btn-label">{app.title}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="win-tray">
|
|
<button
|
|
type="button"
|
|
className="win-theme-toggle"
|
|
onClick={toggle}
|
|
title={`Switch to ${theme === "skeuo" ? "gruvbox" : "Desktop"} theme`}
|
|
>
|
|
<span aria-hidden="true">{theme === "skeuo" ? "▚" : "❖"}</span>
|
|
<span>{theme === "skeuo" ? "Gruvbox" : "Desktop"}</span>
|
|
</button>
|
|
|
|
<Clock className="win-clock" />
|
|
|
|
<button
|
|
type="button"
|
|
className="win-showdesktop"
|
|
aria-label="Show desktop"
|
|
onClick={() => setWins((prev) => prev.map((w) => ({ ...w, minimized: true })))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|