1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182 | /**
* The app shell: header (brand + relay status line), scrolling main panel and
* the bottom tab bar. Every tab shows a placeholder panel in M0 — the real
* views land in later milestones and mount into the same frame.
*/
import { recordLangChange } from "../../core/session";
import { getLang, setLang, t } from "../i18n";
import { h, svgIcon } from "../ui-kit";
import { renderChatsTab } from "./chats-tab";
import { renderContactsTab } from "./contacts-tab";
import { renderFeedTab } from "./feed-tab";
import { renderProfileTab } from "./profile-tab";
export type RelayStatus = "checking" | "online" | "offline";
// remembered across re-renders; boot (main.ts) drives it via setRelayStatus
let relayStatus: RelayStatus = "checking";
let statusEl: HTMLElement | null = null;
export function setRelayStatus(s: RelayStatus): void {
relayStatus = s;
if (statusEl) renderStatus(statusEl);
}
function renderStatus(el: HTMLElement): void {
el.className = `status-line status-${relayStatus}`;
el.replaceChildren(
h("span", { class: "status-dot" }),
h("span", {}, t(relayStatus === "online" ? "statusOnline" : relayStatus === "offline" ? "statusOffline" : "statusChecking")),
);
}
export type TabId = "feed" | "chats" | "contacts" | "profile";
const TABS: { id: TabId; label: () => string; icon: string; hint: () => string }[] = [
{
id: "feed",
label: () => t("tabFeed"),
// grid of photos
icon: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z",
hint: () => t("panelFeedHint"),
},
{
id: "chats",
label: () => t("tabChats"),
// speech bubble
icon: "M21 12a8 8 0 0 1-8 8H4l2.4-2.9A8 8 0 1 1 21 12z",
hint: () => t("panelChatsHint"),
},
{
id: "contacts",
label: () => t("tabContacts"),
// two people
icon: "M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2.5 19a5.5 5.5 0 0 1 11 0M16 10.5a2.6 2.6 0 1 0 0-5.2M15 14.6a5 5 0 0 1 6.5 4.4",
hint: () => t("panelContactsHint"),
},
{
id: "profile",
label: () => t("tabProfile"),
// single person
icon: "M12 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM5 20a7 7 0 0 1 14 0",
hint: () => t("panelProfileHint"),
},
];
/** Small brand mark reused in the header: two interlocking rings. */
function brandMark(): SVGSVGElement {
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", "brand-mark");
svg.setAttribute("aria-hidden", "true");
const ring = (cx: number, color: string): SVGCircleElement => {
const c = document.createElementNS(NS, "circle");
c.setAttribute("cx", String(cx));
c.setAttribute("cy", "12");
c.setAttribute("r", "6");
c.setAttribute("fill", "none");
c.setAttribute("stroke", color);
c.setAttribute("stroke-width", "2.6");
return c;
};
svg.append(ring(8.6, "var(--accent)"), ring(15.4, "var(--accent-2)"));
return svg;
}
function panel(tab: TabId, rerender: () => void): HTMLElement {
if (tab === "profile") {
// the real M1 view: identity panel (fingerprint, link, export, lang, sync)
const el = h("div", { class: "profile-panel" });
renderProfileTab(el, rerender);
return el;
}
if (tab === "contacts") {
// the real M2 view: contact book (invites, groups, pending, detail)
const el = h("div", { class: "contacts-frame" });
renderContactsTab(el);
return el;
}
if (tab === "chats") {
// the real M3 view: conversation list (1:1 + group chats)
const el = h("div", { class: "chats-frame" });
renderChatsTab(el);
return el;
}
// the real M5 view: the public post feed
const el = h("div", { class: "feed-outer" });
renderFeedTab(el);
return el;
}
// remembered across re-renders (language change re-renders the whole lobby)
let activeTab: TabId = "feed";
/** Pre-select the tab the NEXT lobby render should open (e.g. after #/add). */
export function presetTab(tab: TabId): void {
activeTab = tab;
}
export function renderLobby(root: HTMLElement): void {
const main = h("main", { class: "app-main" });
const tabButtons = new Map<TabId, HTMLButtonElement>();
const select = (tab: TabId): void => {
activeTab = tab;
for (const [id, btn] of tabButtons) btn.classList.toggle("active", id === activeTab);
main.replaceChildren(panel(activeTab, () => renderLobby(root)));
};
const tabbar = h("nav", { class: "tabbar", "aria-label": t("appName") });
for (const def of TABS) {
const btn = h(
"button",
{ type: "button", "data-testid": `tab-${def.id}`, onclick: () => select(def.id) },
svgIcon(def.icon),
h("span", {}, def.label()),
);
tabButtons.set(def.id, btn);
tabbar.append(btn);
}
statusEl = h("div");
renderStatus(statusEl);
// ro → hu → en → ro cycle; setLang persists, then the whole view re-renders
const langBtn = h(
"button",
{
type: "button",
class: "lang-toggle",
"aria-label": t("langToggle"),
title: t("langToggle"),
onclick: () => {
const next = getLang() === "ro" ? "hu" : getLang() === "hu" ? "en" : "ro";
setLang(next);
recordLangChange(next); // settings op → home log → other devices
renderLobby(root);
},
},
getLang().toUpperCase(),
);
const header = h("header", { class: "app-header" }, brandMark(), h("h1", {}, t("appName")), statusEl, langBtn);
root.replaceChildren(header, main, tabbar);
select(activeTab);
}
/** Styled "coming in a later milestone" full view for not-yet-built routes. */
export function renderPlaceholder(root: HTMLElement, title: string, body: string): void {
statusEl = null; // status line not mounted on this view
const view = h(
"div",
{ class: "placeholder", style: "padding-top: calc(var(--safe-top) + 64px)" },
h("div", { class: "glyph" }, "🚧"),
h("h2", {}, title),
h("p", {}, body),
h("a", { class: "btn ghost", href: "#/" }, t("backHome")),
);
root.replaceChildren(view);
}
|