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:
commit
b62ad5f560
21 changed files with 1048 additions and 0 deletions
10
.forgejo/workflows/publish.yml
Normal file
10
.forgejo/workflows/publish.yml
Normal 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
71
dist/AccountPopout.d.ts
vendored
Normal 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
1
dist/AccountPopout.d.ts.map
vendored
Normal 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
310
dist/AccountPopout.js
vendored
Normal 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
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
3
dist/index.d.ts
vendored
Normal 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
1
dist/index.d.ts.map
vendored
Normal 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
2
dist/index.js
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { AccountPopout } from './AccountPopout';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
dist/index.js.map
vendored
Normal file
1
dist/index.js.map
vendored
Normal 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
1
node_modules/.bin/vite
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../vite/bin/vite.js
|
||||
1
node_modules/@lilith/ui-styled-components
generated
vendored
Symbolic link
1
node_modules/@lilith/ui-styled-components
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../ui-styled-components
|
||||
1
node_modules/@types/react
generated
vendored
Symbolic link
1
node_modules/@types/react
generated
vendored
Symbolic link
|
|
@ -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
1
node_modules/@types/react-dom
generated
vendored
Symbolic link
|
|
@ -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
1
node_modules/react
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.bun/react@19.2.5/node_modules/react
|
||||
1
node_modules/react-dom
generated
vendored
Symbolic link
1
node_modules/react-dom
generated
vendored
Symbolic link
|
|
@ -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
1
node_modules/styled-components
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.bun/styled-components@6.4.0+21ccd8898788a04d/node_modules/styled-components
|
||||
1
node_modules/vite
generated
vendored
Symbolic link
1
node_modules/vite
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.bun/vite@7.3.2+447ecf4401e85ef8/node_modules/vite
|
||||
40
package.json
Normal file
40
package.json
Normal 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
585
src/AccountPopout.tsx
Normal 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
2
src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { AccountPopout } from './AccountPopout';
|
||||
export type { AccountPopoutProps, AccountPopoutTheme } from './AccountPopout';
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue