@axium/client 0.4.9 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/styles.css +28 -0
- package/axium-client.service +22 -0
- package/dist/cli/config.d.ts +23 -0
- package/dist/cli/config.js +52 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +244 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +33 -0
- package/dist/requests.js +4 -1
- package/dist/user.d.ts +1 -1
- package/lib/AccessControlDialog.svelte +2 -3
- package/lib/FormDialog.svelte +1 -10
- package/lib/Login.svelte +4 -2
- package/lib/NumberBar.svelte +3 -1
- package/lib/Register.svelte +7 -1
- package/lib/SessionList.svelte +57 -0
- package/lib/UserMenu.svelte +75 -0
- package/lib/auth_redirect.ts +28 -0
- package/lib/index.ts +2 -0
- package/package.json +11 -4
- package/styles/account.css +57 -0
- package/styles/list.css +61 -0
package/assets/styles.css
CHANGED
|
@@ -65,6 +65,27 @@ textarea {
|
|
|
65
65
|
outline: none;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
select,
|
|
69
|
+
::picker(select) {
|
|
70
|
+
appearance: base-select;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
::picker(select) {
|
|
74
|
+
border: 1px solid var(--border-accent);
|
|
75
|
+
padding: 1em;
|
|
76
|
+
border-radius: 0.5em;
|
|
77
|
+
background-color: var(--bg-menu);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
option {
|
|
81
|
+
padding: 0.25em 0;
|
|
82
|
+
border-radius: 0.5em;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
option:hover {
|
|
86
|
+
background-color: var(--bg-alt);
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
button {
|
|
69
90
|
cursor: pointer;
|
|
70
91
|
}
|
|
@@ -73,6 +94,13 @@ button:hover {
|
|
|
73
94
|
background-color: hsl(var(--hue) 15 calc(var(--bg-light) + (var(--light-step) * 2)));
|
|
74
95
|
}
|
|
75
96
|
|
|
97
|
+
code,
|
|
98
|
+
pre {
|
|
99
|
+
background-color: var(--bg-menu);
|
|
100
|
+
padding: 1em;
|
|
101
|
+
border-radius: 0.5em;
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
.error {
|
|
77
105
|
padding: 1em;
|
|
78
106
|
border-radius: 0.5em;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Axium Client Daemon
|
|
3
|
+
Wants=network-online.target
|
|
4
|
+
After=network-online.target
|
|
5
|
+
RequiresMountsFor=%Y
|
|
6
|
+
StartLimitIntervalSec=1min
|
|
7
|
+
StartLimitBurst=5
|
|
8
|
+
StartLimitAction=none
|
|
9
|
+
FailureAction=none
|
|
10
|
+
|
|
11
|
+
[Service]
|
|
12
|
+
Type=simple
|
|
13
|
+
ExecStartPre=/usr/bin/env cd "%Y/../../.."
|
|
14
|
+
ExecStart=/usr/bin/env npx --prefix "%Y/../../.." axium-client run
|
|
15
|
+
ExecReload=kill -HUP $MAINPID
|
|
16
|
+
SyslogIdentifier=axium-client
|
|
17
|
+
Restart=on-failure
|
|
18
|
+
RestartSec=3s
|
|
19
|
+
TimeoutStopSec=15s
|
|
20
|
+
|
|
21
|
+
[Install]
|
|
22
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const configDir: string;
|
|
2
|
+
export declare function session(): {
|
|
3
|
+
id: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
expires: Date;
|
|
6
|
+
created: Date;
|
|
7
|
+
elevated: boolean;
|
|
8
|
+
user: {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
email: string;
|
|
12
|
+
preferences: Record<string, any>;
|
|
13
|
+
roles: string[];
|
|
14
|
+
registeredAt: Date;
|
|
15
|
+
isAdmin: boolean;
|
|
16
|
+
emailVerified?: Date | null | undefined;
|
|
17
|
+
image?: string | null | undefined;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare function loadConfig(safe: boolean): Promise<void>;
|
|
21
|
+
export declare function saveConfig(): void;
|
|
22
|
+
export declare const _dayMs: number;
|
|
23
|
+
export declare function updateCache(force: boolean): Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as io from '@axium/core/node/io';
|
|
2
|
+
import { loadPlugin } from '@axium/core/node/plugins';
|
|
3
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path/posix';
|
|
6
|
+
import * as z from 'zod';
|
|
7
|
+
import { fetchAPI, setPrefix, setToken } from '../requests.js';
|
|
8
|
+
import { getCurrentSession } from '../user.js';
|
|
9
|
+
import { ClientConfig, config } from '../config.js';
|
|
10
|
+
export const configDir = join(homedir(), '.config/axium');
|
|
11
|
+
mkdirSync(configDir, { recursive: true });
|
|
12
|
+
const axcConfig = join(configDir, 'config.json');
|
|
13
|
+
export function session() {
|
|
14
|
+
if (!config.token)
|
|
15
|
+
io.exit('Not logged in.', 4);
|
|
16
|
+
if (!config.cache)
|
|
17
|
+
io.exit('No session data available.', 3);
|
|
18
|
+
return config.cache.session;
|
|
19
|
+
}
|
|
20
|
+
export async function loadConfig(safe) {
|
|
21
|
+
try {
|
|
22
|
+
Object.assign(config, ClientConfig.parse(JSON.parse(readFileSync(axcConfig, 'utf-8'))));
|
|
23
|
+
if (config.server)
|
|
24
|
+
setPrefix(config.server);
|
|
25
|
+
if (config.token)
|
|
26
|
+
setToken(config.token);
|
|
27
|
+
for (const plugin of config.plugins ?? [])
|
|
28
|
+
await loadPlugin('client', plugin, axcConfig, safe);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
io.warn('Failed to load config: ' + (e instanceof z.core.$ZodError ? z.prettifyError(e) : io._debugOutput ? e.stack : e.message));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function saveConfig() {
|
|
35
|
+
io.writeJSON(axcConfig, config);
|
|
36
|
+
io.debug('Saved config to ' + axcConfig);
|
|
37
|
+
}
|
|
38
|
+
export const _dayMs = 24 * 3600_000;
|
|
39
|
+
export async function updateCache(force) {
|
|
40
|
+
if (!force && config.cache && config.cache.fetched + _dayMs > Date.now())
|
|
41
|
+
return;
|
|
42
|
+
io.start('Fetching metadata');
|
|
43
|
+
const [session, apps] = await Promise.all([getCurrentSession(), fetchAPI('GET', 'apps')]).catch(err => io.exit(err.message));
|
|
44
|
+
try {
|
|
45
|
+
config.cache = { fetched: Date.now(), session, apps };
|
|
46
|
+
saveConfig();
|
|
47
|
+
io.done();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#! /usr/bin/env node
|
|
2
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
3
|
+
if (value !== null && value !== void 0) {
|
|
4
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
5
|
+
var dispose, inner;
|
|
6
|
+
if (async) {
|
|
7
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
8
|
+
dispose = value[Symbol.asyncDispose];
|
|
9
|
+
}
|
|
10
|
+
if (dispose === void 0) {
|
|
11
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
12
|
+
dispose = value[Symbol.dispose];
|
|
13
|
+
if (async) inner = dispose;
|
|
14
|
+
}
|
|
15
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
16
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
17
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
18
|
+
}
|
|
19
|
+
else if (async) {
|
|
20
|
+
env.stack.push({ async: true });
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
};
|
|
24
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
25
|
+
return function (env) {
|
|
26
|
+
function fail(e) {
|
|
27
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
28
|
+
env.hasError = true;
|
|
29
|
+
}
|
|
30
|
+
var r, s = 0;
|
|
31
|
+
function next() {
|
|
32
|
+
while (r = env.stack.pop()) {
|
|
33
|
+
try {
|
|
34
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
35
|
+
if (r.dispose) {
|
|
36
|
+
var result = r.dispose.call(r.value);
|
|
37
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
38
|
+
}
|
|
39
|
+
else s |= 1;
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
fail(e);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
46
|
+
if (env.hasError) throw env.error;
|
|
47
|
+
}
|
|
48
|
+
return next();
|
|
49
|
+
};
|
|
50
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
51
|
+
var e = new Error(message);
|
|
52
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
53
|
+
});
|
|
54
|
+
import { outputDaemonStatus, io, pluginText } from '@axium/core/node';
|
|
55
|
+
import { _findPlugin, plugins } from '@axium/core/plugins';
|
|
56
|
+
import { program } from 'commander';
|
|
57
|
+
import { createServer } from 'node:http';
|
|
58
|
+
import { createInterface } from 'node:readline/promises';
|
|
59
|
+
import { styleText } from 'node:util';
|
|
60
|
+
import * as z from 'zod';
|
|
61
|
+
import $pkg from '../../package.json' with { type: 'json' };
|
|
62
|
+
import { config, resolveServerURL } from '../config.js';
|
|
63
|
+
import { prefix, setPrefix, setToken } from '../requests.js';
|
|
64
|
+
import { getCurrentSession, logout } from '../user.js';
|
|
65
|
+
import { loadConfig, saveConfig, updateCache } from './config.js';
|
|
66
|
+
const safe = z.stringbool().default(false).parse(process.env.SAFE?.toLowerCase()) || process.argv.includes('--safe');
|
|
67
|
+
await loadConfig(safe);
|
|
68
|
+
process.on('SIGHUP', () => {
|
|
69
|
+
io.info('Reloading configuration due to SIGHUP.');
|
|
70
|
+
void loadConfig(safe);
|
|
71
|
+
});
|
|
72
|
+
var rl, axiumPlugin;
|
|
73
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
74
|
+
try {
|
|
75
|
+
rl = __addDisposableResource(env_1, createInterface({
|
|
76
|
+
input: process.stdin,
|
|
77
|
+
output: process.stdout,
|
|
78
|
+
}), false);
|
|
79
|
+
rl.on('SIGINT', () => io.exit('Aborted.', 7));
|
|
80
|
+
program
|
|
81
|
+
.version($pkg.version)
|
|
82
|
+
.name('axium-client')
|
|
83
|
+
.alias('axc')
|
|
84
|
+
.description('Axium client CLI')
|
|
85
|
+
.configureHelp({ showGlobalOptions: true })
|
|
86
|
+
.option('--debug', 'override debug mode')
|
|
87
|
+
.option('--no-debug', 'override debug mode')
|
|
88
|
+
.option('--refresh-session', 'Force a refresh of session and user metadata from server')
|
|
89
|
+
.option('--cache-only', 'Run entirely from local cache, even if it is expired.')
|
|
90
|
+
.option('--safe', 'do not execute code from plugins');
|
|
91
|
+
program.on('option:debug', () => io._setDebugOutput(true));
|
|
92
|
+
program.hook('preAction', async (_, action) => {
|
|
93
|
+
const opt = action.optsWithGlobals();
|
|
94
|
+
if (!config.token)
|
|
95
|
+
return;
|
|
96
|
+
if (!opt.cacheOnly)
|
|
97
|
+
await updateCache(opt.refreshSession);
|
|
98
|
+
});
|
|
99
|
+
program
|
|
100
|
+
.command('login')
|
|
101
|
+
.description('Log in to your account on an Axium server')
|
|
102
|
+
.argument('[server]', 'Axium server URL')
|
|
103
|
+
.action(async (url) => {
|
|
104
|
+
if (prefix[0] != '/')
|
|
105
|
+
url ||= prefix;
|
|
106
|
+
url ||= await rl.question('Axium server URL: ');
|
|
107
|
+
url = resolveServerURL(url);
|
|
108
|
+
setPrefix(url);
|
|
109
|
+
const sessionReady = Promise.withResolvers();
|
|
110
|
+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
111
|
+
const server = createServer(async (req, res) => {
|
|
112
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
113
|
+
res.setHeader('access-control-allow-methods', '*');
|
|
114
|
+
res.setHeader('access-control-allow-headers', '*');
|
|
115
|
+
if (req.method == 'HEAD' || req.method == 'OPTIONS') {
|
|
116
|
+
res.writeHead(200).end();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!req.headers['content-type']?.endsWith('/json')) {
|
|
120
|
+
res.writeHead(415).end('Unexpected content type');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (req.method !== 'POST') {
|
|
124
|
+
res.writeHead(405).end('Unexpected request method');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const { promise: bodyReady, resolve, reject } = Promise.withResolvers();
|
|
128
|
+
let body = '';
|
|
129
|
+
req.on('data', chunk => (body += chunk.toString()));
|
|
130
|
+
req.on('end', resolve);
|
|
131
|
+
req.on('error', reject);
|
|
132
|
+
try {
|
|
133
|
+
await bodyReady;
|
|
134
|
+
sessionReady.resolve(JSON.parse(body));
|
|
135
|
+
res.writeHead(200).end();
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
res.statusCode = 500;
|
|
139
|
+
res.end('Internal server error: ' + e.message);
|
|
140
|
+
sessionReady.reject(e.message);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
const serverReady = Promise.withResolvers();
|
|
144
|
+
server.listen(() => {
|
|
145
|
+
const { port } = server.address();
|
|
146
|
+
serverReady.resolve(port);
|
|
147
|
+
});
|
|
148
|
+
server.on('error', e => io.exit('Failed to start local callback server: ' + e.message, 5));
|
|
149
|
+
const port = await serverReady.promise;
|
|
150
|
+
const authURL = new URL('/login/client?port=' + port, url).href;
|
|
151
|
+
console.log('Authenticate by visiting this URL in your browser: ' + styleText('underline', authURL));
|
|
152
|
+
const { token } = await sessionReady.promise.catch(e => io.exit('Failed to obtain session: ' + e, 6));
|
|
153
|
+
setToken(token);
|
|
154
|
+
server.close();
|
|
155
|
+
io.start('Verifying session');
|
|
156
|
+
const session = await getCurrentSession().catch(e => io.exit(e.message, 6));
|
|
157
|
+
io.done();
|
|
158
|
+
io.debug('Session UUID: ' + session.id);
|
|
159
|
+
console.log(`Welcome ${session.user.name}! Your session is valid until ${session.expires.toLocaleDateString()}.`);
|
|
160
|
+
config.token = token;
|
|
161
|
+
config.server = url;
|
|
162
|
+
saveConfig();
|
|
163
|
+
await updateCache(true);
|
|
164
|
+
});
|
|
165
|
+
program.command('logout').action(async () => {
|
|
166
|
+
if (!config.token)
|
|
167
|
+
io.exit('Not logged in.', 4);
|
|
168
|
+
if (!config.cache)
|
|
169
|
+
io.exit('No session data available.', 3);
|
|
170
|
+
await logout(config.cache.session.userId, config.cache.session.id);
|
|
171
|
+
});
|
|
172
|
+
program.command('status').action(() => {
|
|
173
|
+
if (!config.token)
|
|
174
|
+
return console.log('Not logged in.');
|
|
175
|
+
if (!config.cache)
|
|
176
|
+
return console.log('No session data available.');
|
|
177
|
+
console.log('Logged in to', new URL(prefix).host);
|
|
178
|
+
console.log(styleText('whiteBright', 'Session ID:'), config.cache.session.id);
|
|
179
|
+
const { user } = config.cache.session;
|
|
180
|
+
console.log(styleText('whiteBright', 'User:'), user.name, `<${user.email}>`, styleText('dim', `(${user.id})`));
|
|
181
|
+
outputDaemonStatus('axium-client');
|
|
182
|
+
});
|
|
183
|
+
program
|
|
184
|
+
.command('run')
|
|
185
|
+
.argument('[plugin]', 'The plugin to run')
|
|
186
|
+
.action(async (name) => {
|
|
187
|
+
if (name) {
|
|
188
|
+
const plugin = _findPlugin(name);
|
|
189
|
+
await plugin._client?.run();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
for (const plugin of plugins.values())
|
|
193
|
+
await plugin._client?.run();
|
|
194
|
+
});
|
|
195
|
+
axiumPlugin = program.command('plugin').alias('plugins').description('Manage plugins');
|
|
196
|
+
axiumPlugin
|
|
197
|
+
.command('list')
|
|
198
|
+
.alias('ls')
|
|
199
|
+
.description('List loaded plugins')
|
|
200
|
+
.option('-l, --long', 'use the long listing format')
|
|
201
|
+
.option('--no-versions', 'do not show plugin versions')
|
|
202
|
+
.action((opt) => {
|
|
203
|
+
if (!plugins.size) {
|
|
204
|
+
console.log('No plugins loaded.');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!opt.long) {
|
|
208
|
+
console.log(Array.from(plugins.keys()).join(', '));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
console.log(styleText('whiteBright', plugins.size + ' plugin(s) loaded:'));
|
|
212
|
+
for (const plugin of plugins.values()) {
|
|
213
|
+
console.log(plugin.name, opt.versions ? plugin.version : '');
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
axiumPlugin
|
|
217
|
+
.command('info')
|
|
218
|
+
.description('Get information about a plugin')
|
|
219
|
+
.argument('<plugin>', 'the plugin to get information about')
|
|
220
|
+
.action((search) => {
|
|
221
|
+
const plugin = _findPlugin(search);
|
|
222
|
+
for (const line of pluginText(plugin))
|
|
223
|
+
console.log(line);
|
|
224
|
+
});
|
|
225
|
+
axiumPlugin
|
|
226
|
+
.command('remove')
|
|
227
|
+
.alias('rm')
|
|
228
|
+
.description('Remove a plugin')
|
|
229
|
+
.argument('<plugin>', 'the plugin to remove')
|
|
230
|
+
.action((search) => {
|
|
231
|
+
const plugin = _findPlugin(search);
|
|
232
|
+
config.plugins = config.plugins.filter(p => p !== plugin.specifier);
|
|
233
|
+
plugins.delete(plugin.name);
|
|
234
|
+
saveConfig();
|
|
235
|
+
});
|
|
236
|
+
await program.parseAsync();
|
|
237
|
+
}
|
|
238
|
+
catch (e_1) {
|
|
239
|
+
env_1.error = e_1;
|
|
240
|
+
env_1.hasError = true;
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
__disposeResources(env_1);
|
|
244
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
export declare const ClientConfig: z.ZodObject<{
|
|
3
|
+
token: z.ZodOptional<z.ZodNullable<z.ZodBase64>>;
|
|
4
|
+
server: z.ZodOptional<z.ZodNullable<z.ZodURL>>;
|
|
5
|
+
cache: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
6
|
+
fetched: z.ZodInt;
|
|
7
|
+
session: z.ZodObject<{
|
|
8
|
+
id: z.ZodUUID;
|
|
9
|
+
userId: z.ZodUUID;
|
|
10
|
+
expires: z.ZodCoercedDate<unknown>;
|
|
11
|
+
created: z.ZodCoercedDate<unknown>;
|
|
12
|
+
elevated: z.ZodBoolean;
|
|
13
|
+
user: z.ZodObject<{
|
|
14
|
+
id: z.ZodUUID;
|
|
15
|
+
name: z.ZodString;
|
|
16
|
+
email: z.ZodEmail;
|
|
17
|
+
emailVerified: z.ZodOptional<z.ZodNullable<z.ZodDate>>;
|
|
18
|
+
image: z.ZodOptional<z.ZodNullable<z.ZodURL>>;
|
|
19
|
+
preferences: z.ZodRecord<z.ZodString, z.ZodAny>;
|
|
20
|
+
roles: z.ZodArray<z.ZodString>;
|
|
21
|
+
registeredAt: z.ZodCoercedDate<unknown>;
|
|
22
|
+
isAdmin: z.ZodBoolean;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
}, z.core.$strip>;
|
|
25
|
+
apps: z.ZodArray<z.ZodObject<{
|
|
26
|
+
id: z.ZodString;
|
|
27
|
+
name: z.ZodOptional<z.ZodString>;
|
|
28
|
+
image: z.ZodOptional<z.ZodString>;
|
|
29
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
30
|
+
}, z.core.$strip>>;
|
|
31
|
+
}, z.core.$loose>>>;
|
|
32
|
+
plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
33
|
+
}, z.core.$loose>;
|
|
34
|
+
export interface ClientConfig extends z.infer<typeof ClientConfig> {
|
|
35
|
+
}
|
|
36
|
+
export declare const config: ClientConfig;
|
|
37
|
+
export declare function resolveServerURL(server: string): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { debug, warn } from '@axium/core/io';
|
|
2
|
+
import { App, Session, User } from '@axium/core';
|
|
3
|
+
import * as z from 'zod';
|
|
4
|
+
export const ClientConfig = z.looseObject({
|
|
5
|
+
token: z.base64().nullish(),
|
|
6
|
+
server: z.url().nullish(),
|
|
7
|
+
// Cache to reduce server load:
|
|
8
|
+
cache: z
|
|
9
|
+
.looseObject({
|
|
10
|
+
fetched: z.int(),
|
|
11
|
+
session: Session.extend({ user: User }),
|
|
12
|
+
apps: App.array(),
|
|
13
|
+
})
|
|
14
|
+
.nullish(),
|
|
15
|
+
plugins: z.string().array().default([]),
|
|
16
|
+
});
|
|
17
|
+
export const config = {
|
|
18
|
+
plugins: [],
|
|
19
|
+
};
|
|
20
|
+
export function resolveServerURL(server) {
|
|
21
|
+
if (!server.startsWith('http://') && !server.startsWith('https://'))
|
|
22
|
+
server = 'https://' + server;
|
|
23
|
+
const url = new URL(server);
|
|
24
|
+
if (url.pathname.endsWith('/api'))
|
|
25
|
+
url.pathname += '/';
|
|
26
|
+
else if (url.pathname.at(-1) == '/' && !url.pathname.endsWith('/api/'))
|
|
27
|
+
url.pathname += 'api/';
|
|
28
|
+
if (url.pathname != '/api/')
|
|
29
|
+
warn('Resolved server URL is not at the top level: ' + url.href);
|
|
30
|
+
else
|
|
31
|
+
debug('Resolved server URL: ' + url.href);
|
|
32
|
+
return url.href;
|
|
33
|
+
}
|
package/dist/requests.js
CHANGED
|
@@ -16,6 +16,9 @@ export async function fetchAPI(method, endpoint, data, ...params) {
|
|
|
16
16
|
};
|
|
17
17
|
if (method !== 'GET' && method !== 'HEAD')
|
|
18
18
|
options.body = JSON.stringify(data);
|
|
19
|
+
const search = method != 'GET' || typeof data != 'object' || data == null || !Object.keys(data).length
|
|
20
|
+
? ''
|
|
21
|
+
: '?' + new URLSearchParams(data).toString();
|
|
19
22
|
if (token)
|
|
20
23
|
options.headers.Authorization = 'Bearer ' + token;
|
|
21
24
|
const parts = [];
|
|
@@ -29,7 +32,7 @@ export async function fetchAPI(method, endpoint, data, ...params) {
|
|
|
29
32
|
throw new Error(`Missing parameter "${part.slice(1)}"`);
|
|
30
33
|
parts.push(value);
|
|
31
34
|
}
|
|
32
|
-
const response = await fetch(prefix + parts.join('/'), options);
|
|
35
|
+
const response = await fetch(prefix + parts.join('/') + search, options);
|
|
33
36
|
if (!response.headers.get('Content-Type')?.includes('application/json')) {
|
|
34
37
|
throw new Error(`Unexpected response type: ${response.headers.get('Content-Type')}`);
|
|
35
38
|
}
|
package/dist/user.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export declare function getSessions(userId: string): Promise<Session[]>;
|
|
|
13
13
|
export declare function logout(userId: string, ...sessionId: string[]): Promise<Session[]>;
|
|
14
14
|
export declare function logoutAll(userId: string): Promise<Session[]>;
|
|
15
15
|
export declare function logoutCurrentSession(): Promise<Session>;
|
|
16
|
-
export declare function register(_data: Record<string,
|
|
16
|
+
export declare function register(_data: Record<string, unknown>): Promise<void>;
|
|
17
17
|
export declare function userInfo(userId: string): Promise<UserPublic & Partial<User>>;
|
|
18
18
|
export declare function updateUser(userId: string, data: Record<string, FormDataEntryValue>): Promise<User>;
|
|
19
19
|
export declare function fullUserInfo(userId: string): Promise<User & {
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import type { User } from '@axium/core';
|
|
3
3
|
import { permissionNames, type AccessControllable } from '@axium/core/access';
|
|
4
4
|
import type { Entries } from 'utilium';
|
|
5
|
+
import FormDialog from './FormDialog.svelte';
|
|
5
6
|
import UserCard from './UserCard.svelte';
|
|
6
|
-
import type { Permission, AccessControl } from '@axium/core/access';
|
|
7
|
-
import type { User } from '@axium/core';
|
|
8
7
|
|
|
9
8
|
let {
|
|
10
9
|
item = $bindable(),
|
package/lib/FormDialog.svelte
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import Dialog from './Dialog.svelte';
|
|
3
3
|
|
|
4
|
-
function resolveRedirectAfter() {
|
|
5
|
-
const url = new URL(location.href);
|
|
6
|
-
const maybe = url.searchParams.get('after');
|
|
7
|
-
if (!maybe || maybe == url.pathname) return '/';
|
|
8
|
-
for (const prefix of ['/api/']) if (maybe.startsWith(prefix)) return '/';
|
|
9
|
-
return maybe || '/';
|
|
10
|
-
}
|
|
11
|
-
|
|
12
4
|
let {
|
|
13
5
|
children,
|
|
14
6
|
dialog = $bindable(),
|
|
@@ -52,8 +44,7 @@
|
|
|
52
44
|
const data = Object.fromEntries(new FormData(e.currentTarget));
|
|
53
45
|
submit(data)
|
|
54
46
|
.then(result => {
|
|
55
|
-
if (pageMode)
|
|
56
|
-
else dialog!.close();
|
|
47
|
+
if (!pageMode) dialog!.close();
|
|
57
48
|
})
|
|
58
49
|
.catch((e: any) => {
|
|
59
50
|
if (!e) error = 'An unknown error occurred';
|
package/lib/Login.svelte
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { loginByEmail } from '@axium/client/user';
|
|
3
3
|
import FormDialog from './FormDialog.svelte';
|
|
4
|
+
import redirectAfter from './auth_redirect.js';
|
|
4
5
|
|
|
5
6
|
let { dialog = $bindable(), fullPage = false }: { dialog?: HTMLDialogElement; fullPage?: boolean } = $props();
|
|
6
7
|
|
|
7
|
-
function submit(data: { email: string }) {
|
|
8
|
+
async function submit(data: { email: string }) {
|
|
8
9
|
if (typeof data.email != 'string') {
|
|
9
10
|
throw 'Tried to upload a file for an email. Huh?!';
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
await loginByEmail(data.email);
|
|
14
|
+
if (fullPage && redirectAfter) location.href = redirectAfter;
|
|
13
15
|
}
|
|
14
16
|
</script>
|
|
15
17
|
|
package/lib/NumberBar.svelte
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
</script>
|
|
4
4
|
|
|
5
5
|
<div class="Bar">
|
|
6
|
-
|
|
6
|
+
{#if max}
|
|
7
|
+
<div class="fill" style="width: {((value - min) / (max - min)) * 100}%"></div>
|
|
8
|
+
{/if}
|
|
7
9
|
{#if text}<span class="text">{text}</span>{/if}
|
|
8
10
|
</div>
|
|
9
11
|
|
package/lib/Register.svelte
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { register } from '@axium/client/user';
|
|
3
3
|
import FormDialog from './FormDialog.svelte';
|
|
4
|
+
import redirectAfter from './auth_redirect.js';
|
|
4
5
|
|
|
5
6
|
let { dialog = $bindable(), fullPage = false }: { dialog?: HTMLDialogElement; fullPage?: boolean } = $props();
|
|
7
|
+
|
|
8
|
+
async function submit(data: Record<string, FormDataEntryValue>) {
|
|
9
|
+
await register(data);
|
|
10
|
+
if (fullPage && redirectAfter) location.href = redirectAfter;
|
|
11
|
+
}
|
|
6
12
|
</script>
|
|
7
13
|
|
|
8
|
-
<FormDialog bind:dialog submitText="Register" submit
|
|
14
|
+
<FormDialog bind:dialog submitText="Register" {submit} pageMode={fullPage}>
|
|
9
15
|
<div>
|
|
10
16
|
<label for="name">Display Name</label>
|
|
11
17
|
<input name="name" type="text" required />
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { logout, logoutAll } from '@axium/client/user';
|
|
3
|
+
import type { Session, User } from '@axium/core';
|
|
4
|
+
import FormDialog from './FormDialog.svelte';
|
|
5
|
+
import Icon from './Icon.svelte';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
sessions = $bindable(),
|
|
9
|
+
currentSession,
|
|
10
|
+
user,
|
|
11
|
+
redirectAfterLogoutAll = false,
|
|
12
|
+
}: { sessions: Session[]; currentSession?: Session; user: User; redirectAfterLogoutAll?: boolean } = $props();
|
|
13
|
+
|
|
14
|
+
const dialogs = $state<Record<string, HTMLDialogElement>>({});
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
{#each sessions as session}
|
|
18
|
+
<div class="item session">
|
|
19
|
+
<p>
|
|
20
|
+
{session.id.slice(0, 4)}...{session.id.slice(-4)}
|
|
21
|
+
{#if session.id == currentSession?.id}
|
|
22
|
+
<span class="current">Current</span>
|
|
23
|
+
{/if}
|
|
24
|
+
{#if session.elevated}
|
|
25
|
+
<span class="elevated">Elevated</span>
|
|
26
|
+
{/if}
|
|
27
|
+
</p>
|
|
28
|
+
<p>Created {session.created.toLocaleString()}</p>
|
|
29
|
+
<p>Expires {session.expires.toLocaleString()}</p>
|
|
30
|
+
<button style:display="contents" onclick={() => dialogs['logout#' + session.id].showModal()}>
|
|
31
|
+
<Icon i="right-from-bracket" --size="16px" />
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
<FormDialog
|
|
35
|
+
bind:dialog={dialogs['logout#' + session.id]}
|
|
36
|
+
submit={async () => {
|
|
37
|
+
await logout(user.id, session.id);
|
|
38
|
+
dialogs['logout#' + session.id].remove();
|
|
39
|
+
sessions.splice(sessions.indexOf(session), 1);
|
|
40
|
+
if (session.id == currentSession?.id) window.location.href = '/';
|
|
41
|
+
}}
|
|
42
|
+
submitText="Logout"
|
|
43
|
+
>
|
|
44
|
+
<p>Are you sure you want to log out this session?</p>
|
|
45
|
+
</FormDialog>
|
|
46
|
+
{/each}
|
|
47
|
+
<span>
|
|
48
|
+
<button onclick={() => dialogs.logout_all.showModal()} class="danger">Logout All</button>
|
|
49
|
+
</span>
|
|
50
|
+
<FormDialog
|
|
51
|
+
bind:dialog={dialogs.logout_all}
|
|
52
|
+
submit={() => logoutAll(user.id).then(() => (redirectAfterLogoutAll ? (window.location.href = '/') : null))}
|
|
53
|
+
submitText="Logout All Sessions"
|
|
54
|
+
submitDanger
|
|
55
|
+
>
|
|
56
|
+
<p>Are you sure you want to log out all sessions?</p>
|
|
57
|
+
</FormDialog>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { User } from '@axium/core/user';
|
|
3
|
+
import { getUserImage } from '@axium/core';
|
|
4
|
+
import { fetchAPI } from '@axium/client/requests';
|
|
5
|
+
import Icon from './Icon.svelte';
|
|
6
|
+
import Popover from './Popover.svelte';
|
|
7
|
+
import Logout from './Logout.svelte';
|
|
8
|
+
|
|
9
|
+
const { user }: { user: Partial<User> } = $props();
|
|
10
|
+
|
|
11
|
+
let logout = $state<HTMLDialogElement>()!;
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<Popover>
|
|
15
|
+
{#snippet toggle()}
|
|
16
|
+
<div style:display="contents">
|
|
17
|
+
<img src={getUserImage(user)} alt={user.name} />
|
|
18
|
+
{user.name}
|
|
19
|
+
</div>
|
|
20
|
+
{/snippet}
|
|
21
|
+
|
|
22
|
+
<a class="menu-item" href="/account">
|
|
23
|
+
<Icon i="user" --size="1.5em" />
|
|
24
|
+
<span>Your Account</span>
|
|
25
|
+
</a>
|
|
26
|
+
|
|
27
|
+
{#if user.isAdmin}
|
|
28
|
+
<a class="menu-item" href="/admin">
|
|
29
|
+
<Icon i="gear-complex" --size="1.5em" />
|
|
30
|
+
<span>Administration</span>
|
|
31
|
+
</a>
|
|
32
|
+
{/if}
|
|
33
|
+
|
|
34
|
+
{#await fetchAPI('GET', 'apps')}
|
|
35
|
+
<i>Loading...</i>
|
|
36
|
+
{:then apps}
|
|
37
|
+
{#each apps as app}
|
|
38
|
+
<a class="menu-item" href="/{app.id}">
|
|
39
|
+
{#if app.image}
|
|
40
|
+
<img src={app.image} alt={app.name} width="1em" height="1em" />
|
|
41
|
+
{:else if app.icon}
|
|
42
|
+
<Icon i={app.icon} --size="1.5em" />
|
|
43
|
+
{:else}
|
|
44
|
+
<Icon i="image-circle-xmark" --size="1.5em" />
|
|
45
|
+
{/if}
|
|
46
|
+
<span>{app.name}</span>
|
|
47
|
+
</a>
|
|
48
|
+
{:else}
|
|
49
|
+
<i>No apps available.</i>
|
|
50
|
+
{/each}
|
|
51
|
+
{:catch}
|
|
52
|
+
<i>Couldn't load apps.</i>
|
|
53
|
+
{/await}
|
|
54
|
+
|
|
55
|
+
<span class="menu-item logout" onclick={() => logout.showModal()}>
|
|
56
|
+
<Icon i="right-from-bracket" --size="1.5em" --fill="hsl(0 33 var(--fg-light))" />
|
|
57
|
+
<span>Logout</span>
|
|
58
|
+
</span>
|
|
59
|
+
</Popover>
|
|
60
|
+
|
|
61
|
+
<Logout bind:dialog={logout} />
|
|
62
|
+
|
|
63
|
+
<style>
|
|
64
|
+
img {
|
|
65
|
+
width: 2em;
|
|
66
|
+
height: 2em;
|
|
67
|
+
border-radius: 50%;
|
|
68
|
+
vertical-align: middle;
|
|
69
|
+
margin-right: 0.5em;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
span.logout > span {
|
|
73
|
+
color: hsl(0 33 var(--fg-light));
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getCurrentSession } from '@axium/client/user';
|
|
2
|
+
|
|
3
|
+
function resolveRedirect(): string | false {
|
|
4
|
+
const url = new URL(location.href);
|
|
5
|
+
const maybe = url.searchParams.get('after');
|
|
6
|
+
if (!['/login', '/register'].includes(url.pathname)) return false;
|
|
7
|
+
if (!maybe || maybe == url.pathname) return '/';
|
|
8
|
+
|
|
9
|
+
if (maybe[0] != '/' || maybe[1] == '/') {
|
|
10
|
+
console.error('Ignoring potentially malicious redirect:', maybe);
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const redirect = new URL(maybe, location.origin);
|
|
15
|
+
|
|
16
|
+
return redirect.pathname + redirect.search || '/';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const redirect = resolveRedirect();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
if (!redirect) throw 'No redirect';
|
|
23
|
+
// Auto-redirect if already logged in.
|
|
24
|
+
const session = await getCurrentSession();
|
|
25
|
+
if (session) location.href = redirect;
|
|
26
|
+
} catch {}
|
|
27
|
+
|
|
28
|
+
export default redirect;
|
package/lib/index.ts
CHANGED
|
@@ -11,7 +11,9 @@ export { default as Popover } from './Popover.svelte';
|
|
|
11
11
|
export { default as Preference } from './Preference.svelte';
|
|
12
12
|
export { default as Preferences } from './Preferences.svelte';
|
|
13
13
|
export { default as Register } from './Register.svelte';
|
|
14
|
+
export { default as SessionList } from './SessionList.svelte';
|
|
14
15
|
export { default as Toast } from './Toast.svelte';
|
|
15
16
|
export { default as Upload } from './Upload.svelte';
|
|
16
17
|
export { default as UserCard } from './UserCard.svelte';
|
|
18
|
+
export { default as UserMenu } from './UserMenu.svelte';
|
|
17
19
|
export { default as WithContextMenu } from './WithContextMenu.svelte';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"author": "James Prevett <jp@jamespre.dev>",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -15,25 +15,32 @@
|
|
|
15
15
|
"bugs": {
|
|
16
16
|
"url": "https://github.com/james-pre/axium/issues"
|
|
17
17
|
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"axium-client": "dist/cli/index.js",
|
|
20
|
+
"axc": "dist/cli/index.js"
|
|
21
|
+
},
|
|
18
22
|
"type": "module",
|
|
19
23
|
"main": "dist/index.js",
|
|
20
24
|
"types": "dist/index.d.ts",
|
|
21
25
|
"files": [
|
|
22
26
|
"assets",
|
|
23
27
|
"dist",
|
|
24
|
-
"lib"
|
|
28
|
+
"lib",
|
|
29
|
+
"styles",
|
|
30
|
+
"axium-client.service"
|
|
25
31
|
],
|
|
26
32
|
"exports": {
|
|
27
33
|
".": "./dist/index.js",
|
|
28
34
|
"./*": "./dist/*.js",
|
|
29
35
|
"./components": "./lib/index.js",
|
|
30
|
-
"./components/*": "./lib/*.svelte"
|
|
36
|
+
"./components/*": "./lib/*.svelte",
|
|
37
|
+
"./styles/*": "./styles/*.css"
|
|
31
38
|
},
|
|
32
39
|
"scripts": {
|
|
33
40
|
"build": "tsc"
|
|
34
41
|
},
|
|
35
42
|
"peerDependencies": {
|
|
36
|
-
"@axium/core": ">=0.
|
|
43
|
+
"@axium/core": ">=0.9.0",
|
|
37
44
|
"utilium": "^2.3.8",
|
|
38
45
|
"zod": "^4.0.5",
|
|
39
46
|
"svelte": "^5.36.0"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
.section {
|
|
2
|
+
width: 50%;
|
|
3
|
+
padding-top: 4em;
|
|
4
|
+
|
|
5
|
+
/* This is causing duplicate separators when removing sessions/passkeys
|
|
6
|
+
> div:has(+ div) {
|
|
7
|
+
border-bottom: 1px solid #8888;
|
|
8
|
+
}
|
|
9
|
+
*/
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.section .item {
|
|
13
|
+
display: grid;
|
|
14
|
+
align-items: center;
|
|
15
|
+
width: 100%;
|
|
16
|
+
gap: 1em;
|
|
17
|
+
text-wrap: nowrap;
|
|
18
|
+
border-top: 1px solid #8888;
|
|
19
|
+
padding-bottom: 1em;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.info {
|
|
23
|
+
grid-template-columns: 10em 1fr 2em;
|
|
24
|
+
|
|
25
|
+
> :first-child {
|
|
26
|
+
margin-left: 1em;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
> :nth-child(2) {
|
|
30
|
+
text-overflow: ellipsis;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.passkey {
|
|
36
|
+
grid-template-columns: 1em 1em 1fr 1fr 1em 1em;
|
|
37
|
+
|
|
38
|
+
dfn:not(.disabled) {
|
|
39
|
+
cursor: help;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.session {
|
|
44
|
+
grid-template-columns: 1fr 1fr 1fr 1em;
|
|
45
|
+
|
|
46
|
+
.current {
|
|
47
|
+
border-radius: 2em;
|
|
48
|
+
padding: 0 0.5em;
|
|
49
|
+
background-color: #337;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.elevated {
|
|
53
|
+
border-radius: 2em;
|
|
54
|
+
padding: 0 0.5em;
|
|
55
|
+
background-color: #733;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/styles/list.css
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
.list-container {
|
|
2
|
+
overflow-x: hidden;
|
|
3
|
+
overflow-y: scroll;
|
|
4
|
+
|
|
5
|
+
.list {
|
|
6
|
+
height: 100%;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.list {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
padding: 0.5em;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.list-header {
|
|
17
|
+
font-weight: bold;
|
|
18
|
+
border-bottom: 1.5px solid var(--fg-accent);
|
|
19
|
+
position: sticky;
|
|
20
|
+
top: 0em;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.list-item-container {
|
|
24
|
+
text-decoration: none;
|
|
25
|
+
color: inherit;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.list-item {
|
|
29
|
+
display: grid;
|
|
30
|
+
grid-template-columns: 1em 4fr 15em 5em repeat(3, 1em);
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: 1em;
|
|
33
|
+
padding: 0.5em;
|
|
34
|
+
overflow: hidden;
|
|
35
|
+
text-wrap: nowrap;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.list-item:not(.list-header, :first-child) {
|
|
39
|
+
border-top: 1px solid var(--fg-accent);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.list-item:not(.list-header):hover {
|
|
43
|
+
background-color: var(--bg-alt);
|
|
44
|
+
cursor: pointer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
p.list-empty {
|
|
48
|
+
text-align: center;
|
|
49
|
+
color: #888;
|
|
50
|
+
margin-top: 1em;
|
|
51
|
+
font-style: italic;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.list-item .action {
|
|
55
|
+
visibility: hidden;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.list-item:hover .action {
|
|
59
|
+
visibility: visible;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
}
|