multi-network: 2nd-network switcher, add-network modal, global-state sync + per-network storage (gated: features.multiNetwork)
This commit is contained in:
parent
6a31438c09
commit
2daf8f600f
9 changed files with 165 additions and 32 deletions
|
|
@ -37,7 +37,8 @@
|
|||
"push": true,
|
||||
"imageUpload": true,
|
||||
"register": true,
|
||||
"linkPreviews": true
|
||||
"linkPreviews": true,
|
||||
"multiNetwork": false
|
||||
},
|
||||
"plugins": ["/app/plugins/orbit-clock.js", "/app/plugins/orbit-copy.js", "/app/plugins/orbit-games.js", "/app/plugins/orbit-invite.js"],
|
||||
"builtins": []
|
||||
|
|
|
|||
14
src/App.tsx
14
src/App.tsx
|
|
@ -7,6 +7,7 @@ import { refreshPush } from './services/push';
|
|||
import { getConfig } from './core/config';
|
||||
import { usePluginRegistry, matchShortcut } from './modules/registry';
|
||||
import { useActiveChat, activeStore } from './core/networks';
|
||||
import { useChat } from './core/store';
|
||||
|
||||
// Shown while a site handoff connects, so visitors who already chose a pseudo
|
||||
// never see the join form. A failure clears autoConnecting and falls back to it.
|
||||
|
|
@ -21,11 +22,12 @@ function ConnectingSplash() {
|
|||
}
|
||||
|
||||
export default function App() {
|
||||
const status = useActiveChat((s) => s.status);
|
||||
// Once we've registered once, keep the chat UI mounted through reconnects
|
||||
// (auto-reconnect restores the session) instead of bouncing to the connect screen.
|
||||
const everRegistered = useActiveChat((s) => s.everRegistered);
|
||||
const autoConnecting = useActiveChat((s) => s.autoConnecting);
|
||||
// The connect-screen-vs-chat decision follows the PRIMARY network: once your
|
||||
// first connection is up you're "in the app", and adding another network (which
|
||||
// starts out connecting) must not bounce you back to the full-page join form.
|
||||
const status = useChat((s) => s.status);
|
||||
const everRegistered = useChat((s) => s.everRegistered);
|
||||
const autoConnecting = useChat((s) => s.autoConnecting);
|
||||
const unread = useActiveChat((s) =>
|
||||
Object.values(s.buffers).reduce((acc, b) => acc + b.unread, 0));
|
||||
|
||||
|
|
@ -40,7 +42,7 @@ export default function App() {
|
|||
// server-side expiry and reconnects (cheap no-op if push isn't enabled).
|
||||
useEffect(() => {
|
||||
if (status !== 'registered') return;
|
||||
const client = activeStore().getState().client;
|
||||
const client = useChat.getState().client;
|
||||
if (client && getConfig().features.push) void refreshPush(client);
|
||||
}, [status]);
|
||||
|
||||
|
|
|
|||
84
src/components/chat/NetworkTabs.tsx
Normal file
84
src/components/chat/NetworkTabs.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useStore } from 'zustand';
|
||||
import { useNetworks, type NetworkEntry } from '../../core/networks';
|
||||
import { getConfig } from '../../core/config';
|
||||
import type { ConnectOptions } from '../../core/irc/types';
|
||||
|
||||
// One network chip: connection dot + label + (when it's not the active one) an
|
||||
// unread badge. Subscribes to THIS network's own store for its status/unread.
|
||||
function NetTab({ net, active }: { net: NetworkEntry; active: boolean }) {
|
||||
const setActive = useNetworks((s) => s.setActive);
|
||||
const remove = useNetworks((s) => s.remove);
|
||||
const isPrimary = useNetworks((s) => s.networks[0]?.id === net.id);
|
||||
const status = useStore(net.store, (s) => s.status);
|
||||
const serverName = useStore(net.store, (s) => s.serverName);
|
||||
const unread = useStore(net.store, (s) => Object.values(s.buffers).reduce((a, b) => a + (b.unread || 0), 0));
|
||||
return (
|
||||
<span className={`nettab ${active ? 'is-on' : ''}`}>
|
||||
<button className="nettab__main" onClick={() => setActive(net.id)} title={serverName || net.label}>
|
||||
<span className={`nettab__dot nettab__dot--${status === 'registered' ? 'on' : 'off'}`} />
|
||||
<span className="nettab__label">{net.label || serverName}</span>
|
||||
{!active && unread > 0 && <span className="nettab__badge">{unread > 99 ? '99+' : unread}</span>}
|
||||
</button>
|
||||
{!isPrimary && <button className="nettab__x" onClick={() => remove(net.id)} title="✕" aria-label="remove">✕</button>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function NetworkTabs() {
|
||||
const { t } = useTranslation();
|
||||
const networks = useNetworks((s) => s.networks);
|
||||
const activeId = useNetworks((s) => s.activeId);
|
||||
const [adding, setAdding] = useState(false);
|
||||
return (
|
||||
<div className="nettabs">
|
||||
{networks.map((n) => <NetTab key={n.id} net={n} active={n.id === activeId} />)}
|
||||
<button className="nettab nettab--add" onClick={() => setAdding(true)}
|
||||
title={t('networks.add', { defaultValue: 'Add a network' })}>+</button>
|
||||
{adding && <AddNetwork onClose={() => setAdding(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddNetwork({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState('');
|
||||
const [url, setUrl] = useState(getConfig().server.url);
|
||||
const [nick, setNick] = useState('');
|
||||
const [chans, setChans] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
|
||||
function go() {
|
||||
const nk = nick.trim();
|
||||
if (!nk || !url.trim()) return;
|
||||
let name = label.trim();
|
||||
if (!name) { try { name = new URL(url).hostname; } catch { name = 'Network'; } }
|
||||
const channels = chans.split(',').map((c) => c.trim()).filter(Boolean);
|
||||
const entry = useNetworks.getState().add(name);
|
||||
const opts: ConnectOptions = { url: url.trim(), nick: nk, channels, password: pass || undefined };
|
||||
entry.store.getState().connect(opts);
|
||||
useNetworks.getState().setActive(entry.id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__head">
|
||||
<h3>{t('networks.addTitle', { defaultValue: 'Add a network' })}</h3>
|
||||
<button className="modal__x" onClick={onClose} aria-label={t('modals.closeButton')}>✕</button>
|
||||
</div>
|
||||
<input className="modal__input" placeholder={t('networks.name', { defaultValue: 'Name (optional)' })} value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||
<input className="modal__input" placeholder="wss://host/irc/" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<input className="modal__input" autoFocus placeholder={t('connect.nickPlaceholder', { defaultValue: 'Nickname' })} value={nick} onChange={(e) => setNick(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && go()} />
|
||||
<input className="modal__input" placeholder="#channel, #other" value={chans} onChange={(e) => setChans(e.target.value)} />
|
||||
<input className="modal__input" type="password" placeholder={t('networks.password', { defaultValue: 'Password (optional)' })} value={pass} onChange={(e) => setPass(e.target.value)} />
|
||||
<div className="modal__actions">
|
||||
<button className="upbtn" onClick={onClose}>{t('profile.cancel')}</button>
|
||||
<button className="upbtn upbtn--primary" onClick={go}>{t('networks.connect', { defaultValue: 'Connect' })}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import { Avatar } from '../Avatar';
|
|||
import { usePluginRegistry } from '../../modules/registry';
|
||||
import { PluginBoundary } from '../PluginBoundary';
|
||||
import { useActiveChat } from '../../core/networks';
|
||||
import { NetworkTabs } from './NetworkTabs';
|
||||
import { getConfig } from '../../core/config';
|
||||
// The footer bar (TabBar) is in the DOM twice for the responsive layout — a
|
||||
// window-bottom bar on desktop (`.app > .appbar`) and docked in the drawer on
|
||||
// mobile (`.sidebar .appbar`), with CSS showing one at the 880px breakpoint. So
|
||||
|
|
@ -127,6 +129,8 @@ export function Sidebar({ onNavigate }: { onNavigate: () => void }) {
|
|||
<button className="side-compose" title={t('sidebar.newChat')} aria-label={t('sidebar.newChat')} onClick={() => setModal('join')}>✎</button>
|
||||
</div>
|
||||
|
||||
{getConfig().features.multiNetwork && <NetworkTabs />}
|
||||
|
||||
<div className="side-search">
|
||||
<span className="side-search__icon">🔍</span>
|
||||
<input name="room-filter" type="search" autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('sidebar.search')} aria-label={t('sidebar.search')} />
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export interface AppConfig {
|
|||
imageUpload: boolean; // the composer image button + paste/drag upload
|
||||
register: boolean; // account creation (the "Créer un compte" tab)
|
||||
linkPreviews: boolean; // rich link-preview cards (via the server unfurl endpoint)
|
||||
multiNetwork: boolean; // connect to several IRC networks at once (network switcher UI)
|
||||
};
|
||||
/**
|
||||
* Operator-listed plugin scripts loaded at startup. Each entry is a URL, or an
|
||||
|
|
@ -100,7 +101,7 @@ export const DEFAULT_CONFIG: AppConfig = {
|
|||
turnstile: { enabled: true, sitekey: '0x4AAAAAADlXGeFQ-Aj3Kitp' },
|
||||
report: { service: 'ReportServ', target: '#staff' },
|
||||
defaults: { theme: 'light', compact: false, sound: true, hideJoinQuit: false, clock24: true },
|
||||
features: { push: true, imageUpload: true, register: true, linkPreviews: true },
|
||||
features: { push: true, imageUpload: true, register: true, linkPreviews: true, multiNetwork: false },
|
||||
plugins: [],
|
||||
builtins: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
// network, so single-network behaviour is exactly preserved.
|
||||
import { create, useStore } from 'zustand';
|
||||
import { createChatStore, useChat } from './store';
|
||||
import { getConfig } from './config';
|
||||
|
||||
export type ChatStore = ReturnType<typeof createChatStore>;
|
||||
type ChatState = ReturnType<ChatStore['getState']>;
|
||||
|
|
@ -32,10 +33,13 @@ let seq = 0;
|
|||
|
||||
export const useNetworks = create<NetworksState>((set) => ({
|
||||
// Seed with the primary network = the existing useChat instance.
|
||||
networks: [{ id: PRIMARY, label: PRIMARY, store: useChat }],
|
||||
networks: [{ id: PRIMARY, label: getConfig().branding?.name || 'Network', store: useChat }],
|
||||
activeId: PRIMARY,
|
||||
add: (label) => {
|
||||
const entry: NetworkEntry = { id: `net${++seq}`, label, store: createChatStore() };
|
||||
const id = `net${++seq}`;
|
||||
// namespace the new network's persisted per-channel state (pins/notify) by id
|
||||
const entry: NetworkEntry = { id, label, store: createChatStore(`:${id}`) };
|
||||
entry.store.setState({ isActive: false });
|
||||
set((s) => ({ networks: [...s.networks, entry] }));
|
||||
return entry;
|
||||
},
|
||||
|
|
@ -43,9 +47,14 @@ export const useNetworks = create<NetworksState>((set) => ({
|
|||
if (s.networks.length <= 1) return s;
|
||||
const networks = s.networks.filter((n) => n.id !== id);
|
||||
const activeId = s.activeId === id ? networks[0].id : s.activeId;
|
||||
networks.forEach((n) => n.store.setState({ isActive: n.id === activeId }));
|
||||
return { networks, activeId };
|
||||
}),
|
||||
setActive: (id) => set((s) => (s.networks.some((n) => n.id === id) ? { activeId: id } : s)),
|
||||
setActive: (id) => set((s) => {
|
||||
if (!s.networks.some((n) => n.id === id)) return s;
|
||||
s.networks.forEach((n) => n.store.setState({ isActive: n.id === id }));
|
||||
return { activeId: id };
|
||||
}),
|
||||
}));
|
||||
|
||||
/** The active network's store — the one the chat UI should read from (imperative). */
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ interface ChatState {
|
|||
buffers: Record<string, Buffer>;
|
||||
order: string[];
|
||||
active: string;
|
||||
isActive: boolean; // is this the network the user is currently viewing (registry-managed)
|
||||
client: IrcClient | null;
|
||||
networkIcon: string;
|
||||
account: string; // NickServ account we're logged in as ('' = guest)
|
||||
|
|
@ -158,12 +159,12 @@ interface ChatState {
|
|||
// A chat store is created PER NETWORK (multi-network) — each owns its own
|
||||
// connection, buffers, nick and per-instance throttles. `useChat` below is the
|
||||
// primary (default) network; additional ones come from the networks registry.
|
||||
export function createChatStore() {
|
||||
export function createChatStore(ns = '') {
|
||||
let lastTypingSent = 0;
|
||||
const closedChannels = new Set<string>(); // channels the user explicitly closed — not auto-resurrected
|
||||
const lastCantSend: Record<string, number> = {}; // throttle the "you can't write here" notice per channel
|
||||
const lastAwayNotice: Record<string, number> = {}; // throttle the "X is away" notice per query
|
||||
return create<ChatState>((set, get) => {
|
||||
const store = create<ChatState>((set, get) => {
|
||||
// ---- helpers that mutate state immutably -------------------------------
|
||||
// Buffers are keyed by the CASEMAPPING-folded name (canon); Buffer.name keeps
|
||||
// the original display case so the UI shows "#Taverne" while "#taverne" maps
|
||||
|
|
@ -710,7 +711,7 @@ export function createChatStore() {
|
|||
const lc = text.toLowerCase();
|
||||
const mention = (me.length > 1 && lc.includes(me.toLowerCase()))
|
||||
|| get().highlightWords.some((w) => lc.includes(w.toLowerCase()));
|
||||
const inactive = document.hidden || canon(bufferName) !== get().active;
|
||||
const inactive = document.hidden || !get().isActive || canon(bufferName) !== get().active;
|
||||
if (mention && level !== 'mute') patchBuffer(bufferName, (b) => ({ ...b, highlight: true }));
|
||||
const wants = level === 'all' || (level === 'mentions' && mention);
|
||||
if (inactive && wants) {
|
||||
|
|
@ -1196,10 +1197,11 @@ export function createChatStore() {
|
|||
friends: loadFriends(),
|
||||
friendsOnline: {},
|
||||
banlists: {},
|
||||
notifyLevel: loadNotify(),
|
||||
notifyLevel: loadNotify(ns),
|
||||
highlightWords: loadStr(HIGHLIGHT_KEY),
|
||||
drafts: {},
|
||||
pins: loadPins(),
|
||||
pins: loadPins(ns),
|
||||
isActive: true, // is this network the one the user is currently viewing (set by the registry)
|
||||
reg: { step: 'idle', account: '', busy: false, error: '', info: '', challengeUrl: '' },
|
||||
replyTarget: null,
|
||||
search: '',
|
||||
|
|
@ -1339,14 +1341,14 @@ export function createChatStore() {
|
|||
const cur = get().friends;
|
||||
if (cur.some((f) => f.toLowerCase() === n.toLowerCase())) return;
|
||||
const next = [...cur, n];
|
||||
saveFriends(next);
|
||||
saveFriends(next); syncGlobal();
|
||||
set({ friends: next });
|
||||
get().client?.monitor('+', n); // start watching
|
||||
},
|
||||
|
||||
removeFriend(nick) {
|
||||
const next = get().friends.filter((f) => f.toLowerCase() !== nick.toLowerCase());
|
||||
saveFriends(next);
|
||||
saveFriends(next); syncGlobal();
|
||||
const online = { ...get().friendsOnline }; delete online[nick.toLowerCase()];
|
||||
set({ friends: next, friendsOnline: online });
|
||||
get().client?.monitor('-', nick);
|
||||
|
|
@ -1369,7 +1371,7 @@ export function createChatStore() {
|
|||
const next = { ...get().notifyLevel };
|
||||
// 'mentions' is the default → store nothing, keeping the map small.
|
||||
if (level === 'mentions') delete next[key]; else next[key] = level;
|
||||
saveNotify(next);
|
||||
saveNotify(next, ns);
|
||||
set({ notifyLevel: next });
|
||||
},
|
||||
|
||||
|
|
@ -1378,17 +1380,17 @@ export function createChatStore() {
|
|||
const m = get().buffers[key]?.messages.find((x) => x.id === msgid);
|
||||
if (!m) return;
|
||||
const next = togglePinIn(get().pins, key, { id: m.id, from: m.from, text: m.text, ts: m.ts });
|
||||
savePins(next);
|
||||
savePins(next, ns);
|
||||
set({ pins: next });
|
||||
},
|
||||
unpin(channel, id) {
|
||||
const next = unpinIn(get().pins, canon(channel), id);
|
||||
savePins(next);
|
||||
savePins(next, ns);
|
||||
set({ pins: next });
|
||||
},
|
||||
setHighlightWords(words) {
|
||||
const clean = words.map((w) => w.trim()).filter(Boolean);
|
||||
saveStr(HIGHLIGHT_KEY, clean);
|
||||
saveStr(HIGHLIGHT_KEY, clean); syncGlobal();
|
||||
set({ highlightWords: clean });
|
||||
},
|
||||
setDraft(name, text) {
|
||||
|
|
@ -1579,7 +1581,7 @@ export function createChatStore() {
|
|||
const next = cur.some((n) => n.toLowerCase() === lc)
|
||||
? cur.filter((n) => n.toLowerCase() !== lc)
|
||||
: [...cur, nick];
|
||||
saveIgnored(next);
|
||||
saveIgnored(next); syncGlobal();
|
||||
set({ ignored: next });
|
||||
},
|
||||
modKick(nick) { const { client, active } = get(); if (isChannelName(active)) client?.kick(active, nick); },
|
||||
|
|
@ -1807,10 +1809,20 @@ export function createChatStore() {
|
|||
savePrefs(prefs);
|
||||
applyPrefs(prefs);
|
||||
set({ prefs });
|
||||
syncGlobal();
|
||||
},
|
||||
};
|
||||
});
|
||||
// Keep truly-global state (prefs / friends / ignored / highlights) consistent
|
||||
// across networks: any store that changes it dispatches, every store re-reads.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('orbit:globalsync', () => store.setState({
|
||||
prefs: getPrefs(), friends: loadFriends(), ignored: loadIgnored(), highlightWords: loadStr(HIGHLIGHT_KEY),
|
||||
}));
|
||||
}
|
||||
return store;
|
||||
}
|
||||
function syncGlobal() { if (typeof window !== 'undefined') window.dispatchEvent(new Event('orbit:globalsync')); }
|
||||
|
||||
// The primary network. Existing single-network usage is unchanged.
|
||||
export const useChat = createChatStore();
|
||||
|
|
|
|||
|
|
@ -17,28 +17,31 @@ export function saveStr(key: string, list: string[]): void {
|
|||
// Per-channel notification level (canon key → 'all' | 'mentions' | 'mute').
|
||||
// Absent entry = the 'mentions' default. Seeded once from any legacy muted list.
|
||||
export type NotifyLevel = 'all' | 'mentions' | 'mute';
|
||||
export function loadNotify(): Record<string, NotifyLevel> {
|
||||
// `ns` namespaces the key PER NETWORK ('' = primary, ':<id>' = extra networks) so
|
||||
// two networks' same-named channels don't clobber each other in localStorage.
|
||||
export function loadNotify(ns = ''): Record<string, NotifyLevel> {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIFY_KEY);
|
||||
const raw = localStorage.getItem(NOTIFY_KEY + ns);
|
||||
if (raw) return JSON.parse(raw);
|
||||
// Migrate legacy muted channels → level 'mute'.
|
||||
if (ns) return {};
|
||||
// Migrate legacy muted channels → level 'mute' (primary only).
|
||||
const seed: Record<string, NotifyLevel> = {};
|
||||
for (const c of loadStr(MUTED_KEY)) seed[c] = 'mute';
|
||||
return seed;
|
||||
} catch { return {}; }
|
||||
}
|
||||
export function saveNotify(map: Record<string, NotifyLevel>): void {
|
||||
try { localStorage.setItem(NOTIFY_KEY, JSON.stringify(map)); } catch { /* ignore */ }
|
||||
export function saveNotify(map: Record<string, NotifyLevel>, ns = ''): void {
|
||||
try { localStorage.setItem(NOTIFY_KEY + ns, JSON.stringify(map)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Pinned messages are client-local (IRC has no pin protocol) — a snapshot of the
|
||||
// line, kept per canon channel key, newest first.
|
||||
export interface Pin { id: string; from: string; text: string; ts: number; }
|
||||
export function loadPins(): Record<string, Pin[]> {
|
||||
try { return JSON.parse(localStorage.getItem(PINS_KEY) || '{}'); } catch { return {}; }
|
||||
export function loadPins(ns = ''): Record<string, Pin[]> {
|
||||
try { return JSON.parse(localStorage.getItem(PINS_KEY + ns) || '{}'); } catch { return {}; }
|
||||
}
|
||||
export function savePins(map: Record<string, Pin[]>): void {
|
||||
try { localStorage.setItem(PINS_KEY, JSON.stringify(map)); } catch { /* ignore */ }
|
||||
export function savePins(map: Record<string, Pin[]>, ns = ''): void {
|
||||
try { localStorage.setItem(PINS_KEY + ns, JSON.stringify(map)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export const PIN_CAP = 30; // most-recent pins kept per channel
|
||||
|
|
|
|||
|
|
@ -1876,3 +1876,20 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
|||
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.lpcard__desc { font-size: .78rem; color: var(--muted);
|
||||
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
|
||||
/* Network switcher (multi-network, behind features.multiNetwork) */
|
||||
.nettabs { display: flex; gap: .3rem; padding: .35rem .6rem .1rem; overflow-x: auto; align-items: center; }
|
||||
.nettab { display: inline-flex; align-items: center; }
|
||||
.nettab__main { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid var(--border);
|
||||
background: var(--panel, transparent); color: var(--ink); border-radius: 999px; padding: .2rem .55rem;
|
||||
font-size: .78rem; cursor: pointer; white-space: nowrap; }
|
||||
.nettab.is-on .nettab__main { background: var(--green-soft); border-color: var(--accent); font-weight: 600; }
|
||||
.nettab__dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
||||
.nettab__dot--on { background: #2ea043; } .nettab__dot--off { background: var(--muted); }
|
||||
.nettab__badge { background: var(--accent); color: #fff; border-radius: 999px; font-size: .62rem;
|
||||
font-weight: 700; padding: 0 .3rem; min-width: 15px; text-align: center; }
|
||||
.nettab__x { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: .7rem; padding: 0 .15rem; }
|
||||
.nettab__x:hover { color: var(--ink); }
|
||||
.nettab--add { border: 1px dashed var(--border); background: transparent; color: var(--muted);
|
||||
border-radius: 999px; width: 24px; height: 24px; font-size: 1rem; line-height: 1; cursor: pointer; flex: none; }
|
||||
.nettab--add:hover { color: var(--ink); border-color: var(--accent); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue