chore: initial package split from monorepo

Package: @lilith/account-popout
Split from: lilith/ui.git or lilith/build.git
Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
This commit is contained in:
autocommit 2026-04-20 01:11:14 -07:00
commit b62ad5f560
21 changed files with 1048 additions and 0 deletions

View file

@ -0,0 +1,10 @@
name: Publish
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
publish:
uses: lilith/workflows/.forgejo/workflows/publish-npm.yml@main
secrets: inherit

71
dist/AccountPopout.d.ts vendored Normal file
View file

@ -0,0 +1,71 @@
import { type ReactElement } from 'react';
export interface AccountPopoutTheme {
/** Sidebar background */
bg: string;
/** Card / panel background */
card: string;
/** Slightly elevated surface (hover states, action buttons) */
elevated: string;
/** Primary text */
text: string;
/** Muted / secondary text */
muted: string;
/** Default border */
border: string;
/** Avatar background (accent) */
gold: string;
/** Avatar text (contrast on gold) */
goldContrast: string;
/** Gold-tinted border for hover accents */
goldBorder: string;
/** Success color */
success: string;
/** Error / danger color */
error: string;
/** z-index for the floating panel */
zOverlay: number | string;
/** Border radius — small (close button) */
radiusSm: string;
/** Border radius — medium (action buttons, QR image) */
radiusMd: string;
/** Border radius — large (panel) */
radiusLg: string;
/** Full border radius (avatar) */
radiusFull: string;
/** Spacing — small */
spaceSm: string;
/** Spacing — medium */
spaceMd: string;
/** CSS transition shorthand */
transition: string;
}
export interface AccountPopoutProps {
/** Display name shown in the trigger and panel header */
displayName: string;
/** Subtitle (e.g. "Admin", "My Dashboard") */
spaceLabel: string;
/**
* Base URL for device-link API calls.
* Use '' for relative paths (same-origin) or a full URL for cross-origin.
* @default ''
*/
apiBaseUrl?: string;
/**
* Label for the settings action button.
* When provided, the settings button is rendered.
*/
settingsLabel?: string;
/** Called when the user clicks the settings button */
onSettings?: () => void;
/**
* Label for the logout action button.
* When provided, the logout button is rendered.
*/
logoutLabel?: string;
/** Called when the user clicks the logout button */
onLogout?: () => void;
/** Theme tokens — callers supply their own */
theme: AccountPopoutTheme;
}
export declare function AccountPopout({ displayName, spaceLabel, apiBaseUrl, settingsLabel, onSettings, logoutLabel, onLogout, theme, }: AccountPopoutProps): ReactElement;
//# sourceMappingURL=AccountPopout.d.ts.map

1
dist/AccountPopout.d.ts.map vendored Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"AccountPopout.d.ts","sourceRoot":"","sources":["../src/AccountPopout.tsx"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,YAAY,EAElB,MAAM,OAAO,CAAC;AA+Df,MAAM,WAAW,kBAAkB;IACjC,yBAAyB;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,6BAA6B;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,oBAAoB;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1B,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,QAAQ,EAAE,MAAM,CAAC;IACjB,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,sBAAsB;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,uBAAuB;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,UAAU,EAAE,MAAM,CAAC;CACpB;AA8CD,MAAM,WAAW,kBAAkB;IACjC,yDAAyD;IACzD,WAAW,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,8CAA8C;IAC9C,KAAK,EAAE,kBAAkB,CAAC;CAC3B;AAED,wBAAgB,aAAa,CAAC,EAC5B,WAAW,EACX,UAAU,EACV,UAAe,EACf,aAAa,EACb,UAAU,EACV,WAAW,EACX,QAAQ,EACR,KAAK,GACN,EAAE,kBAAkB,GAAG,YAAY,CA0KnC"}

310
dist/AccountPopout.js vendored Normal file
View file

@ -0,0 +1,310 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useState, useEffect, useRef, useCallback, } from 'react';
import styled from '@lilith/ui-styled-components';
// ---------------------------------------------------------------------------
// Inline icons — no external icon dep required
// ---------------------------------------------------------------------------
function IconPhone() {
return (_jsxs("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "5", y: "2", width: "14", height: "20", rx: "2" }), _jsx("line", { x1: "12", y1: "18", x2: "12", y2: "18.01" })] }));
}
function IconX() {
return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }));
}
function IconChevronUp({ open }) {
const style = {
transition: 'transform 0.15s',
transform: open ? 'rotate(180deg)' : 'none',
marginLeft: 'auto',
opacity: 0.5,
flexShrink: 0,
};
return (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: style, children: _jsx("polyline", { points: "18 15 12 9 6 15" }) }));
}
function IconSettings() {
return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "12", cy: "12", r: "3" }), _jsx("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" })] }));
}
function IconLogOut() {
return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }), _jsx("polyline", { points: "16 17 21 12 16 7" }), _jsx("line", { x1: "21", y1: "12", x2: "9", y2: "12" })] }));
}
async function generateToken(apiBaseUrl) {
const res = await fetch(`${apiBaseUrl}/api/device-link/token`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok)
throw new Error(`${res.status}`);
return res.json();
}
async function pollStatus(apiBaseUrl, id) {
const res = await fetch(`${apiBaseUrl}/api/device-link/token/${id}`, {
credentials: 'include',
});
if (!res.ok)
throw new Error(`${res.status}`);
return res.json();
}
export function AccountPopout({ displayName, spaceLabel, apiBaseUrl = '', settingsLabel, onSettings, logoutLabel, onLogout, theme, }) {
const [open, setOpen] = useState(false);
const [qr, setQr] = useState({ kind: 'idle' });
const [seconds, setSeconds] = useState(0);
const pollRef = useRef(null);
const countdownRef = useRef(null);
const containerRef = useRef(null);
const stopTimers = useCallback(() => {
if (pollRef.current !== null) {
clearInterval(pollRef.current);
pollRef.current = null;
}
if (countdownRef.current !== null) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
useEffect(() => {
return () => { stopTimers(); };
}, [stopTimers]);
useEffect(() => {
if (!open)
return () => { };
const handler = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => { document.removeEventListener('mousedown', handler); };
}, [open]);
const handleGenerateQr = useCallback(async () => {
setQr({ kind: 'loading' });
stopTimers();
let data;
try {
data = await generateToken(apiBaseUrl);
}
catch {
setQr({ kind: 'error', message: 'Failed to generate QR code. Try again.' });
return;
}
const expiresAt = new Date(data.expiresAt).getTime();
setQr({ kind: 'active', id: data.id, qrDataUrl: data.qrDataUrl, expiresAt });
setSeconds(Math.round((expiresAt - Date.now()) / 1000));
countdownRef.current = setInterval(() => {
const remaining = Math.round((expiresAt - Date.now()) / 1000);
if (remaining <= 0) {
if (countdownRef.current !== null)
clearInterval(countdownRef.current);
setSeconds(0);
}
else {
setSeconds(remaining);
}
}, 1000);
pollRef.current = setInterval(() => {
pollStatus(apiBaseUrl, data.id)
.then((s) => {
if (s.status === 'scanned') {
stopTimers();
setQr({ kind: 'scanned' });
}
else if (s.status === 'expired') {
stopTimers();
setQr({ kind: 'expired' });
}
})
.catch(() => {
// transient network error — polling continues on next tick
});
}, 3000);
}, [stopTimers, apiBaseUrl]);
const showActions = (settingsLabel != null && onSettings != null) || (logoutLabel != null && onLogout != null);
return (_jsxs(Wrapper, { ref: containerRef, "$theme": theme, children: [open && (_jsxs(Panel, { "$theme": theme, children: [_jsxs(PanelHeader, { "$theme": theme, children: [_jsx(PanelTitle, { "$theme": theme, children: displayName }), _jsx(PanelSubtitle, { "$theme": theme, children: spaceLabel }), _jsx(CloseBtn, { "$theme": theme, onClick: () => { setOpen(false); }, "aria-label": "Close", children: _jsx(IconX, {}) })] }), import.meta.env.PROD && (_jsxs(DeviceLinkSection, { "$theme": theme, children: [_jsxs(SectionLabel, { "$theme": theme, children: [_jsx(IconPhone, {}), "Log in on another device"] }), qr.kind === 'idle' && (_jsx(ActionBtn, { "$theme": theme, onClick: () => { void handleGenerateQr(); }, children: "Show QR Code" })), qr.kind === 'loading' && (_jsx(StatusText, { "$theme": theme, children: "Generating\u2026" })), qr.kind === 'active' && (_jsxs(_Fragment, { children: [_jsx(QrImg, { src: qr.qrDataUrl, alt: "Scan to log in on another device", "$theme": theme }), _jsxs(CountdownText, { "$theme": theme, children: ["Expires in ", seconds, "s"] })] })), qr.kind === 'scanned' && (_jsx(SuccessText, { "$theme": theme, children: "Device linked successfully" })), qr.kind === 'expired' && (_jsxs(_Fragment, { children: [_jsx(MutedText, { "$theme": theme, children: "QR code expired." }), _jsx(ActionBtn, { "$theme": theme, onClick: () => { void handleGenerateQr(); }, children: "Generate new code" })] })), qr.kind === 'error' && (_jsxs(_Fragment, { children: [_jsx(ErrorText, { "$theme": theme, children: qr.message }), _jsx(ActionBtn, { "$theme": theme, onClick: () => { void handleGenerateQr(); }, children: "Try again" })] }))] })), import.meta.env.PROD && showActions && _jsx(Divider, { "$theme": theme }), showActions && (_jsxs(PanelActions, { "$theme": theme, children: [settingsLabel != null && onSettings != null && (_jsxs(PanelBtn, { "$theme": theme, onClick: () => { onSettings(); setOpen(false); }, children: [_jsx(IconSettings, {}), settingsLabel] })), logoutLabel != null && onLogout != null && (_jsxs(PanelBtn, { "$theme": theme, "$danger": true, onClick: onLogout, children: [_jsx(IconLogOut, {}), logoutLabel] }))] }))] })), _jsxs(TriggerBtn, { "$theme": theme, onClick: () => { setOpen((v) => !v); }, children: [_jsx(Avatar, { "$theme": theme, children: displayName.charAt(0).toUpperCase() }), _jsxs(TriggerLabel, { children: [_jsx(TriggerName, { "$theme": theme, children: displayName }), _jsx(TriggerSpace, { "$theme": theme, children: spaceLabel })] }), _jsx(IconChevronUp, { open: open })] })] }));
}
const Wrapper = styled.div `
position: relative;
margin-top: auto;
padding: ${({ $theme }) => $theme.spaceSm};
border-top: 1px solid ${({ $theme }) => $theme.border};
`;
const TriggerBtn = styled.button `
display: flex;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
width: 100%;
padding: ${({ $theme }) => $theme.spaceSm};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: none;
background: transparent;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
color: ${({ $theme }) => $theme.text};
&:hover {
background: ${({ $theme }) => $theme.elevated};
}
`;
const Avatar = styled.div `
width: 28px;
height: 28px;
border-radius: ${({ $theme }) => $theme.radiusFull};
background: ${({ $theme }) => $theme.gold};
color: ${({ $theme }) => $theme.goldContrast};
font-size: 0.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`;
const TriggerLabel = styled.div `
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
`;
const TriggerName = styled.span `
font-size: 0.8125rem;
font-weight: 600;
color: ${({ $theme }) => $theme.text};
line-height: 1.2;
`;
const TriggerSpace = styled.span `
font-size: 0.6875rem;
color: ${({ $theme }) => $theme.muted};
line-height: 1.2;
`;
const Panel = styled.div `
position: absolute;
bottom: calc(100% + 4px);
left: ${({ $theme }) => $theme.spaceSm};
right: ${({ $theme }) => $theme.spaceSm};
background: ${({ $theme }) => $theme.card};
border: 1px solid ${({ $theme }) => $theme.border};
border-radius: ${({ $theme }) => $theme.radiusLg};
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
overflow: hidden;
z-index: ${({ $theme }) => $theme.zOverlay};
`;
const PanelHeader = styled.div `
padding: ${({ $theme }) => $theme.spaceMd};
border-bottom: 1px solid ${({ $theme }) => $theme.border};
position: relative;
`;
const PanelTitle = styled.div `
font-size: 0.875rem;
font-weight: 600;
color: ${({ $theme }) => $theme.text};
`;
const PanelSubtitle = styled.div `
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
margin-top: 1px;
`;
const CloseBtn = styled.button `
position: absolute;
top: ${({ $theme }) => $theme.spaceSm};
right: ${({ $theme }) => $theme.spaceSm};
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: ${({ $theme }) => $theme.muted};
border-radius: ${({ $theme }) => $theme.radiusSm};
cursor: pointer;
&:hover {
color: ${({ $theme }) => $theme.text};
}
`;
const DeviceLinkSection = styled.div `
padding: ${({ $theme }) => $theme.spaceMd};
display: flex;
flex-direction: column;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
`;
const SectionLabel = styled.div `
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
align-self: flex-start;
`;
const QrImg = styled.img `
width: 180px;
height: 180px;
border-radius: ${({ $theme }) => $theme.radiusMd};
background: #fff;
padding: 4px;
`;
const CountdownText = styled.div `
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
`;
const SuccessText = styled.div `
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.success};
text-align: center;
`;
const MutedText = styled.div `
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.muted};
`;
const ErrorText = styled.div `
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.error};
text-align: center;
`;
const StatusText = styled.div `
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.muted};
`;
const ActionBtn = styled.button `
padding: 6px ${({ $theme }) => $theme.spaceMd};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: 1px solid ${({ $theme }) => $theme.border};
background: ${({ $theme }) => $theme.elevated};
color: ${({ $theme }) => $theme.text};
font-size: 0.8125rem;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
&:hover {
border-color: ${({ $theme }) => $theme.goldBorder};
color: ${({ $theme }) => $theme.gold};
}
`;
const Divider = styled.div `
height: 1px;
background: ${({ $theme }) => $theme.border};
`;
const PanelActions = styled.div `
padding: ${({ $theme }) => $theme.spaceSm};
display: flex;
flex-direction: column;
gap: 2px;
`;
const PanelBtn = styled.button `
display: flex;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
width: 100%;
padding: ${({ $theme }) => $theme.spaceSm};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: none;
background: transparent;
font-size: 0.8125rem;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
color: ${({ $danger, $theme }) => ($danger === true ? $theme.muted : $theme.text)};
text-align: left;
&:hover {
background: ${({ $theme }) => $theme.elevated};
color: ${({ $danger, $theme }) => ($danger === true ? $theme.error : $theme.text)};
}
`;
//# sourceMappingURL=AccountPopout.js.map

1
dist/AccountPopout.js.map vendored Normal file

File diff suppressed because one or more lines are too long

3
dist/index.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
export { AccountPopout } from './AccountPopout';
export type { AccountPopoutProps, AccountPopoutTheme } from './AccountPopout';
//# sourceMappingURL=index.d.ts.map

1
dist/index.d.ts.map vendored Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,YAAY,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC"}

2
dist/index.js vendored Normal file
View file

@ -0,0 +1,2 @@
export { AccountPopout } from './AccountPopout';
//# sourceMappingURL=index.js.map

1
dist/index.js.map vendored Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC"}

1
node_modules/.bin/vite generated vendored Symbolic link
View file

@ -0,0 +1 @@
../vite/bin/vite.js

1
node_modules/@lilith/ui-styled-components generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../ui-styled-components

1
node_modules/@types/react generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../../node_modules/.bun/@types+react@19.2.14/node_modules/@types/react

1
node_modules/@types/react-dom generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../../node_modules/.bun/@types+react-dom@19.2.3+273cdfb19a04c3e9/node_modules/@types/react-dom

1
node_modules/react generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.bun/react@19.2.5/node_modules/react

1
node_modules/react-dom generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.bun/react-dom@19.2.5+3f10a4be4e334a9b/node_modules/react-dom

1
node_modules/styled-components generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.bun/styled-components@6.4.0+21ccd8898788a04d/node_modules/styled-components

1
node_modules/vite generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.bun/vite@7.3.2+447ecf4401e85ef8/node_modules/vite

40
package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "@lilith/account-popout",
"version": "1.0.0",
"description": "Account popout sidebar component with QR device-link session transfer",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsc --project tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint src --fix",
"lint:check": "eslint src"
},
"devDependencies": {
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"vite": "^7.3.1"
},
"peerDependencies": {
"@lilith/ui-styled-components": "^6.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"styled-components": "^6.0.0"
},
"publishConfig": {
"registry": "http://forge.black.local/api/packages/lilith/npm/"
},
"_": {
"registry": "forgejo",
"publish": true,
"build": true
}
}

585
src/AccountPopout.tsx Normal file
View file

@ -0,0 +1,585 @@
import {
useState,
useEffect,
useRef,
useCallback,
type ReactElement,
type CSSProperties,
} from 'react';
import styled from '@lilith/ui-styled-components';
// ---------------------------------------------------------------------------
// Inline icons — no external icon dep required
// ---------------------------------------------------------------------------
function IconPhone(): ReactElement {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" />
<line x1="12" y1="18" x2="12" y2="18.01" />
</svg>
);
}
function IconX(): ReactElement {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}
function IconChevronUp({ open }: { open: boolean }): ReactElement {
const style: CSSProperties = {
transition: 'transform 0.15s',
transform: open ? 'rotate(180deg)' : 'none',
marginLeft: 'auto',
opacity: 0.5,
flexShrink: 0,
};
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={style}>
<polyline points="18 15 12 9 6 15" />
</svg>
);
}
function IconSettings(): ReactElement {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
}
function IconLogOut(): ReactElement {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Theme tokens — callers pass their own values
// ---------------------------------------------------------------------------
export interface AccountPopoutTheme {
/** Sidebar background */
bg: string;
/** Card / panel background */
card: string;
/** Slightly elevated surface (hover states, action buttons) */
elevated: string;
/** Primary text */
text: string;
/** Muted / secondary text */
muted: string;
/** Default border */
border: string;
/** Avatar background (accent) */
gold: string;
/** Avatar text (contrast on gold) */
goldContrast: string;
/** Gold-tinted border for hover accents */
goldBorder: string;
/** Success color */
success: string;
/** Error / danger color */
error: string;
/** z-index for the floating panel */
zOverlay: number | string;
/** Border radius — small (close button) */
radiusSm: string;
/** Border radius — medium (action buttons, QR image) */
radiusMd: string;
/** Border radius — large (panel) */
radiusLg: string;
/** Full border radius (avatar) */
radiusFull: string;
/** Spacing — small */
spaceSm: string;
/** Spacing — medium */
spaceMd: string;
/** CSS transition shorthand */
transition: string;
}
// ---------------------------------------------------------------------------
// Device-link QR state machine
// ---------------------------------------------------------------------------
type QrState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'active'; id: string; qrDataUrl: string; expiresAt: number }
| { kind: 'scanned' }
| { kind: 'expired' }
| { kind: 'error'; message: string };
interface DeviceLinkTokenResponse {
id: string;
url: string;
qrDataUrl: string;
expiresAt: string;
}
interface DeviceLinkStatusResponse {
status: 'pending' | 'scanned' | 'expired';
}
async function generateToken(apiBaseUrl: string): Promise<DeviceLinkTokenResponse> {
const res = await fetch(`${apiBaseUrl}/api/device-link/token`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<DeviceLinkTokenResponse>;
}
async function pollStatus(apiBaseUrl: string, id: string): Promise<DeviceLinkStatusResponse> {
const res = await fetch(`${apiBaseUrl}/api/device-link/token/${id}`, {
credentials: 'include',
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<DeviceLinkStatusResponse>;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export interface AccountPopoutProps {
/** Display name shown in the trigger and panel header */
displayName: string;
/** Subtitle (e.g. "Admin", "My Dashboard") */
spaceLabel: string;
/**
* Base URL for device-link API calls.
* Use '' for relative paths (same-origin) or a full URL for cross-origin.
* @default ''
*/
apiBaseUrl?: string;
/**
* Label for the settings action button.
* When provided, the settings button is rendered.
*/
settingsLabel?: string;
/** Called when the user clicks the settings button */
onSettings?: () => void;
/**
* Label for the logout action button.
* When provided, the logout button is rendered.
*/
logoutLabel?: string;
/** Called when the user clicks the logout button */
onLogout?: () => void;
/** Theme tokens — callers supply their own */
theme: AccountPopoutTheme;
}
export function AccountPopout({
displayName,
spaceLabel,
apiBaseUrl = '',
settingsLabel,
onSettings,
logoutLabel,
onLogout,
theme,
}: AccountPopoutProps): ReactElement {
const [open, setOpen] = useState(false);
const [qr, setQr] = useState<QrState>({ kind: 'idle' });
const [seconds, setSeconds] = useState(0);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const stopTimers = useCallback((): void => {
if (pollRef.current !== null) {
clearInterval(pollRef.current);
pollRef.current = null;
}
if (countdownRef.current !== null) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
useEffect((): (() => void) => {
return (): void => { stopTimers(); };
}, [stopTimers]);
useEffect((): (() => void) => {
if (!open) return (): void => {};
const handler = (e: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return (): void => { document.removeEventListener('mousedown', handler); };
}, [open]);
const handleGenerateQr = useCallback(async (): Promise<void> => {
setQr({ kind: 'loading' });
stopTimers();
let data: DeviceLinkTokenResponse;
try {
data = await generateToken(apiBaseUrl);
} catch {
setQr({ kind: 'error', message: 'Failed to generate QR code. Try again.' });
return;
}
const expiresAt = new Date(data.expiresAt).getTime();
setQr({ kind: 'active', id: data.id, qrDataUrl: data.qrDataUrl, expiresAt });
setSeconds(Math.round((expiresAt - Date.now()) / 1000));
countdownRef.current = setInterval((): void => {
const remaining = Math.round((expiresAt - Date.now()) / 1000);
if (remaining <= 0) {
if (countdownRef.current !== null) clearInterval(countdownRef.current);
setSeconds(0);
} else {
setSeconds(remaining);
}
}, 1000);
pollRef.current = setInterval((): void => {
pollStatus(apiBaseUrl, data.id)
.then((s): void => {
if (s.status === 'scanned') {
stopTimers();
setQr({ kind: 'scanned' });
} else if (s.status === 'expired') {
stopTimers();
setQr({ kind: 'expired' });
}
})
.catch((): void => {
// transient network error — polling continues on next tick
});
}, 3000);
}, [stopTimers, apiBaseUrl]);
const showActions = (settingsLabel != null && onSettings != null) || (logoutLabel != null && onLogout != null);
return (
<Wrapper ref={containerRef} $theme={theme}>
{open && (
<Panel $theme={theme}>
<PanelHeader $theme={theme}>
<PanelTitle $theme={theme}>{displayName}</PanelTitle>
<PanelSubtitle $theme={theme}>{spaceLabel}</PanelSubtitle>
<CloseBtn $theme={theme} onClick={(): void => { setOpen(false); }} aria-label="Close">
<IconX />
</CloseBtn>
</PanelHeader>
{import.meta.env.PROD && (
<DeviceLinkSection $theme={theme}>
<SectionLabel $theme={theme}>
<IconPhone />
Log in on another device
</SectionLabel>
{qr.kind === 'idle' && (
<ActionBtn $theme={theme} onClick={(): void => { void handleGenerateQr(); }}>
Show QR Code
</ActionBtn>
)}
{qr.kind === 'loading' && (
<StatusText $theme={theme}>Generating</StatusText>
)}
{qr.kind === 'active' && (
<>
<QrImg src={qr.qrDataUrl} alt="Scan to log in on another device" $theme={theme} />
<CountdownText $theme={theme}>Expires in {seconds}s</CountdownText>
</>
)}
{qr.kind === 'scanned' && (
<SuccessText $theme={theme}>Device linked successfully</SuccessText>
)}
{qr.kind === 'expired' && (
<>
<MutedText $theme={theme}>QR code expired.</MutedText>
<ActionBtn $theme={theme} onClick={(): void => { void handleGenerateQr(); }}>
Generate new code
</ActionBtn>
</>
)}
{qr.kind === 'error' && (
<>
<ErrorText $theme={theme}>{qr.message}</ErrorText>
<ActionBtn $theme={theme} onClick={(): void => { void handleGenerateQr(); }}>
Try again
</ActionBtn>
</>
)}
</DeviceLinkSection>
)}
{import.meta.env.PROD && showActions && <Divider $theme={theme} />}
{showActions && (
<PanelActions $theme={theme}>
{settingsLabel != null && onSettings != null && (
<PanelBtn $theme={theme} onClick={(): void => { onSettings(); setOpen(false); }}>
<IconSettings />
{settingsLabel}
</PanelBtn>
)}
{logoutLabel != null && onLogout != null && (
<PanelBtn $theme={theme} $danger onClick={onLogout}>
<IconLogOut />
{logoutLabel}
</PanelBtn>
)}
</PanelActions>
)}
</Panel>
)}
<TriggerBtn $theme={theme} onClick={(): void => { setOpen((v) => !v); }}>
<Avatar $theme={theme}>{displayName.charAt(0).toUpperCase()}</Avatar>
<TriggerLabel>
<TriggerName $theme={theme}>{displayName}</TriggerName>
<TriggerSpace $theme={theme}>{spaceLabel}</TriggerSpace>
</TriggerLabel>
<IconChevronUp open={open} />
</TriggerBtn>
</Wrapper>
);
}
// ---------------------------------------------------------------------------
// Styled components — all theme values injected via props
// ---------------------------------------------------------------------------
interface T { $theme: AccountPopoutTheme; }
const Wrapper = styled.div<T>`
position: relative;
margin-top: auto;
padding: ${({ $theme }) => $theme.spaceSm};
border-top: 1px solid ${({ $theme }) => $theme.border};
`;
const TriggerBtn = styled.button<T>`
display: flex;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
width: 100%;
padding: ${({ $theme }) => $theme.spaceSm};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: none;
background: transparent;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
color: ${({ $theme }) => $theme.text};
&:hover {
background: ${({ $theme }) => $theme.elevated};
}
`;
const Avatar = styled.div<T>`
width: 28px;
height: 28px;
border-radius: ${({ $theme }) => $theme.radiusFull};
background: ${({ $theme }) => $theme.gold};
color: ${({ $theme }) => $theme.goldContrast};
font-size: 0.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`;
const TriggerLabel = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
`;
const TriggerName = styled.span<T>`
font-size: 0.8125rem;
font-weight: 600;
color: ${({ $theme }) => $theme.text};
line-height: 1.2;
`;
const TriggerSpace = styled.span<T>`
font-size: 0.6875rem;
color: ${({ $theme }) => $theme.muted};
line-height: 1.2;
`;
const Panel = styled.div<T>`
position: absolute;
bottom: calc(100% + 4px);
left: ${({ $theme }) => $theme.spaceSm};
right: ${({ $theme }) => $theme.spaceSm};
background: ${({ $theme }) => $theme.card};
border: 1px solid ${({ $theme }) => $theme.border};
border-radius: ${({ $theme }) => $theme.radiusLg};
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
overflow: hidden;
z-index: ${({ $theme }) => $theme.zOverlay};
`;
const PanelHeader = styled.div<T>`
padding: ${({ $theme }) => $theme.spaceMd};
border-bottom: 1px solid ${({ $theme }) => $theme.border};
position: relative;
`;
const PanelTitle = styled.div<T>`
font-size: 0.875rem;
font-weight: 600;
color: ${({ $theme }) => $theme.text};
`;
const PanelSubtitle = styled.div<T>`
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
margin-top: 1px;
`;
const CloseBtn = styled.button<T>`
position: absolute;
top: ${({ $theme }) => $theme.spaceSm};
right: ${({ $theme }) => $theme.spaceSm};
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: ${({ $theme }) => $theme.muted};
border-radius: ${({ $theme }) => $theme.radiusSm};
cursor: pointer;
&:hover {
color: ${({ $theme }) => $theme.text};
}
`;
const DeviceLinkSection = styled.div<T>`
padding: ${({ $theme }) => $theme.spaceMd};
display: flex;
flex-direction: column;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
`;
const SectionLabel = styled.div<T>`
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
align-self: flex-start;
`;
const QrImg = styled.img<T>`
width: 180px;
height: 180px;
border-radius: ${({ $theme }) => $theme.radiusMd};
background: #fff;
padding: 4px;
`;
const CountdownText = styled.div<T>`
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
`;
const SuccessText = styled.div<T>`
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.success};
text-align: center;
`;
const MutedText = styled.div<T>`
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.muted};
`;
const ErrorText = styled.div<T>`
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.error};
text-align: center;
`;
const StatusText = styled.div<T>`
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.muted};
`;
const ActionBtn = styled.button<T>`
padding: 6px ${({ $theme }) => $theme.spaceMd};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: 1px solid ${({ $theme }) => $theme.border};
background: ${({ $theme }) => $theme.elevated};
color: ${({ $theme }) => $theme.text};
font-size: 0.8125rem;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
&:hover {
border-color: ${({ $theme }) => $theme.goldBorder};
color: ${({ $theme }) => $theme.gold};
}
`;
const Divider = styled.div<T>`
height: 1px;
background: ${({ $theme }) => $theme.border};
`;
const PanelActions = styled.div<T>`
padding: ${({ $theme }) => $theme.spaceSm};
display: flex;
flex-direction: column;
gap: 2px;
`;
interface PanelBtnProps extends T {
$danger?: boolean;
}
const PanelBtn = styled.button<PanelBtnProps>`
display: flex;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
width: 100%;
padding: ${({ $theme }) => $theme.spaceSm};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: none;
background: transparent;
font-size: 0.8125rem;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
color: ${({ $danger, $theme }) => ($danger === true ? $theme.muted : $theme.text)};
text-align: left;
&:hover {
background: ${({ $theme }) => $theme.elevated};
color: ${({ $danger, $theme }) => ($danger === true ? $theme.error : $theme.text)};
}
`;

2
src/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { AccountPopout } from './AccountPopout';
export type { AccountPopoutProps, AccountPopoutTheme } from './AccountPopout';

13
tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"noEmit": false,
"rewriteRelativeImportExtensions": true,
"types": ["vite/client"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}