Initial commit: scaffold stack.gallery catalog frontend
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# directories
|
||||
node_modules/
|
||||
dist_ts_web/
|
||||
dist_watch/
|
||||
.nogit/
|
||||
.playwright-mcp/
|
||||
|
||||
# files
|
||||
package-lock.json
|
||||
17
html/index.html
Normal file
17
html/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="user-scalable=no, initial-scale=1">
|
||||
<meta charset="utf-8" />
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0px;
|
||||
background: #222222;
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="/bundle.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
18
html/index.ts
Normal file
18
html/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as deesWccTools from '@design.estate/dees-wcctools';
|
||||
import * as elements from '../ts_web/elements/index.js';
|
||||
import * as pages from '../ts_web/pages/index.js';
|
||||
|
||||
deesWccTools.setupWccTools({
|
||||
sections: [
|
||||
{
|
||||
name: 'Pages',
|
||||
type: 'pages',
|
||||
items: pages,
|
||||
},
|
||||
{
|
||||
name: 'Elements',
|
||||
type: 'elements',
|
||||
items: elements,
|
||||
},
|
||||
],
|
||||
});
|
||||
25
npmextra.json
Normal file
25
npmextra.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
"preset": "element"
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "wcc",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "stack.gallery",
|
||||
"gitrepo": "catalog",
|
||||
"description": "UI component catalog for Stack.Gallery",
|
||||
"npmPackagename": "@stack.gallery/catalog",
|
||||
"license": "MIT",
|
||||
"projectDomain": "stack.gallery"
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@stack.gallery/catalog",
|
||||
"version": "1.0.1",
|
||||
"private": false,
|
||||
"description": "UI component catalog for Stack.Gallery",
|
||||
"main": "dist_ts_web/index.js",
|
||||
"typings": "dist_ts_web/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tstest test/",
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@design.estate/dees-catalog": "^3.43.0",
|
||||
"@design.estate/dees-domtools": "^2.3.8",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@design.estate/dees-wcctools": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@types/node": "^25.3.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
"last 1 Chrome versions"
|
||||
]
|
||||
}
|
||||
9294
pnpm-lock.yaml
generated
Normal file
9294
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
0
readme.hints.md
Normal file
0
readme.hints.md
Normal file
9
ts_web/00_commitinfo_data.ts
Normal file
9
ts_web/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* autocreated commitance info file
|
||||
* for: @stack.gallery/catalog
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/catalog',
|
||||
version: '1.0.0',
|
||||
description: 'UI component catalog for Stack.Gallery',
|
||||
};
|
||||
19
ts_web/elements/index.ts
Normal file
19
ts_web/elements/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Small reusable components
|
||||
export * from './sg-stat-card.js';
|
||||
export * from './sg-protocol-badge.js';
|
||||
export * from './sg-install-snippet.js';
|
||||
|
||||
// View components
|
||||
export * from './sg-login-view.js';
|
||||
export * from './sg-dashboard-view.js';
|
||||
export * from './sg-organizations-list-view.js';
|
||||
export * from './sg-organization-detail-view.js';
|
||||
export * from './sg-repository-detail-view.js';
|
||||
export * from './sg-packages-list-view.js';
|
||||
export * from './sg-package-detail-view.js';
|
||||
export * from './sg-tokens-view.js';
|
||||
export * from './sg-settings-view.js';
|
||||
export * from './sg-admin-providers-view.js';
|
||||
export * from './sg-admin-provider-form-view.js';
|
||||
export * from './sg-public-layout.js';
|
||||
export * from './sg-public-search-view.js';
|
||||
674
ts_web/elements/sg-admin-provider-form-view.ts
Normal file
674
ts_web/elements/sg-admin-provider-form-view.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgAuthProviderDetail } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-admin-provider-form-view': SgAdminProviderFormView;
|
||||
}
|
||||
}
|
||||
|
||||
interface IProviderFormData {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: 'oidc' | 'ldap';
|
||||
status: 'active' | 'disabled' | 'testing';
|
||||
priority: number;
|
||||
// OIDC fields
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
issuerUrl?: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
userInfoUrl?: string;
|
||||
scopes?: string;
|
||||
// LDAP fields
|
||||
ldapUrl?: string;
|
||||
bindDn?: string;
|
||||
bindPassword?: string;
|
||||
baseDn?: string;
|
||||
userFilter?: string;
|
||||
// Attribute mapping
|
||||
usernameAttr?: string;
|
||||
emailAttr?: string;
|
||||
displayNameAttr?: string;
|
||||
// Provisioning
|
||||
autoCreateUsers?: boolean;
|
||||
defaultRole?: string;
|
||||
}
|
||||
|
||||
@customElement('sg-admin-provider-form-view')
|
||||
export class SgAdminProviderFormView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 800px; background: #09090b;">
|
||||
<sg-admin-provider-form-view
|
||||
.provider=${null}
|
||||
></sg-admin-provider-form-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Admin'];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor provider: ISgAuthProviderDetail | null = null;
|
||||
|
||||
private formData: IProviderFormData = {
|
||||
name: '',
|
||||
displayName: '',
|
||||
type: 'oidc',
|
||||
status: 'testing',
|
||||
priority: 10,
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
issuerUrl: '',
|
||||
authorizationUrl: '',
|
||||
tokenUrl: '',
|
||||
userInfoUrl: '',
|
||||
scopes: 'openid profile email',
|
||||
ldapUrl: '',
|
||||
bindDn: '',
|
||||
bindPassword: '',
|
||||
baseDn: '',
|
||||
userFilter: '(uid={{username}})',
|
||||
usernameAttr: 'preferred_username',
|
||||
emailAttr: 'email',
|
||||
displayNameAttr: 'name',
|
||||
autoCreateUsers: true,
|
||||
defaultRole: 'member',
|
||||
};
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
if (this.provider) {
|
||||
this.formData = {
|
||||
...this.formData,
|
||||
name: this.provider.name,
|
||||
displayName: this.provider.displayName,
|
||||
type: this.provider.type,
|
||||
status: this.provider.status,
|
||||
priority: this.provider.priority,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#ddd')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.form-input.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#aaa', '#666')};
|
||||
}
|
||||
|
||||
.form-select {
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Type selector */
|
||||
.type-selector {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 10px 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.type-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
|
||||
.type-btn:hover:not(.active) {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: ${cssManager.bdTheme('#ddd', '#333')};
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch.on {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.toggle-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.toggle-switch.on::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.form-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 10px 24px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.footer-cancel-btn {
|
||||
padding: 10px 24px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.footer-cancel-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const isNew = !this.provider;
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="page-title">${isNew ? 'Add Authentication Provider' : 'Edit Provider'}</div>
|
||||
<button class="cancel-btn" @click=${() => this.emitEvent('cancel', {})}>Cancel</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Basic Information</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Provider Type</label>
|
||||
<div class="type-selector">
|
||||
<button
|
||||
class="type-btn ${this.formData.type === 'oidc' ? 'active' : ''}"
|
||||
@click=${() => this.updateField('type', 'oidc')}
|
||||
>OpenID Connect</button>
|
||||
<button
|
||||
class="type-btn ${this.formData.type === 'ldap' ? 'active' : ''}"
|
||||
@click=${() => this.updateField('type', 'ldap')}
|
||||
>LDAP</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Internal Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.name}
|
||||
@input=${(e: InputEvent) => this.updateField('name', (e.target as HTMLInputElement).value)}
|
||||
placeholder="github-sso"
|
||||
>
|
||||
<span class="form-hint">Unique identifier (lowercase, no spaces)</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.formData.displayName}
|
||||
@input=${(e: InputEvent) => this.updateField('displayName', (e.target as HTMLInputElement).value)}
|
||||
placeholder="GitHub SSO"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select
|
||||
class="form-select"
|
||||
@change=${(e: Event) => this.updateField('status', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="testing" ?selected=${this.formData.status === 'testing'}>Testing</option>
|
||||
<option value="active" ?selected=${this.formData.status === 'active'}>Active</option>
|
||||
<option value="disabled" ?selected=${this.formData.status === 'disabled'}>Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
.value=${String(this.formData.priority)}
|
||||
@input=${(e: InputEvent) => this.updateField('priority', parseInt((e.target as HTMLInputElement).value) || 0)}
|
||||
min="0"
|
||||
>
|
||||
<span class="form-hint">Lower number = higher priority</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.formData.type === 'oidc' ? this.renderOidcFields() : this.renderLdapFields()}
|
||||
|
||||
${this.renderAttributeMapping()}
|
||||
|
||||
${this.renderProvisioning()}
|
||||
|
||||
<div class="form-footer">
|
||||
<button class="save-btn" @click=${this.handleSave}>
|
||||
${isNew ? 'Create Provider' : 'Save Changes'}
|
||||
</button>
|
||||
<button class="footer-cancel-btn" @click=${() => this.emitEvent('cancel', {})}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOidcFields(): TemplateResult {
|
||||
return html`
|
||||
<div class="section">
|
||||
<div class="section-title">OpenID Connect Configuration</div>
|
||||
<div class="section-subtitle">Configure your OIDC provider endpoints and credentials</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.clientId || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('clientId', (e.target as HTMLInputElement).value)}
|
||||
placeholder="your-client-id"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.clientSecret || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('clientSecret', (e.target as HTMLInputElement).value)}
|
||||
placeholder="your-client-secret"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group full">
|
||||
<label class="form-label">Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.issuerUrl || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('issuerUrl', (e.target as HTMLInputElement).value)}
|
||||
placeholder="https://accounts.google.com"
|
||||
>
|
||||
<span class="form-hint">The OIDC discovery endpoint base URL</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Authorization URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.authorizationUrl || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('authorizationUrl', (e.target as HTMLInputElement).value)}
|
||||
placeholder="Auto-discovered from issuer"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Token URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.tokenUrl || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('tokenUrl', (e.target as HTMLInputElement).value)}
|
||||
placeholder="Auto-discovered from issuer"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">UserInfo URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.userInfoUrl || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('userInfoUrl', (e.target as HTMLInputElement).value)}
|
||||
placeholder="Auto-discovered from issuer"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.scopes || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('scopes', (e.target as HTMLInputElement).value)}
|
||||
placeholder="openid profile email"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLdapFields(): TemplateResult {
|
||||
return html`
|
||||
<div class="section">
|
||||
<div class="section-title">LDAP Configuration</div>
|
||||
<div class="section-subtitle">Configure your LDAP/Active Directory server connection</div>
|
||||
|
||||
<div class="form-group full">
|
||||
<label class="form-label">LDAP URL</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.ldapUrl || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('ldapUrl', (e.target as HTMLInputElement).value)}
|
||||
placeholder="ldaps://ldap.example.com:636"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bind DN</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.bindDn || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('bindDn', (e.target as HTMLInputElement).value)}
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bind Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.bindPassword || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('bindPassword', (e.target as HTMLInputElement).value)}
|
||||
placeholder="Bind password"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group full">
|
||||
<label class="form-label">Base DN</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.baseDn || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('baseDn', (e.target as HTMLInputElement).value)}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group full">
|
||||
<label class="form-label">User Search Filter</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.userFilter || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('userFilter', (e.target as HTMLInputElement).value)}
|
||||
placeholder="(uid={{username}})"
|
||||
>
|
||||
<span class="form-hint">Use {{username}} as placeholder for the login username</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAttributeMapping(): TemplateResult {
|
||||
return html`
|
||||
<div class="section">
|
||||
<div class="section-title">Attribute Mapping</div>
|
||||
<div class="section-subtitle">Map provider attributes to registry user fields</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.usernameAttr || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('usernameAttr', (e.target as HTMLInputElement).value)}
|
||||
placeholder="preferred_username"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.emailAttr || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('emailAttr', (e.target as HTMLInputElement).value)}
|
||||
placeholder="email"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Display Name Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input mono"
|
||||
.value=${this.formData.displayNameAttr || ''}
|
||||
@input=${(e: InputEvent) => this.updateField('displayNameAttr', (e.target as HTMLInputElement).value)}
|
||||
placeholder="name"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProvisioning(): TemplateResult {
|
||||
return html`
|
||||
<div class="section">
|
||||
<div class="section-title">Provisioning</div>
|
||||
<div class="section-subtitle">Control automatic user creation and default roles</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div>
|
||||
<div style="font-size: 14px; font-weight: 500; color: ${cssManager.bdTheme('#111', '#fff')}">Auto-create Users</div>
|
||||
<div style="font-size: 12px; color: ${cssManager.bdTheme('#888', '#777')}">Automatically create accounts for new users who authenticate via this provider</div>
|
||||
</div>
|
||||
<div
|
||||
class="toggle-switch ${this.formData.autoCreateUsers ? 'on' : ''}"
|
||||
@click=${() => { this.formData.autoCreateUsers = !this.formData.autoCreateUsers; this.requestUpdate(); }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default Role</label>
|
||||
<select
|
||||
class="form-select"
|
||||
@change=${(e: Event) => this.updateField('defaultRole', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="member" ?selected=${this.formData.defaultRole === 'member'}>Member</option>
|
||||
<option value="admin" ?selected=${this.formData.defaultRole === 'admin'}>Admin</option>
|
||||
</select>
|
||||
<span class="form-hint">Role assigned to newly provisioned users</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private updateField(field: string, value: unknown) {
|
||||
(this.formData as unknown as Record<string, unknown>)[field] = value;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleSave() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('save', {
|
||||
detail: { providerData: { ...this.formData } },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private emitEvent(name: string, detail: Record<string, unknown>) {
|
||||
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
471
ts_web/elements/sg-admin-providers-view.ts
Normal file
471
ts_web/elements/sg-admin-providers-view.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgAuthProviderDetail, ISgPlatformSettings } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-admin-providers-view': SgAdminProvidersView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-admin-providers-view')
|
||||
export class SgAdminProvidersView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1000px; background: #09090b;">
|
||||
<sg-admin-providers-view
|
||||
.providers=${[
|
||||
{ id: 'p1', name: 'github', displayName: 'GitHub SSO', type: 'oidc', status: 'active', priority: 1, createdAt: '2025-08-01', updatedAt: '2026-03-15', lastTestedAt: '2026-03-15T10:00:00Z', lastTestResult: 'success' },
|
||||
{ id: 'p2', name: 'corp-ldap', displayName: 'Corporate LDAP', type: 'ldap', status: 'active', priority: 2, createdAt: '2025-09-15', updatedAt: '2026-02-20', lastTestedAt: '2026-02-20T14:00:00Z', lastTestResult: 'success' },
|
||||
{ id: 'p3', name: 'okta', displayName: 'Okta (Staging)', type: 'oidc', status: 'testing', priority: 3, createdAt: '2026-03-01', updatedAt: '2026-03-18', lastTestedAt: '2026-03-18T09:30:00Z', lastTestResult: 'failure', lastTestError: 'Invalid client_id' },
|
||||
]}
|
||||
.settings=${{
|
||||
localAuthEnabled: true,
|
||||
allowUserRegistration: false,
|
||||
sessionDurationMinutes: 1440,
|
||||
defaultProviderId: 'p1',
|
||||
}}
|
||||
></sg-admin-providers-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Admin'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor providers: ISgAuthProviderDetail[] = [];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor settings: ISgPlatformSettings = {
|
||||
localAuthEnabled: true,
|
||||
allowUserRegistration: false,
|
||||
sessionDurationMinutes: 1440,
|
||||
};
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Platform settings */
|
||||
.settings-box {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.toggle-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.toggle-hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: ${cssManager.bdTheme('#ddd', '#333')};
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch.on {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.toggle-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.toggle-switch.on::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.save-settings-btn {
|
||||
align-self: flex-start;
|
||||
padding: 8px 20px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.save-settings-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Providers section */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Provider list */
|
||||
.provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.provider-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.provider-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.provider-type {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 6px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.provider-type.oidc {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.provider-type.ldap {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 6px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-badge.disabled {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-badge.testing {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.provider-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.test-result.success {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.test-result.failure {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 32px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="page-title">Authentication Management</div>
|
||||
|
||||
<div class="settings-box">
|
||||
<div class="settings-title">Platform Settings</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-info">
|
||||
<div class="toggle-label">Local Authentication</div>
|
||||
<div class="toggle-hint">Allow users to log in with email and password</div>
|
||||
</div>
|
||||
<div
|
||||
class="toggle-switch ${this.settings.localAuthEnabled ? 'on' : ''}"
|
||||
@click=${() => { this.settings = { ...this.settings, localAuthEnabled: !this.settings.localAuthEnabled }; this.requestUpdate(); }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-info">
|
||||
<div class="toggle-label">User Registration</div>
|
||||
<div class="toggle-hint">Allow new users to create accounts</div>
|
||||
</div>
|
||||
<div
|
||||
class="toggle-switch ${this.settings.allowUserRegistration ? 'on' : ''}"
|
||||
@click=${() => { this.settings = { ...this.settings, allowUserRegistration: !this.settings.allowUserRegistration }; this.requestUpdate(); }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="toggle-info">
|
||||
<div class="toggle-label">Session Duration</div>
|
||||
<div class="toggle-hint">How long sessions remain valid (in minutes)</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="input-field"
|
||||
.value=${String(this.settings.sessionDurationMinutes)}
|
||||
@input=${(e: InputEvent) => {
|
||||
this.settings = { ...this.settings, sessionDurationMinutes: parseInt((e.target as HTMLInputElement).value) || 0 };
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<button class="save-settings-btn" @click=${this.handleSaveSettings}>Save Settings</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="section-header">
|
||||
<div class="section-title">Authentication Providers</div>
|
||||
<button class="add-btn" @click=${() => this.emitEvent('create', {})}>+ Add Provider</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.providers.length > 0
|
||||
? html`
|
||||
<div class="provider-list">
|
||||
${this.providers.map((provider) => this.renderProvider(provider))}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No authentication providers configured. Add an OIDC or LDAP provider to enable external authentication.</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProvider(provider: ISgAuthProviderDetail): TemplateResult {
|
||||
return html`
|
||||
<div class="provider-row">
|
||||
<div class="provider-info">
|
||||
<div class="provider-name-row">
|
||||
<span class="provider-name">${provider.displayName}</span>
|
||||
<span class="provider-type ${provider.type}">${provider.type}</span>
|
||||
<span class="status-badge ${provider.status}">${provider.status}</span>
|
||||
</div>
|
||||
<div class="provider-meta">
|
||||
<span>Priority: ${provider.priority}</span>
|
||||
<span>Updated ${this.formatDate(provider.updatedAt)}</span>
|
||||
${provider.lastTestedAt
|
||||
? html`
|
||||
<span>
|
||||
Last test:
|
||||
<span class="test-result ${provider.lastTestResult}">
|
||||
${provider.lastTestResult}
|
||||
</span>
|
||||
${provider.lastTestError ? html` - ${provider.lastTestError}` : ''}
|
||||
</span>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<button class="action-btn" @click=${() => this.emitEvent('test', { providerId: provider.id })}>Test</button>
|
||||
<button class="action-btn" @click=${() => this.emitEvent('edit', { providerId: provider.id })}>Edit</button>
|
||||
<button class="action-btn delete" @click=${() => this.emitEvent('delete', { providerId: provider.id })}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleSaveSettings() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('save-settings', {
|
||||
detail: { settings: { ...this.settings } },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
private emitEvent(name: string, detail: Record<string, unknown>) {
|
||||
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
400
ts_web/elements/sg-dashboard-view.ts
Normal file
400
ts_web/elements/sg-dashboard-view.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgDashboardStats, ISgPackage, ISgOrganization } from '../interfaces.js';
|
||||
import './sg-protocol-badge.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-dashboard-view': SgDashboardView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-dashboard-view')
|
||||
export class SgDashboardView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px; background: #09090b;">
|
||||
<sg-dashboard-view
|
||||
.stats=${{
|
||||
organizationCount: 5,
|
||||
packageCount: 128,
|
||||
totalDownloads: 45230,
|
||||
tokenCount: 12,
|
||||
}}
|
||||
.recentPackages=${[
|
||||
{ id: '1', name: '@myorg/web-framework', protocol: 'npm', organizationId: 'org1', repositoryId: 'repo1', latestVersion: '3.2.1', isPrivate: false, downloadCount: 1240, updatedAt: '2026-03-19T10:30:00Z', description: 'Modern web framework' },
|
||||
{ id: '2', name: 'myorg/api-gateway', protocol: 'oci', organizationId: 'org1', repositoryId: 'repo2', latestVersion: 'v1.8.0', isPrivate: true, downloadCount: 890, updatedAt: '2026-03-18T14:20:00Z', description: 'API gateway image' },
|
||||
{ id: '3', name: 'data-utils', protocol: 'pypi', organizationId: 'org2', repositoryId: 'repo3', latestVersion: '0.9.4', isPrivate: false, downloadCount: 320, updatedAt: '2026-03-17T08:15:00Z' },
|
||||
]}
|
||||
.organizations=${[
|
||||
{ id: 'org1', name: 'myorg', displayName: 'My Organization', isPublic: true, memberCount: 8, createdAt: '2025-06-01' },
|
||||
{ id: 'org2', name: 'acme', displayName: 'ACME Corp', isPublic: false, memberCount: 25, createdAt: '2025-01-15' },
|
||||
]}
|
||||
></sg-dashboard-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Dashboard'];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor stats: ISgDashboardStats = {
|
||||
organizationCount: 0,
|
||||
packageCount: 0,
|
||||
totalDownloads: 0,
|
||||
tokenCount: 0,
|
||||
};
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor recentPackages: ISgPackage[] = [];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor organizations: ISgOrganization[] = [];
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Stats grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.section-action {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
padding: 6px 12px;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.section-action:hover {
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Package list */
|
||||
.package-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.package-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.package-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.package-row:hover {
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.package-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.package-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.package-version {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
padding: 2px 8px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.package-downloads {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Orgs grid */
|
||||
.orgs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.org-card {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.org-card:hover {
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.org-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.org-handle {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.org-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
/* Quick actions */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-action-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#333', '#ddd')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
font-size: 14px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="dashboard">
|
||||
<div class="page-title">Dashboard</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Organizations</div>
|
||||
<div class="stat-value">${this.stats.organizationCount}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Packages</div>
|
||||
<div class="stat-value">${this.stats.packageCount}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Downloads</div>
|
||||
<div class="stat-value">${this.formatNumber(this.stats.totalDownloads)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">API Tokens</div>
|
||||
<div class="stat-value">${this.stats.tokenCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Recent Packages</div>
|
||||
<button class="section-action" @click=${() => this.navigate('packages')}>View all</button>
|
||||
</div>
|
||||
${this.recentPackages.length > 0
|
||||
? html`
|
||||
<div class="package-list">
|
||||
${this.recentPackages.map(
|
||||
(pkg) => html`
|
||||
<div class="package-row" @click=${() => this.navigate('package', pkg.id)}>
|
||||
<div class="package-info">
|
||||
<div class="package-name">${pkg.name}</div>
|
||||
<div class="package-meta">
|
||||
<sg-protocol-badge .protocol=${pkg.protocol}></sg-protocol-badge>
|
||||
${pkg.description || ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="package-right">
|
||||
${pkg.latestVersion ? html`<span class="package-version">${pkg.latestVersion}</span>` : ''}
|
||||
<span class="package-downloads">${this.formatNumber(pkg.downloadCount)} pulls</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No packages published yet</div>`}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Organizations</div>
|
||||
<button class="section-action" @click=${() => this.navigate('org')}>View all</button>
|
||||
</div>
|
||||
${this.organizations.length > 0
|
||||
? html`
|
||||
<div class="orgs-grid">
|
||||
${this.organizations.map(
|
||||
(org) => html`
|
||||
<div class="org-card" @click=${() => this.navigate('org', org.id)}>
|
||||
<div class="org-name">${org.displayName}</div>
|
||||
<div class="org-handle">@${org.name}</div>
|
||||
<div class="org-meta">
|
||||
<span>${org.memberCount} members</span>
|
||||
<span>${org.isPublic ? 'Public' : 'Private'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No organizations yet</div>`}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Quick Actions</div>
|
||||
<div class="quick-actions">
|
||||
<button class="quick-action-btn" @click=${() => this.navigate('org')}>Browse Organizations</button>
|
||||
<button class="quick-action-btn" @click=${() => this.navigate('packages')}>Browse Packages</button>
|
||||
<button class="quick-action-btn" @click=${() => this.navigate('tokens')}>Manage Tokens</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
private navigate(type: 'org' | 'package' | 'tokens' | 'packages', id?: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('navigate', {
|
||||
detail: { type, id },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
152
ts_web/elements/sg-install-snippet.ts
Normal file
152
ts_web/elements/sg-install-snippet.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { TSgProtocol } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-install-snippet': SgInstallSnippet;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-install-snippet')
|
||||
export class SgInstallSnippet extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 500px;">
|
||||
<sg-install-snippet .protocol=${'npm'} .packageName=${'@myorg/mypackage'}></sg-install-snippet>
|
||||
<sg-install-snippet .protocol=${'oci'} .packageName=${'myorg/myimage'} .registryUrl=${'registry.stack.gallery'}></sg-install-snippet>
|
||||
<sg-install-snippet .protocol=${'pypi'} .packageName=${'mypackage'} .registryUrl=${'registry.stack.gallery'}></sg-install-snippet>
|
||||
<sg-install-snippet .protocol=${'cargo'} .packageName=${'mypackage'}></sg-install-snippet>
|
||||
</div>
|
||||
`;
|
||||
public static demoGroups = ['Packages'];
|
||||
|
||||
@property({ type: String })
|
||||
accessor protocol: TSgProtocol = 'npm';
|
||||
|
||||
@property({ type: String })
|
||||
accessor packageName: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor registryUrl: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.snippet {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 12px 16px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#333', '#e5e5e5')};
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
margin-bottom: 4px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: ${cssManager.bdTheme('#ddd', '#444')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private getInstallCommand(): { label: string; command: string } {
|
||||
const reg = this.registryUrl;
|
||||
switch (this.protocol) {
|
||||
case 'npm':
|
||||
return {
|
||||
label: 'npm',
|
||||
command: reg
|
||||
? `npm install ${this.packageName} --registry=https://${reg}`
|
||||
: `npm install ${this.packageName}`,
|
||||
};
|
||||
case 'oci':
|
||||
return {
|
||||
label: 'Docker / OCI',
|
||||
command: reg
|
||||
? `docker pull ${reg}/${this.packageName}`
|
||||
: `docker pull ${this.packageName}`,
|
||||
};
|
||||
case 'maven':
|
||||
return {
|
||||
label: 'Maven',
|
||||
command: `<dependency>\n <groupId>${this.packageName.split('/')[0] || ''}</groupId>\n <artifactId>${this.packageName.split('/')[1] || this.packageName}</artifactId>\n</dependency>`,
|
||||
};
|
||||
case 'cargo':
|
||||
return {
|
||||
label: 'Cargo',
|
||||
command: `cargo add ${this.packageName}`,
|
||||
};
|
||||
case 'pypi':
|
||||
return {
|
||||
label: 'pip',
|
||||
command: reg
|
||||
? `pip install ${this.packageName} --index-url https://${reg}/simple/`
|
||||
: `pip install ${this.packageName}`,
|
||||
};
|
||||
case 'composer':
|
||||
return {
|
||||
label: 'Composer',
|
||||
command: `composer require ${this.packageName}`,
|
||||
};
|
||||
case 'rubygems':
|
||||
return {
|
||||
label: 'gem',
|
||||
command: reg
|
||||
? `gem install ${this.packageName} --source https://${reg}`
|
||||
: `gem install ${this.packageName}`,
|
||||
};
|
||||
default:
|
||||
return { label: this.protocol, command: this.packageName };
|
||||
}
|
||||
}
|
||||
|
||||
private async copyToClipboard() {
|
||||
const { command } = this.getInstallCommand();
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
} catch {
|
||||
// Fallback for browsers without clipboard API
|
||||
}
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { label, command } = this.getInstallCommand();
|
||||
return html`
|
||||
<div class="label">${label}</div>
|
||||
<div class="snippet">
|
||||
<code>${command}</code>
|
||||
<button class="copy-btn" @click=${() => this.copyToClipboard()}>Copy</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
501
ts_web/elements/sg-login-view.ts
Normal file
501
ts_web/elements/sg-login-view.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgAuthProvider } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-login-view': SgLoginView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-login-view')
|
||||
export class SgLoginView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="height: 700px; display: flex; align-items: center; justify-content: center; background: #09090b;">
|
||||
<sg-login-view
|
||||
.providers=${[
|
||||
{ id: 'github', name: 'github', displayName: 'GitHub', type: 'oidc' as const },
|
||||
{ id: 'gitlab', name: 'gitlab', displayName: 'GitLab', type: 'oidc' as const },
|
||||
{ id: 'corp-ldap', name: 'corp-ldap', displayName: 'Corporate LDAP', type: 'ldap' as const },
|
||||
]}
|
||||
.localAuthEnabled=${true}
|
||||
.error=${''}
|
||||
></sg-login-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Auth'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor providers: ISgAuthProvider[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
public accessor localAuthEnabled: boolean = true;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public accessor loading: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public accessor error: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#ddd')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#aaa', '#555')};
|
||||
}
|
||||
|
||||
.form-input.has-error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
font-size: 13px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.oauth-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.oauth-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#333', '#ddd')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.oauth-btn:hover {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
border-color: ${cssManager.bdTheme('#ccc', '#555')};
|
||||
}
|
||||
|
||||
.ldap-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ldap-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ldap-toggle:hover {
|
||||
color: ${cssManager.bdTheme('#333', '#ddd')};
|
||||
}
|
||||
|
||||
.ldap-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.ldap-provider-select {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ldap-provider-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.ldap-provider-btn:hover,
|
||||
.ldap-provider-btn.active {
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#999', '#555')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private ldapExpanded = false;
|
||||
private selectedLdapProviderId = '';
|
||||
|
||||
private get oauthProviders(): ISgAuthProvider[] {
|
||||
return this.providers.filter((p) => p.type === 'oidc');
|
||||
}
|
||||
|
||||
private get ldapProviders(): ISgAuthProvider[] {
|
||||
return this.providers.filter((p) => p.type === 'ldap');
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const hasOauth = this.oauthProviders.length > 0;
|
||||
const hasLdap = this.ldapProviders.length > 0;
|
||||
|
||||
return html`
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="logo-section">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-title">Stack.Gallery</div>
|
||||
<div class="brand-subtitle">Sign in to your registry</div>
|
||||
</div>
|
||||
|
||||
${this.error ? html`
|
||||
<div class="error-banner">${this.error}</div>
|
||||
` : ''}
|
||||
|
||||
${this.localAuthEnabled ? html`
|
||||
<form class="form" @submit=${this.handleLocalLogin}>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="login-email"
|
||||
class="form-input ${this.error ? 'has-error' : ''}"
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
?disabled=${this.loading}
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="login-password"
|
||||
class="form-input ${this.error ? 'has-error' : ''}"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
?disabled=${this.loading}
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<button type="submit" class="submit-btn" ?disabled=${this.loading}>
|
||||
${this.loading ? html`<div class="spinner"></div> Signing in...` : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
` : ''}
|
||||
|
||||
${hasOauth ? html`
|
||||
${this.localAuthEnabled ? html`
|
||||
<div class="divider">
|
||||
<span class="divider-line"></span>
|
||||
<span class="divider-text">or continue with</span>
|
||||
<span class="divider-line"></span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="oauth-buttons">
|
||||
${this.oauthProviders.map(
|
||||
(p) => html`
|
||||
<button
|
||||
class="oauth-btn"
|
||||
@click=${() => this.handleOAuthLogin(p.id)}
|
||||
?disabled=${this.loading}
|
||||
>
|
||||
${p.displayName}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${hasLdap ? html`
|
||||
<div class="ldap-section">
|
||||
${this.localAuthEnabled || hasOauth ? html`
|
||||
<div class="divider">
|
||||
<span class="divider-line"></span>
|
||||
<span class="divider-text">enterprise</span>
|
||||
<span class="divider-line"></span>
|
||||
</div>
|
||||
` : ''}
|
||||
<button class="ldap-toggle" @click=${() => { this.ldapExpanded = !this.ldapExpanded; this.requestUpdate(); }}>
|
||||
${this.ldapExpanded ? '\u25BE' : '\u25B8'} Sign in with LDAP
|
||||
</button>
|
||||
${this.ldapExpanded ? html`
|
||||
<div class="ldap-form">
|
||||
${this.ldapProviders.length > 1 ? html`
|
||||
<div class="ldap-provider-select">
|
||||
${this.ldapProviders.map(
|
||||
(p) => html`
|
||||
<button
|
||||
class="ldap-provider-btn ${this.selectedLdapProviderId === p.id ? 'active' : ''}"
|
||||
@click=${() => { this.selectedLdapProviderId = p.id; this.requestUpdate(); }}
|
||||
>${p.displayName}</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ldap-username"
|
||||
class="form-input"
|
||||
placeholder="LDAP username"
|
||||
autocomplete="username"
|
||||
?disabled=${this.loading}
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ldap-password"
|
||||
class="form-input"
|
||||
placeholder="LDAP password"
|
||||
autocomplete="current-password"
|
||||
?disabled=${this.loading}
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<button class="submit-btn" @click=${this.handleLdapLogin} ?disabled=${this.loading}>
|
||||
${this.loading ? html`<div class="spinner"></div> Authenticating...` : 'Sign in with LDAP'}
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="footer">
|
||||
Powered by Stack.Gallery
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleLocalLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
const emailInput = this.shadowRoot?.getElementById('login-email') as HTMLInputElement;
|
||||
const passwordInput = this.shadowRoot?.getElementById('login-password') as HTMLInputElement;
|
||||
if (!emailInput || !passwordInput) return;
|
||||
const email = emailInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
if (!email || !password) return;
|
||||
this.dispatchEvent(new CustomEvent('login', {
|
||||
detail: { email, password },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleOAuthLogin(providerId: string) {
|
||||
this.dispatchEvent(new CustomEvent('oauth-login', {
|
||||
detail: { providerId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleLdapLogin() {
|
||||
const usernameInput = this.shadowRoot?.getElementById('ldap-username') as HTMLInputElement;
|
||||
const passwordInput = this.shadowRoot?.getElementById('ldap-password') as HTMLInputElement;
|
||||
if (!usernameInput || !passwordInput) return;
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
if (!username || !password) return;
|
||||
const providerId = this.selectedLdapProviderId || this.ldapProviders[0]?.id;
|
||||
if (!providerId) return;
|
||||
this.dispatchEvent(new CustomEvent('ldap-login', {
|
||||
detail: { providerId, username, password },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
888
ts_web/elements/sg-organization-detail-view.ts
Normal file
888
ts_web/elements/sg-organization-detail-view.ts
Normal file
@@ -0,0 +1,888 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type {
|
||||
ISgOrganizationDetail,
|
||||
ISgRepository,
|
||||
ISgOrganizationMember,
|
||||
ISgOrgRedirect,
|
||||
TSgOrgRole,
|
||||
} from '../interfaces.js';
|
||||
import './sg-protocol-badge.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-organization-detail-view': SgOrganizationDetailView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-organization-detail-view')
|
||||
export class SgOrganizationDetailView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px; background: #09090b;">
|
||||
<sg-organization-detail-view
|
||||
.organization=${{
|
||||
id: 'org1',
|
||||
name: 'myorg',
|
||||
displayName: 'My Organization',
|
||||
description: 'Primary development organization for internal and open-source packages.',
|
||||
isPublic: true,
|
||||
memberCount: 8,
|
||||
createdAt: '2025-06-01',
|
||||
website: 'https://myorg.dev',
|
||||
usedStorageBytes: 2147483648,
|
||||
storageQuotaBytes: 10737418240,
|
||||
}}
|
||||
.repositories=${[
|
||||
{ id: 'r1', organizationId: 'org1', name: 'npm-packages', description: 'NPM package repository', protocol: 'npm', visibility: 'public', isPublic: true, packageCount: 24, createdAt: '2025-06-15' },
|
||||
{ id: 'r2', organizationId: 'org1', name: 'docker-images', description: 'OCI container images', protocol: 'oci', visibility: 'private', isPublic: false, packageCount: 12, createdAt: '2025-07-01' },
|
||||
{ id: 'r3', organizationId: 'org1', name: 'python-libs', protocol: 'pypi', visibility: 'internal', isPublic: false, packageCount: 6, createdAt: '2025-08-10' },
|
||||
]}
|
||||
.members=${[
|
||||
{ userId: 'u1', role: 'owner', addedAt: '2025-06-01', user: { username: 'admin', displayName: 'Admin User', avatarUrl: '' } },
|
||||
{ userId: 'u2', role: 'admin', addedAt: '2025-06-10', user: { username: 'jane', displayName: 'Jane Doe', avatarUrl: '' } },
|
||||
{ userId: 'u3', role: 'member', addedAt: '2025-07-05', user: { username: 'bob', displayName: 'Bob Smith', avatarUrl: '' } },
|
||||
]}
|
||||
></sg-organization-detail-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Organizations'];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor organization: ISgOrganizationDetail = {
|
||||
id: '',
|
||||
name: '',
|
||||
displayName: '',
|
||||
isPublic: true,
|
||||
memberCount: 0,
|
||||
createdAt: '',
|
||||
usedStorageBytes: 0,
|
||||
storageQuotaBytes: 0,
|
||||
};
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor repositories: ISgRepository[] = [];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor members: ISgOrganizationMember[] = [];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor redirects: ISgOrgRedirect[] = [];
|
||||
|
||||
@state()
|
||||
accessor editing: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor editName: string = '';
|
||||
|
||||
@state()
|
||||
accessor editDisplayName: string = '';
|
||||
|
||||
@state()
|
||||
accessor editDescription: string = '';
|
||||
|
||||
@state()
|
||||
accessor editWebsite: string = '';
|
||||
|
||||
@state()
|
||||
accessor editIsPublic: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor showDeleteConfirm: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor deleteConfirmName: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
/* Back button */
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Org header */
|
||||
.org-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.org-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.org-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-titles h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.org-handle {
|
||||
font-size: 14px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.org-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.org-meta-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.org-meta-bar span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.storage-bar {
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.storage-fill {
|
||||
height: 100%;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#333', '#ddd')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Repo grid */
|
||||
.repo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.repo-card {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repo-card:hover {
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repo-description {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
}
|
||||
|
||||
.repo-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.visibility-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.visibility-tag.public {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.visibility-tag.private {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.visibility-tag.internal {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Members list */
|
||||
.members-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.member-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.member-username {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
|
||||
/* Header actions */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#333', '#ddd')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
/* Edit form */
|
||||
.edit-form {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.edit-form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#ddd')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.form-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-toggle input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 8px 20px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 8px 20px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#333', '#ddd')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.danger-zone {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.danger-zone-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.danger-zone-text {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.danger-zone-warning {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.danger-confirm-btn {
|
||||
align-self: flex-start;
|
||||
padding: 8px 16px;
|
||||
background: #ef4444;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.danger-confirm-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.danger-confirm-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.danger-cancel-btn {
|
||||
align-self: flex-start;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#333', '#ddd')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.danger-cancel-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const storagePercent =
|
||||
this.organization.storageQuotaBytes > 0
|
||||
? Math.round((this.organization.usedStorageBytes / this.organization.storageQuotaBytes) * 100)
|
||||
: 0;
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<button class="back-btn" @click=${() => this.emitEvent('back', {})}>
|
||||
\u2190 Back to organizations
|
||||
</button>
|
||||
|
||||
<div class="org-header">
|
||||
<div class="org-top">
|
||||
<div class="org-avatar">
|
||||
${(this.organization.displayName || this.organization.name).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="org-titles">
|
||||
<h1>${this.organization.displayName || this.organization.name}</h1>
|
||||
<div class="org-handle">@${this.organization.name}</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="edit-btn" @click=${() => this.startEdit()}>Edit</button>
|
||||
<button class="delete-btn" @click=${() => { this.showDeleteConfirm = true; }}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
${this.editing
|
||||
? html`
|
||||
<div class="edit-form">
|
||||
<div class="edit-form-title">Edit Organization</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Handle</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.editName}
|
||||
@input=${(e: InputEvent) => { this.editName = (e.target as HTMLInputElement).value; }}
|
||||
placeholder="my-org"
|
||||
>
|
||||
${this.editName !== this.organization.name
|
||||
? html`<span class="form-hint" style="color: #f59e0b;">Renaming will create a redirect from "@${this.organization.name}"</span>`
|
||||
: html`<span class="form-hint">Lowercase letters, numbers, and dashes</span>`}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.editDisplayName}
|
||||
@input=${(e: InputEvent) => { this.editDisplayName = (e.target as HTMLInputElement).value; }}
|
||||
placeholder="Organization display name"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
.value=${this.editDescription}
|
||||
@input=${(e: InputEvent) => { this.editDescription = (e.target as HTMLTextAreaElement).value; }}
|
||||
placeholder="A short description"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Website</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-input"
|
||||
.value=${this.editWebsite}
|
||||
@input=${(e: InputEvent) => { this.editWebsite = (e.target as HTMLInputElement).value; }}
|
||||
placeholder="https://..."
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${this.editIsPublic}
|
||||
@change=${(e: Event) => { this.editIsPublic = (e.target as HTMLInputElement).checked; }}
|
||||
>
|
||||
Public organization
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="save-btn" @click=${() => this.handleEditSave()}>Save</button>
|
||||
<button class="cancel-btn" @click=${() => this.handleEditCancel()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
${this.organization.description
|
||||
? html`<div class="org-description">${this.organization.description}</div>`
|
||||
: ''}
|
||||
<div class="org-meta-bar">
|
||||
<span>${this.organization.memberCount} members</span>
|
||||
<span>${this.organization.isPublic ? 'Public' : 'Private'}</span>
|
||||
${this.organization.website ? html`<span>${this.organization.website}</span>` : ''}
|
||||
<span>
|
||||
Storage: ${this.formatBytes(this.organization.usedStorageBytes)} / ${this.formatBytes(this.organization.storageQuotaBytes)}
|
||||
<span class="storage-bar"><span class="storage-fill" style="width: ${storagePercent}%"></span></span>
|
||||
</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Repositories (${this.repositories.length})</div>
|
||||
<button class="add-btn" @click=${() => this.emitEvent('create-repo', {})}>+ New Repository</button>
|
||||
</div>
|
||||
${this.repositories.length > 0
|
||||
? html`
|
||||
<div class="repo-grid">
|
||||
${this.repositories.map(
|
||||
(repo) => html`
|
||||
<div class="repo-card" @click=${() => this.emitEvent('select-repo', { repositoryId: repo.id })}>
|
||||
<div class="repo-name">
|
||||
<sg-protocol-badge .protocol=${repo.protocol}></sg-protocol-badge>
|
||||
${repo.name}
|
||||
</div>
|
||||
${repo.description ? html`<div class="repo-description">${repo.description}</div>` : ''}
|
||||
<div class="repo-meta">
|
||||
<span>${repo.packageCount} packages</span>
|
||||
<span class="visibility-tag ${repo.visibility}">${repo.visibility}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No repositories yet. Create one to start publishing packages.</div>`}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Members (${this.members.length})</div>
|
||||
<button class="add-btn" @click=${() => this.emitEvent('add-member', {})}>+ Add Member</button>
|
||||
</div>
|
||||
${this.members.length > 0
|
||||
? html`
|
||||
<div class="members-list">
|
||||
${this.members.map(
|
||||
(member) => html`
|
||||
<div class="member-row">
|
||||
<div class="member-info">
|
||||
<div class="member-avatar">
|
||||
${(member.user?.displayName || member.user?.username || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div class="member-name">${member.user?.displayName || 'Unknown'}</div>
|
||||
<div class="member-username">@${member.user?.username || member.userId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-actions">
|
||||
<span class="role-badge">${member.role}</span>
|
||||
${member.role !== 'owner'
|
||||
? html`<button class="remove-btn" @click=${() => this.emitEvent('remove-member', { userId: member.userId })}>Remove</button>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No members</div>`}
|
||||
</div>
|
||||
|
||||
${this.redirects.length > 0
|
||||
? html`
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Handle Redirects (${this.redirects.length})</div>
|
||||
</div>
|
||||
<div class="members-list">
|
||||
${this.redirects.map(
|
||||
(redirect) => html`
|
||||
<div class="member-row">
|
||||
<div class="member-info">
|
||||
<div>
|
||||
<div class="member-name" style="font-family: 'JetBrains Mono', monospace;">@${redirect.oldName}</div>
|
||||
<div class="member-username">redirects to @${this.organization.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-actions">
|
||||
<button class="remove-btn" @click=${() => this.emitEvent('delete-redirect', { redirectId: redirect.id })}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
${this.showDeleteConfirm
|
||||
? html`
|
||||
<div class="danger-zone">
|
||||
<div class="danger-zone-title">Delete Organization</div>
|
||||
<div class="danger-zone-text">
|
||||
This action is <strong>irreversible</strong>. All repositories, packages, and data belonging to
|
||||
<strong>@${this.organization.name}</strong> will be permanently deleted.
|
||||
</div>
|
||||
<div class="danger-zone-warning">
|
||||
Type <strong>${this.organization.name}</strong> to confirm deletion.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.deleteConfirmName}
|
||||
@input=${(e: InputEvent) => { this.deleteConfirmName = (e.target as HTMLInputElement).value; }}
|
||||
placeholder=${this.organization.name}
|
||||
>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="danger-confirm-btn"
|
||||
?disabled=${this.deleteConfirmName !== this.organization.name}
|
||||
@click=${() => this.handleDelete()}
|
||||
>Delete Organization</button>
|
||||
<button class="danger-cancel-btn" @click=${() => { this.showDeleteConfirm = false; this.deleteConfirmName = ''; }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
private startEdit() {
|
||||
this.editName = this.organization.name || '';
|
||||
this.editDisplayName = this.organization.displayName || '';
|
||||
this.editDescription = this.organization.description || '';
|
||||
this.editWebsite = this.organization.website || '';
|
||||
this.editIsPublic = this.organization.isPublic;
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
private handleEditSave() {
|
||||
const detail: Record<string, unknown> = {
|
||||
organizationId: this.organization.id,
|
||||
displayName: this.editDisplayName.trim(),
|
||||
description: this.editDescription.trim(),
|
||||
website: this.editWebsite.trim(),
|
||||
isPublic: this.editIsPublic,
|
||||
};
|
||||
// Only include name if it changed
|
||||
if (this.editName.trim() && this.editName.trim() !== this.organization.name) {
|
||||
detail.name = this.editName.trim();
|
||||
}
|
||||
this.emitEvent('edit', detail);
|
||||
this.editing = false;
|
||||
}
|
||||
|
||||
private handleEditCancel() {
|
||||
this.editing = false;
|
||||
}
|
||||
|
||||
private handleDelete() {
|
||||
this.emitEvent('delete', {
|
||||
organizationId: this.organization.id,
|
||||
});
|
||||
this.showDeleteConfirm = false;
|
||||
this.deleteConfirmName = '';
|
||||
}
|
||||
|
||||
private emitEvent(name: string, detail: Record<string, unknown>) {
|
||||
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
443
ts_web/elements/sg-organizations-list-view.ts
Normal file
443
ts_web/elements/sg-organizations-list-view.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgOrganization } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-organizations-list-view': SgOrganizationsListView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-organizations-list-view')
|
||||
export class SgOrganizationsListView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px; background: #09090b;">
|
||||
<sg-organizations-list-view
|
||||
.organizations=${[
|
||||
{ id: 'org1', name: 'myorg', displayName: 'My Organization', description: 'Primary development organization', isPublic: true, memberCount: 12, createdAt: '2025-06-01' },
|
||||
{ id: 'org2', name: 'acme-corp', displayName: 'ACME Corp', description: 'Enterprise packages and images', isPublic: false, memberCount: 48, createdAt: '2025-01-15' },
|
||||
{ id: 'org3', name: 'oss-collective', displayName: 'Open Source Collective', isPublic: true, memberCount: 156, createdAt: '2024-11-20' },
|
||||
]}
|
||||
></sg-organizations-list-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Organizations'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor organizations: ISgOrganization[] = [];
|
||||
|
||||
@state()
|
||||
accessor showCreateForm: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor createName: string = '';
|
||||
|
||||
@state()
|
||||
accessor createDisplayName: string = '';
|
||||
|
||||
@state()
|
||||
accessor createDescription: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.create-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.orgs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.org-card {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.org-card:hover {
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.org-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.org-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.org-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-handle {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.org-description {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-footer {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.visibility-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.visibility-badge.public {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.visibility-badge.private {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 32px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Create form */
|
||||
.create-form {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.create-form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#ddd')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#aaa', '#666')};
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 8px 20px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#333', '#ddd')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="page-title">Organizations</div>
|
||||
<button class="create-btn" @click=${this.handleCreate}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
New Organization
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this.showCreateForm
|
||||
? html`
|
||||
<div class="create-form">
|
||||
<div class="create-form-title">Create Organization</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.createName}
|
||||
@input=${(e: InputEvent) => { this.createName = (e.target as HTMLInputElement).value; }}
|
||||
placeholder="my-org"
|
||||
>
|
||||
<span class="form-hint">Lowercase letters, numbers, and dashes only</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
.value=${this.createDisplayName}
|
||||
@input=${(e: InputEvent) => { this.createDisplayName = (e.target as HTMLInputElement).value; }}
|
||||
placeholder="My Organization"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
.value=${this.createDescription}
|
||||
@input=${(e: InputEvent) => { this.createDescription = (e.target as HTMLTextAreaElement).value; }}
|
||||
placeholder="A short description of the organization"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="submit-btn" @click=${this.handleCreateSubmit}>Create</button>
|
||||
<button class="cancel-btn" @click=${this.handleCreateCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
${this.organizations.length > 0
|
||||
? html`
|
||||
<div class="orgs-grid">
|
||||
${this.organizations.map(
|
||||
(org) => html`
|
||||
<div class="org-card" @click=${() => this.handleSelect(org.id)}>
|
||||
<div class="org-header">
|
||||
<div class="org-avatar">${(org.displayName || org.name).charAt(0).toUpperCase()}</div>
|
||||
<div class="org-titles">
|
||||
<div class="org-name">${org.displayName || org.name}</div>
|
||||
<div class="org-handle">@${org.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
${org.description ? html`<div class="org-description">${org.description}</div>` : ''}
|
||||
<div class="org-footer">
|
||||
<span>${org.memberCount} member${org.memberCount !== 1 ? 's' : ''}</span>
|
||||
<span class="visibility-badge ${org.isPublic ? 'public' : 'private'}">
|
||||
${org.isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">No organizations yet</div>
|
||||
<div class="empty-text">Create an organization to start publishing packages.</div>
|
||||
<button class="create-btn" @click=${this.handleCreate}>Create Organization</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleCreate() {
|
||||
this.showCreateForm = true;
|
||||
}
|
||||
|
||||
private handleCreateSubmit() {
|
||||
if (!this.createName.trim()) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('create', {
|
||||
detail: {
|
||||
name: this.createName.trim(),
|
||||
displayName: this.createDisplayName.trim(),
|
||||
description: this.createDescription.trim(),
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
this.createName = '';
|
||||
this.createDisplayName = '';
|
||||
this.createDescription = '';
|
||||
this.showCreateForm = false;
|
||||
}
|
||||
|
||||
private handleCreateCancel() {
|
||||
this.createName = '';
|
||||
this.createDisplayName = '';
|
||||
this.createDescription = '';
|
||||
this.showCreateForm = false;
|
||||
}
|
||||
|
||||
private handleSelect(organizationId: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('select', {
|
||||
detail: { organizationId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
496
ts_web/elements/sg-package-detail-view.ts
Normal file
496
ts_web/elements/sg-package-detail-view.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgPackageDetail, ISgPackageVersion } from '../interfaces.js';
|
||||
import './sg-protocol-badge.js';
|
||||
import './sg-install-snippet.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-package-detail-view': SgPackageDetailView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-package-detail-view')
|
||||
export class SgPackageDetailView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px; background: #09090b;">
|
||||
<sg-package-detail-view
|
||||
.package=${{
|
||||
id: 'p1',
|
||||
name: '@myorg/web-framework',
|
||||
description: 'Modern web framework for building full-stack TypeScript applications with first-class support for SSR, routing, and state management.',
|
||||
protocol: 'npm',
|
||||
organizationId: 'org1',
|
||||
repositoryId: 'r1',
|
||||
latestVersion: '3.2.1',
|
||||
isPrivate: false,
|
||||
downloadCount: 12400,
|
||||
updatedAt: '2026-03-19T10:30:00Z',
|
||||
distTags: { latest: '3.2.1', next: '4.0.0-beta.1' },
|
||||
versions: ['3.2.1', '3.2.0', '3.1.0', '3.0.0', '2.5.0'],
|
||||
starCount: 42,
|
||||
storageBytes: 52428800,
|
||||
createdAt: '2025-01-10T08:00:00Z',
|
||||
}}
|
||||
.versions=${[
|
||||
{ version: '3.2.1', publishedAt: '2026-03-19T10:30:00Z', size: 1048576, downloads: 5200 },
|
||||
{ version: '3.2.0', publishedAt: '2026-03-10T14:00:00Z', size: 1024000, downloads: 4800 },
|
||||
{ version: '3.1.0', publishedAt: '2026-02-15T09:00:00Z', size: 998400, downloads: 1800 },
|
||||
{ version: '3.0.0', publishedAt: '2026-01-01T12:00:00Z', size: 921600, downloads: 600 },
|
||||
]}
|
||||
></sg-package-detail-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Packages'];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor package: ISgPackageDetail = {
|
||||
id: '',
|
||||
name: '',
|
||||
protocol: 'npm',
|
||||
organizationId: '',
|
||||
repositoryId: '',
|
||||
isPrivate: false,
|
||||
downloadCount: 0,
|
||||
updatedAt: '',
|
||||
distTags: {},
|
||||
versions: [],
|
||||
starCount: 0,
|
||||
storageBytes: 0,
|
||||
createdAt: '',
|
||||
};
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor versions: ISgPackageVersion[] = [];
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Package header */
|
||||
.pkg-header {
|
||||
padding: 24px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pkg-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pkg-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.private-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pkg-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dist-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dist-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.dist-tag-name {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.sidebar-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sidebar-stat-label {
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.sidebar-stat-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Version list */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.version-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.version-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delete-version-btn {
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
font-size: 11px;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.delete-version-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.danger-zone {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.danger-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
align-self: flex-start;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid #ef4444;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const pkg = this.package;
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<button class="back-btn" @click=${() => this.emitEvent('back', {})}>
|
||||
\u2190 Back to packages
|
||||
</button>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="main-content">
|
||||
<div class="pkg-header">
|
||||
<div class="pkg-title-row">
|
||||
<sg-protocol-badge .protocol=${pkg.protocol}></sg-protocol-badge>
|
||||
<span class="pkg-name">${pkg.name}</span>
|
||||
${pkg.isPrivate ? html`<span class="private-badge">Private</span>` : ''}
|
||||
</div>
|
||||
${pkg.description ? html`<div class="pkg-description">${pkg.description}</div>` : ''}
|
||||
${Object.keys(pkg.distTags).length > 0
|
||||
? html`
|
||||
<div class="dist-tags">
|
||||
${Object.entries(pkg.distTags).map(
|
||||
([tag, version]) => html`
|
||||
<span class="dist-tag">
|
||||
<span class="dist-tag-name">${tag}</span>: ${version}
|
||||
</span>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 24px;">
|
||||
<div class="section-title">Install</div>
|
||||
<sg-install-snippet
|
||||
.protocol=${pkg.protocol}
|
||||
.packageName=${pkg.name}
|
||||
></sg-install-snippet>
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 24px;">
|
||||
<div class="section-title">Versions (${this.versions.length})</div>
|
||||
${this.versions.length > 0
|
||||
? html`
|
||||
<div class="version-list">
|
||||
${this.versions.map(
|
||||
(v) => html`
|
||||
<div class="version-row">
|
||||
<div class="version-info">
|
||||
<span class="version-number">${v.version}</span>
|
||||
<div class="version-meta">
|
||||
<span>${this.formatDate(v.publishedAt)}</span>
|
||||
<span>${this.formatBytes(v.size)}</span>
|
||||
<span>${this.formatNumber(v.downloads)} downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<button
|
||||
class="delete-version-btn"
|
||||
@click=${() =>
|
||||
this.emitEvent('delete-version', {
|
||||
packageId: pkg.id,
|
||||
version: v.version,
|
||||
})}
|
||||
>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 24px;">
|
||||
<div class="danger-zone">
|
||||
<div class="danger-title">Danger Zone</div>
|
||||
<div class="danger-text">
|
||||
Deleting this package will permanently remove all versions and associated data.
|
||||
This action cannot be undone.
|
||||
</div>
|
||||
<button
|
||||
class="danger-btn"
|
||||
@click=${() => this.emitEvent('delete', { packageId: pkg.id })}
|
||||
>Delete Package</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Stats</div>
|
||||
<div class="sidebar-stat">
|
||||
<span class="sidebar-stat-label">Downloads</span>
|
||||
<span class="sidebar-stat-value">${this.formatNumber(pkg.downloadCount)}</span>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span class="sidebar-stat-label">Stars</span>
|
||||
<span class="sidebar-stat-value">${pkg.starCount}</span>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span class="sidebar-stat-label">Versions</span>
|
||||
<span class="sidebar-stat-value">${pkg.versions.length}</span>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span class="sidebar-stat-label">Total Size</span>
|
||||
<span class="sidebar-stat-value">${this.formatBytes(pkg.storageBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Info</div>
|
||||
<div class="sidebar-stat">
|
||||
<span class="sidebar-stat-label">Protocol</span>
|
||||
<span class="sidebar-stat-value">${pkg.protocol.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span class="sidebar-stat-label">Latest</span>
|
||||
<span class="sidebar-stat-value">${pkg.latestVersion || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span class="sidebar-stat-label">Created</span>
|
||||
<span class="sidebar-stat-value">${this.formatDate(pkg.createdAt)}</span>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span class="sidebar-stat-label">Updated</span>
|
||||
<span class="sidebar-stat-value">${this.formatDate(pkg.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
private emitEvent(name: string, detail: Record<string, unknown>) {
|
||||
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
427
ts_web/elements/sg-packages-list-view.ts
Normal file
427
ts_web/elements/sg-packages-list-view.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgPackage, TSgProtocol } from '../interfaces.js';
|
||||
import './sg-protocol-badge.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-packages-list-view': SgPackagesListView;
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_PROTOCOLS: TSgProtocol[] = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
|
||||
|
||||
@customElement('sg-packages-list-view')
|
||||
export class SgPackagesListView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px; background: #09090b;">
|
||||
<sg-packages-list-view
|
||||
.packages=${[
|
||||
{ id: '1', name: '@myorg/web-framework', protocol: 'npm', organizationId: 'org1', repositoryId: 'r1', latestVersion: '3.2.1', isPrivate: false, downloadCount: 12400, updatedAt: '2026-03-19T10:30:00Z', description: 'Modern web framework for building full-stack apps' },
|
||||
{ id: '2', name: 'myorg/api-gateway', protocol: 'oci', organizationId: 'org1', repositoryId: 'r2', latestVersion: 'v1.8.0', isPrivate: true, downloadCount: 890, updatedAt: '2026-03-18T14:20:00Z', description: 'API gateway container image' },
|
||||
{ id: '3', name: 'data-utils', protocol: 'pypi', organizationId: 'org2', repositoryId: 'r3', latestVersion: '0.9.4', isPrivate: false, downloadCount: 3200, updatedAt: '2026-03-17T08:15:00Z', description: 'Data utilities for Python' },
|
||||
{ id: '4', name: 'myorg/auth-service', protocol: 'oci', organizationId: 'org1', repositoryId: 'r2', latestVersion: 'v2.1.0', isPrivate: false, downloadCount: 4500, updatedAt: '2026-03-16T12:00:00Z' },
|
||||
{ id: '5', name: '@myorg/config-tools', protocol: 'npm', organizationId: 'org1', repositoryId: 'r1', latestVersion: '1.1.3', isPrivate: false, downloadCount: 780, updatedAt: '2026-03-14T09:30:00Z', description: 'Configuration management utilities' },
|
||||
]}
|
||||
.total=${42}
|
||||
.protocols=${['npm', 'oci', 'pypi']}
|
||||
.query=${''}
|
||||
></sg-packages-list-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Packages'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor packages: ISgPackage[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public accessor total: number = 0;
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor protocols: string[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
public accessor query: string = '';
|
||||
|
||||
private activeFilter: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#aaa', '#555')};
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
padding: 10px 20px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Protocol filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Package list */
|
||||
.package-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.package-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.package-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.package-row:hover {
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.package-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.package-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.private-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 5px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.package-description {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
padding: 2px 8px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.download-count {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.updated-at {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#aaa', '#666')};
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.pagination-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.page-btn:hover {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 32px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const availableProtocols = this.protocols.length > 0 ? this.protocols : ALL_PROTOCOLS;
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="page-title">Packages</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search packages..."
|
||||
.value=${this.query}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') this.handleSearch();
|
||||
}}
|
||||
@input=${(e: InputEvent) => {
|
||||
this.query = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
>
|
||||
<button class="search-btn" @click=${this.handleSearch}>Search</button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<button
|
||||
class="filter-btn ${this.activeFilter === '' ? 'active' : ''}"
|
||||
@click=${() => this.handleFilter('')}
|
||||
>All</button>
|
||||
${availableProtocols.map(
|
||||
(proto) => html`
|
||||
<button
|
||||
class="filter-btn ${this.activeFilter === proto ? 'active' : ''}"
|
||||
@click=${() => this.handleFilter(proto)}
|
||||
>${proto}</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
||||
${this.packages.length > 0
|
||||
? html`
|
||||
<div class="package-list">
|
||||
${this.packages.map(
|
||||
(pkg) => html`
|
||||
<div class="package-row" @click=${() => this.handleSelect(pkg.id)}>
|
||||
<div class="package-info">
|
||||
<div class="package-name-row">
|
||||
<sg-protocol-badge .protocol=${pkg.protocol}></sg-protocol-badge>
|
||||
<span class="package-name">${pkg.name}</span>
|
||||
${pkg.isPrivate ? html`<span class="private-badge">Private</span>` : ''}
|
||||
</div>
|
||||
${pkg.description
|
||||
? html`<div class="package-description">${pkg.description}</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="package-right">
|
||||
${pkg.latestVersion ? html`<span class="version-tag">${pkg.latestVersion}</span>` : ''}
|
||||
<span class="download-count">${this.formatNumber(pkg.downloadCount)}</span>
|
||||
<span class="updated-at">${this.formatDate(pkg.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Showing ${this.packages.length} of ${this.total} packages
|
||||
</div>
|
||||
<div class="pagination-buttons">
|
||||
<button class="page-btn" @click=${() => this.handlePage(-1)}>Previous</button>
|
||||
<button class="page-btn" @click=${() => this.handlePage(1)}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No packages found${this.query ? ` for "${this.query}"` : ''}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearch() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('search', {
|
||||
detail: { query: this.query },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleFilter(protocol: string) {
|
||||
this.activeFilter = protocol;
|
||||
this.requestUpdate();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('filter', {
|
||||
detail: { protocol },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleSelect(packageId: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('select', {
|
||||
detail: { packageId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handlePage(direction: number) {
|
||||
const currentOffset = this.packages.length;
|
||||
const offset = direction > 0 ? currentOffset : Math.max(0, currentOffset - this.packages.length * 2);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('page', {
|
||||
detail: { offset },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
74
ts_web/elements/sg-protocol-badge.ts
Normal file
74
ts_web/elements/sg-protocol-badge.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { TSgProtocol } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-protocol-badge': SgProtocolBadge;
|
||||
}
|
||||
}
|
||||
|
||||
const protocolColors: Record<string, string> = {
|
||||
npm: '#cb3837',
|
||||
oci: '#2496ed',
|
||||
maven: '#c71a36',
|
||||
cargo: '#dea584',
|
||||
composer: '#885630',
|
||||
pypi: '#3775a9',
|
||||
rubygems: '#e9573f',
|
||||
};
|
||||
|
||||
@customElement('sg-protocol-badge')
|
||||
export class SgProtocolBadge extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<sg-protocol-badge .protocol=${'npm'}></sg-protocol-badge>
|
||||
<sg-protocol-badge .protocol=${'oci'}></sg-protocol-badge>
|
||||
<sg-protocol-badge .protocol=${'maven'}></sg-protocol-badge>
|
||||
<sg-protocol-badge .protocol=${'cargo'}></sg-protocol-badge>
|
||||
<sg-protocol-badge .protocol=${'pypi'}></sg-protocol-badge>
|
||||
<sg-protocol-badge .protocol=${'composer'}></sg-protocol-badge>
|
||||
<sg-protocol-badge .protocol=${'rubygems'}></sg-protocol-badge>
|
||||
</div>
|
||||
`;
|
||||
public static demoGroups = ['Shared'];
|
||||
|
||||
@property({ type: String })
|
||||
accessor protocol: TSgProtocol = 'npm';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #fff;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const color = protocolColors[this.protocol] || '#666';
|
||||
return html`
|
||||
<span class="badge" style="background: ${color}">
|
||||
${this.protocol}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
179
ts_web/elements/sg-public-layout.ts
Normal file
179
ts_web/elements/sg-public-layout.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-public-layout': SgPublicLayout;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-public-layout')
|
||||
export class SgPublicLayout extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<sg-public-layout>
|
||||
<div style="padding: 48px; text-align: center; color: #666;">
|
||||
Page content goes here
|
||||
</div>
|
||||
</sg-public-layout>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Public'];
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 24px;
|
||||
background: ${cssManager.bdTheme('#fff', '#09090b')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: -0.02em;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.foss-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.foss-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.sign-in-btn {
|
||||
padding: 6px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sign-in-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
margin-top: 56px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(0 0% 6%)')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 15%)')};
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')};
|
||||
}
|
||||
.statusbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.statusbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.statusbar a {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')};
|
||||
text-decoration: none;
|
||||
}
|
||||
.statusbar a:hover {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 80%)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="top-bar">
|
||||
<div class="logo">Stack.Gallery</div>
|
||||
<div class="header-actions">
|
||||
<a class="foss-btn" href="https://code.foss.global/stack.gallery/registry" target="_blank">View on foss.global</a>
|
||||
<button class="sign-in-btn" @click=${this.handleSignIn}>Sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="statusbar">
|
||||
<div class="statusbar-left">
|
||||
<span>Powered by Stack.Gallery</span>
|
||||
</div>
|
||||
<div class="statusbar-right">
|
||||
<a href="https://code.foss.global/stack.gallery/registry" target="_blank">Host your own private on-prem registry</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleSignIn() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('sign-in', {
|
||||
detail: {},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
658
ts_web/elements/sg-public-search-view.ts
Normal file
658
ts_web/elements/sg-public-search-view.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgPackage, TSgProtocol } from '../interfaces.js';
|
||||
import './sg-protocol-badge.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-public-search-view': SgPublicSearchView;
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_PROTOCOLS: TSgProtocol[] = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
|
||||
|
||||
@customElement('sg-public-search-view')
|
||||
export class SgPublicSearchView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="background: #09090b; min-height: 800px;">
|
||||
<sg-public-search-view
|
||||
.packages=${[
|
||||
{ id: '1', name: '@myorg/web-framework', protocol: 'npm', organizationId: 'org1', repositoryId: 'r1', latestVersion: '3.2.1', isPrivate: false, downloadCount: 12400, updatedAt: '2026-03-19T10:30:00Z', description: 'Modern web framework for building full-stack applications with TypeScript' },
|
||||
{ id: '2', name: 'myorg/api-gateway', protocol: 'oci', organizationId: 'org1', repositoryId: 'r2', latestVersion: 'v1.8.0', isPrivate: false, downloadCount: 890, updatedAt: '2026-03-18T14:20:00Z', description: 'API gateway container image with rate limiting and auth' },
|
||||
{ id: '3', name: 'data-utils', protocol: 'pypi', organizationId: 'org2', repositoryId: 'r3', latestVersion: '0.9.4', isPrivate: false, downloadCount: 3200, updatedAt: '2026-03-17T08:15:00Z', description: 'Data utilities for Python with pandas integration' },
|
||||
{ id: '4', name: 'myorg/auth-service', protocol: 'oci', organizationId: 'org1', repositoryId: 'r2', latestVersion: 'v2.1.0', isPrivate: false, downloadCount: 4500, updatedAt: '2026-03-16T12:00:00Z', description: 'Authentication microservice with OAuth2 and SAML support' },
|
||||
{ id: '5', name: '@myorg/config-tools', protocol: 'npm', organizationId: 'org1', repositoryId: 'r1', latestVersion: '1.1.3', isPrivate: false, downloadCount: 780, updatedAt: '2026-03-14T09:30:00Z', description: 'Configuration management utilities for Node.js' },
|
||||
{ id: '6', name: 'com.myorg:db-driver', protocol: 'maven', organizationId: 'org3', repositoryId: 'r4', latestVersion: '2.0.0', isPrivate: false, downloadCount: 5600, updatedAt: '2026-03-13T16:45:00Z', description: 'High-performance database driver for JVM applications' },
|
||||
]}
|
||||
.total=${42}
|
||||
.query=${'web framework'}
|
||||
.activeProtocol=${''}
|
||||
></sg-public-search-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Public'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor packages: ISgPackage[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public accessor total: number = 0;
|
||||
|
||||
@property({ type: String })
|
||||
public accessor query: string = '';
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor protocols: string[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
public accessor activeProtocol: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public accessor loading: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Hero section */
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80px 24px 64px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
max-width: 600px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.hero-search {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
}
|
||||
|
||||
.hero-search-input {
|
||||
flex: 1;
|
||||
padding: 14px 18px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.hero-search-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#aaa', '#555')};
|
||||
}
|
||||
|
||||
.hero-search-btn {
|
||||
padding: 14px 28px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.hero-search-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.protocol-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Compact search bar (shown in results mode) */
|
||||
.compact-search {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 24px 0;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.compact-search-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
border-right: none;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.compact-search-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#aaa', '#555')};
|
||||
}
|
||||
|
||||
.compact-search-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.compact-search-btn {
|
||||
padding: 10px 20px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.compact-search-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Results section */
|
||||
.results {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 48px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
/* Protocol filter tabs */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Package cards */
|
||||
.package-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.package-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.package-card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.package-card:hover {
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.package-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.package-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-description {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#aaa', '#666')};
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#ddd', '#444')};
|
||||
}
|
||||
|
||||
.package-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
padding: 2px 8px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.download-count {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.pagination-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.page-btn:hover {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 32px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
|
||||
/* Hero empty placeholder */
|
||||
.hero-placeholder {
|
||||
margin-top: 32px;
|
||||
padding: 32px;
|
||||
border: 1px dashed ${cssManager.bdTheme('#ddd', '#333')};
|
||||
text-align: center;
|
||||
}
|
||||
.hero-placeholder-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.hero-placeholder-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hero-placeholder-text {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
line-height: 1.5;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const showHero = this.packages.length === 0 && !this.query && !this.loading;
|
||||
const showResults = this.packages.length > 0 || this.query || this.loading;
|
||||
const availableProtocols = this.protocols.length > 0 ? this.protocols : ALL_PROTOCOLS;
|
||||
|
||||
return html`
|
||||
${showHero ? this.renderHero() : ''}
|
||||
${showResults ? this.renderResults(availableProtocols) : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHero(): TemplateResult {
|
||||
return html`
|
||||
<div class="hero">
|
||||
<div class="hero-title">Stack.Gallery Registry</div>
|
||||
<div class="hero-subtitle">
|
||||
Browse and discover packages across npm, OCI, Maven, Cargo, PyPI, Composer, and RubyGems
|
||||
</div>
|
||||
<div class="hero-search">
|
||||
<input
|
||||
type="text"
|
||||
class="hero-search-input"
|
||||
id="hero-search-input"
|
||||
placeholder="Search packages..."
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.query = (e.target as HTMLInputElement).value;
|
||||
this.handleSearch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button class="hero-search-btn" @click=${() => {
|
||||
const input = this.shadowRoot?.getElementById('hero-search-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
this.query = input.value;
|
||||
this.handleSearch();
|
||||
}
|
||||
}}>Search</button>
|
||||
</div>
|
||||
<div class="protocol-row">
|
||||
${ALL_PROTOCOLS.map(
|
||||
(proto) => html`<sg-protocol-badge .protocol=${proto}></sg-protocol-badge>`
|
||||
)}
|
||||
</div>
|
||||
${this.total === 0 ? html`
|
||||
<div class="hero-placeholder">
|
||||
<div class="hero-placeholder-icon">\u{1F4E6}</div>
|
||||
<div class="hero-placeholder-title">No public packages yet</div>
|
||||
<div class="hero-placeholder-text">
|
||||
This registry is ready to host packages. Sign in to create an organization and start publishing.
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCompactSearch(): TemplateResult {
|
||||
return html`
|
||||
<div class="compact-search">
|
||||
<input
|
||||
type="text"
|
||||
class="compact-search-input"
|
||||
id="compact-search-input"
|
||||
placeholder="Search packages..."
|
||||
.value=${this.query}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.query = (e.target as HTMLInputElement).value;
|
||||
this.handleSearch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button class="compact-search-btn" @click=${() => {
|
||||
const input = this.shadowRoot?.getElementById('compact-search-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
this.query = input.value;
|
||||
this.handleSearch();
|
||||
}
|
||||
}}>Search</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResults(availableProtocols: string[]): TemplateResult {
|
||||
return html`
|
||||
${this.renderCompactSearch()}
|
||||
<div class="results">
|
||||
<div class="filters">
|
||||
<button
|
||||
class="filter-btn ${this.activeProtocol === '' ? 'active' : ''}"
|
||||
@click=${() => this.handleFilter('')}
|
||||
>All</button>
|
||||
${availableProtocols.map(
|
||||
(proto) => html`
|
||||
<button
|
||||
class="filter-btn ${this.activeProtocol === proto ? 'active' : ''}"
|
||||
@click=${() => this.handleFilter(proto)}
|
||||
>${proto}</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="results-header">
|
||||
<span class="results-count">
|
||||
${this.total} package${this.total !== 1 ? 's' : ''} found${this.query ? ` for "${this.query}"` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${this.loading
|
||||
? html`
|
||||
<div class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
Searching packages...
|
||||
</div>
|
||||
`
|
||||
: this.packages.length > 0
|
||||
? html`
|
||||
<div class="package-grid">
|
||||
${this.packages.map(
|
||||
(pkg) => html`
|
||||
<div class="package-card" @click=${() => this.handleSelect(pkg.id)}>
|
||||
<div class="package-info">
|
||||
<div class="package-name-row">
|
||||
<sg-protocol-badge .protocol=${pkg.protocol}></sg-protocol-badge>
|
||||
<span class="package-name">${pkg.name}</span>
|
||||
</div>
|
||||
${pkg.description
|
||||
? html`<div class="package-description">${pkg.description}</div>`
|
||||
: ''}
|
||||
<div class="package-meta">
|
||||
<span class="meta-item">${this.formatNumber(pkg.downloadCount)} downloads</span>
|
||||
<span class="meta-separator">·</span>
|
||||
<span class="meta-item">${this.formatDate(pkg.updatedAt)}</span>
|
||||
<span class="meta-separator">·</span>
|
||||
<span class="meta-item">${pkg.organizationId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="package-right">
|
||||
${pkg.latestVersion
|
||||
? html`<span class="version-tag">${pkg.latestVersion}</span>`
|
||||
: ''}
|
||||
<span class="download-count">${this.formatNumber(pkg.downloadCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Showing ${this.packages.length} of ${this.total} packages
|
||||
</div>
|
||||
<div class="pagination-buttons">
|
||||
<button class="page-btn" @click=${() => this.handlePage(-1)}>Previous</button>
|
||||
<button class="page-btn" @click=${() => this.handlePage(1)}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No packages found${this.query ? ` for "${this.query}"` : ''}</div>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearch() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('search', {
|
||||
detail: { query: this.query },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleFilter(protocol: string) {
|
||||
this.activeProtocol = protocol;
|
||||
this.requestUpdate();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('filter', {
|
||||
detail: { protocol },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleSelect(packageId: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('select', {
|
||||
detail: { packageId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handlePage(direction: number) {
|
||||
const currentOffset = this.packages.length;
|
||||
const offset = direction > 0 ? currentOffset : Math.max(0, currentOffset - this.packages.length * 2);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('page', {
|
||||
detail: { offset },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
327
ts_web/elements/sg-repository-detail-view.ts
Normal file
327
ts_web/elements/sg-repository-detail-view.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgRepository, ISgPackage } from '../interfaces.js';
|
||||
import './sg-protocol-badge.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-repository-detail-view': SgRepositoryDetailView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-repository-detail-view')
|
||||
export class SgRepositoryDetailView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px; background: #09090b;">
|
||||
<sg-repository-detail-view
|
||||
.repository=${{
|
||||
id: 'r1',
|
||||
organizationId: 'org1',
|
||||
name: 'npm-packages',
|
||||
description: 'Organization NPM package repository for internal and public libraries.',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
isPublic: true,
|
||||
packageCount: 5,
|
||||
createdAt: '2025-06-15',
|
||||
}}
|
||||
.packages=${[
|
||||
{ id: 'p1', name: '@myorg/web-framework', protocol: 'npm', organizationId: 'org1', repositoryId: 'r1', latestVersion: '3.2.1', isPrivate: false, downloadCount: 12400, updatedAt: '2026-03-19T10:30:00Z', description: 'Modern web framework' },
|
||||
{ id: 'p2', name: '@myorg/auth-client', protocol: 'npm', organizationId: 'org1', repositoryId: 'r1', latestVersion: '1.0.8', isPrivate: false, downloadCount: 5200, updatedAt: '2026-03-18T14:00:00Z', description: 'Authentication client library' },
|
||||
{ id: 'p3', name: '@myorg/data-utils', protocol: 'npm', organizationId: 'org1', repositoryId: 'r1', latestVersion: '2.4.0', isPrivate: true, downloadCount: 890, updatedAt: '2026-03-15T08:45:00Z' },
|
||||
]}
|
||||
></sg-repository-detail-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Repositories'];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor repository: ISgRepository = {
|
||||
id: '',
|
||||
organizationId: '',
|
||||
name: '',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
isPublic: true,
|
||||
packageCount: 0,
|
||||
createdAt: '',
|
||||
};
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor packages: ISgPackage[] = [];
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Repo header */
|
||||
.repo-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.repo-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.repo-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.repo-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.visibility-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.visibility-tag.public {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.visibility-tag.private {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.visibility-tag.internal {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Package list */
|
||||
.packages-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.package-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.package-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.package-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.package-row:hover {
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.package-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-description {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
padding: 2px 8px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.download-count {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.private-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 5px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 32px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<button class="back-btn" @click=${() => this.emitEvent('back', {})}>
|
||||
\u2190 Back to organization
|
||||
</button>
|
||||
|
||||
<div class="repo-header">
|
||||
<div class="repo-title-row">
|
||||
<sg-protocol-badge .protocol=${this.repository.protocol}></sg-protocol-badge>
|
||||
<span class="repo-name">${this.repository.name}</span>
|
||||
<span class="visibility-tag ${this.repository.visibility}">${this.repository.visibility}</span>
|
||||
</div>
|
||||
${this.repository.description
|
||||
? html`<div class="repo-description">${this.repository.description}</div>`
|
||||
: ''}
|
||||
<div class="repo-meta">
|
||||
<span>${this.repository.packageCount} package${this.repository.packageCount !== 1 ? 's' : ''}</span>
|
||||
<span>Protocol: ${this.repository.protocol.toUpperCase()}</span>
|
||||
<span>Created: ${this.formatDate(this.repository.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="packages-section">
|
||||
<div class="section-title">Packages</div>
|
||||
${this.packages.length > 0
|
||||
? html`
|
||||
<div class="package-list">
|
||||
${this.packages.map(
|
||||
(pkg) => html`
|
||||
<div class="package-row" @click=${() => this.emitEvent('select-package', { packageId: pkg.id })}>
|
||||
<div class="package-info">
|
||||
<div class="package-name">${pkg.name}</div>
|
||||
${pkg.description
|
||||
? html`<div class="package-description">${pkg.description}</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="package-right">
|
||||
${pkg.isPrivate ? html`<span class="private-badge">Private</span>` : ''}
|
||||
${pkg.latestVersion ? html`<span class="version-tag">${pkg.latestVersion}</span>` : ''}
|
||||
<span class="download-count">${this.formatNumber(pkg.downloadCount)} pulls</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No packages published to this repository yet.</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
private emitEvent(name: string, detail: Record<string, unknown>) {
|
||||
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
470
ts_web/elements/sg-settings-view.ts
Normal file
470
ts_web/elements/sg-settings-view.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgUser, ISgSession } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-settings-view': SgSettingsView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-settings-view')
|
||||
export class SgSettingsView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 900px; background: #09090b;">
|
||||
<sg-settings-view
|
||||
.user=${{
|
||||
id: 'u1',
|
||||
email: 'admin@stack.gallery',
|
||||
username: 'admin',
|
||||
displayName: 'Admin User',
|
||||
avatarUrl: '',
|
||||
isSystemAdmin: true,
|
||||
}}
|
||||
.sessions=${[
|
||||
{ id: 's1', userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0', ipAddress: '192.168.1.100', isValid: true, lastActivityAt: '2026-03-20T10:30:00Z', createdAt: '2026-03-18T08:00:00Z' },
|
||||
{ id: 's2', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) Safari/17.0', ipAddress: '10.0.0.42', isValid: true, lastActivityAt: '2026-03-19T15:45:00Z', createdAt: '2026-03-15T14:00:00Z' },
|
||||
{ id: 's3', userAgent: 'curl/8.4.0', ipAddress: '203.0.113.50', isValid: false, lastActivityAt: '2026-03-10T22:00:00Z', createdAt: '2026-03-10T20:00:00Z' },
|
||||
]}
|
||||
></sg-settings-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Auth'];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor user: ISgUser = {
|
||||
id: '',
|
||||
email: '',
|
||||
username: '',
|
||||
displayName: '',
|
||||
isSystemAdmin: false,
|
||||
};
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor sessions: ISgSession[] = [];
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-box {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#ddd')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#aaa', '#666')};
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
align-self: flex-start;
|
||||
padding: 8px 20px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Admin badge */
|
||||
.admin-badge {
|
||||
display: inline-flex;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Sessions */
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.session-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-agent {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
}
|
||||
|
||||
.session-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.session-status.active {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.session-status.expired {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.revoke-session-btn {
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.revoke-session-btn:hover {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.danger-section {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.danger-title {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
align-self: flex-start;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid #ef4444;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="page-title">Settings</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
Profile
|
||||
${this.user.isSystemAdmin ? html`<span class="admin-badge">Admin</span>` : ''}
|
||||
</div>
|
||||
<div class="section-box">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-input" .value=${this.user.email} disabled>
|
||||
<span class="form-hint">Email cannot be changed</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-input" .value=${this.user.username} disabled>
|
||||
<span class="form-hint">Username cannot be changed</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="settings-displayname"
|
||||
class="form-input"
|
||||
.value=${this.user.displayName}
|
||||
placeholder="Your display name"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Avatar URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="settings-avatar"
|
||||
class="form-input"
|
||||
.value=${this.user.avatarUrl || ''}
|
||||
placeholder="https://..."
|
||||
>
|
||||
</div>
|
||||
<button class="save-btn" @click=${this.handleSaveProfile}>Save Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Change Password</div>
|
||||
<div class="section-box">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Current Password</label>
|
||||
<input type="password" id="settings-current-pw" class="form-input" placeholder="Current password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" id="settings-new-pw" class="form-input" placeholder="New password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<input type="password" id="settings-confirm-pw" class="form-input" placeholder="Confirm new password">
|
||||
</div>
|
||||
<button class="save-btn" @click=${this.handleChangePassword}>Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Active Sessions</div>
|
||||
${this.sessions.length > 0
|
||||
? html`
|
||||
<div class="session-list">
|
||||
${this.sessions.map(
|
||||
(session) => html`
|
||||
<div class="session-row">
|
||||
<div class="session-info">
|
||||
<div class="session-agent">${this.parseUserAgent(session.userAgent || 'Unknown client')}</div>
|
||||
<div class="session-meta">
|
||||
<span>${session.ipAddress || 'unknown'}</span>
|
||||
<span>Active ${this.formatDate(session.lastActivityAt || '')}</span>
|
||||
<span class="session-status ${session.isValid ? 'active' : 'expired'}">
|
||||
${session.isValid ? 'Active' : 'Expired'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
${session.isValid
|
||||
? html`
|
||||
<button
|
||||
class="revoke-session-btn"
|
||||
@click=${() => this.handleRevokeSession(session.id)}
|
||||
>Revoke</button>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="section-box">No active sessions</div>`}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title danger-title">Danger Zone</div>
|
||||
<div class="section-box danger-section">
|
||||
<div class="danger-text">
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
All your tokens will be revoked and organization memberships removed.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Confirm with password</label>
|
||||
<input type="password" id="settings-delete-pw" class="form-input" placeholder="Enter your password">
|
||||
</div>
|
||||
<button class="danger-btn" @click=${this.handleDeleteAccount}>Delete My Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private parseUserAgent(ua: string): string {
|
||||
if (!ua) return 'Unknown client';
|
||||
if (ua.length > 80) return ua.substring(0, 77) + '...';
|
||||
return ua;
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSaveProfile() {
|
||||
const displayName = (this.shadowRoot?.getElementById('settings-displayname') as HTMLInputElement)?.value || '';
|
||||
const avatarUrl = (this.shadowRoot?.getElementById('settings-avatar') as HTMLInputElement)?.value || '';
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('save-profile', {
|
||||
detail: { displayName, avatarUrl },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleChangePassword() {
|
||||
const currentPassword = (this.shadowRoot?.getElementById('settings-current-pw') as HTMLInputElement)?.value || '';
|
||||
const newPassword = (this.shadowRoot?.getElementById('settings-new-pw') as HTMLInputElement)?.value || '';
|
||||
const confirmPassword = (this.shadowRoot?.getElementById('settings-confirm-pw') as HTMLInputElement)?.value || '';
|
||||
if (!currentPassword || !newPassword) return;
|
||||
if (newPassword !== confirmPassword) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('change-password', {
|
||||
detail: { currentPassword, newPassword },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleRevokeSession(sessionId: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('revoke-session', {
|
||||
detail: { sessionId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleDeleteAccount() {
|
||||
const password = (this.shadowRoot?.getElementById('settings-delete-pw') as HTMLInputElement)?.value || '';
|
||||
if (!password) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('delete-account', {
|
||||
detail: { password },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
96
ts_web/elements/sg-stat-card.ts
Normal file
96
ts_web/elements/sg-stat-card.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgStat } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-stat-card': SgStatCard;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-stat-card')
|
||||
export class SgStatCard extends DeesElement {
|
||||
public static demo = () => html`<sg-stat-card
|
||||
.title=${'Total Packages'}
|
||||
.value=${1234}
|
||||
.icon=${'lucide:package'}
|
||||
.trend=${'up'}
|
||||
.trendValue=${'+12%'}
|
||||
></sg-stat-card>`;
|
||||
public static demoGroups = ['Dashboard'];
|
||||
|
||||
@property({ type: String })
|
||||
accessor title: string = '';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor value: string | number = 0;
|
||||
|
||||
@property({ type: String })
|
||||
accessor icon: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor trend: 'up' | 'down' | 'neutral' = 'neutral';
|
||||
|
||||
@property({ type: String })
|
||||
accessor trendValue: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.card {
|
||||
background: ${cssManager.bdTheme('#fff', '#1a1a1a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.card-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.card-trend {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.card-trend.up { color: #22c55e; }
|
||||
.card-trend.down { color: #ef4444; }
|
||||
.card-trend.neutral { color: ${cssManager.bdTheme('#999', '#666')}; }
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">${this.title}</div>
|
||||
<div class="card-value">${this.value}</div>
|
||||
${this.trendValue ? html`
|
||||
<div class="card-trend ${this.trend}">
|
||||
${this.trend === 'up' ? '\u2191' : this.trend === 'down' ? '\u2193' : '\u2022'}
|
||||
${this.trendValue}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
495
ts_web/elements/sg-tokens-view.ts
Normal file
495
ts_web/elements/sg-tokens-view.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { ISgToken, ISgOrganization, TSgProtocol } from '../interfaces.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-tokens-view': SgTokensView;
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_PROTOCOLS: TSgProtocol[] = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
|
||||
|
||||
@customElement('sg-tokens-view')
|
||||
export class SgTokensView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1000px; background: #09090b;">
|
||||
<sg-tokens-view
|
||||
.tokens=${[
|
||||
{ id: 't1', name: 'CI/CD Pipeline', tokenPrefix: 'sg_abc', protocols: ['npm', 'oci'], scopes: [{ protocol: '*', actions: ['read', 'write'] }], expiresAt: '2027-01-01', lastUsedAt: '2026-03-19', usageCount: 245, createdAt: '2026-01-15' },
|
||||
{ id: 't2', name: 'Read-only Mirror', tokenPrefix: 'sg_def', protocols: ['npm'], scopes: [{ protocol: 'npm', actions: ['read'] }], lastUsedAt: '2026-03-18', usageCount: 1230, createdAt: '2025-11-01' },
|
||||
{ id: 't3', name: 'Deploy Token', tokenPrefix: 'sg_ghi', protocols: ['oci'], scopes: [{ protocol: 'oci', actions: ['read', 'write'] }], organizationId: 'org1', expiresAt: '2026-06-01', usageCount: 56, createdAt: '2026-02-20' },
|
||||
]}
|
||||
.organizations=${[
|
||||
{ id: 'org1', name: 'myorg', displayName: 'My Organization', isPublic: true, memberCount: 8, createdAt: '2025-06-01' },
|
||||
]}
|
||||
></sg-tokens-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Auth'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor tokens: ISgToken[] = [];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor organizations: ISgOrganization[] = [];
|
||||
|
||||
private showCreateForm = false;
|
||||
private createName = '';
|
||||
private createProtocols: TSgProtocol[] = [];
|
||||
private createOrgId = '';
|
||||
private createExpiryDays = 0;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Create form */
|
||||
.create-form {
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#ddd')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.form-select {
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.protocol-selector {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.protocol-chip {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.protocol-chip:hover {
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
}
|
||||
|
||||
.protocol-chip.selected {
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-submit {
|
||||
padding: 8px 20px;
|
||||
background: ${cssManager.bdTheme('#111', '#fff')};
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#fff', '#111')};
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.form-submit:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.form-cancel {
|
||||
padding: 8px 20px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.form-cancel:hover {
|
||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
/* Token list */
|
||||
.token-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.token-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.token-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
|
||||
.token-prefix {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
padding: 1px 6px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.token-protocols {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.token-protocol {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 6px;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#666', '#aaa')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
}
|
||||
|
||||
.token-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.token-expired {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.token-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.revoke-btn {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.revoke-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 32px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#888', '#777')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333')};
|
||||
background: ${cssManager.bdTheme('#fff', '#111')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="page-title">API Tokens</div>
|
||||
<button class="create-btn" @click=${() => { this.showCreateForm = true; this.requestUpdate(); }}>
|
||||
+ New Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this.showCreateForm ? this.renderCreateForm() : ''}
|
||||
|
||||
${this.tokens.length > 0
|
||||
? html`
|
||||
<div class="token-list">
|
||||
${this.tokens.map((token) => this.renderToken(token))}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="empty-state">No API tokens created yet. Create one to authenticate with the registry.</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCreateForm(): TemplateResult {
|
||||
return html`
|
||||
<div class="create-form">
|
||||
<div class="form-title">Create New Token</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Token Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="e.g., CI/CD Pipeline"
|
||||
@input=${(e: InputEvent) => { this.createName = (e.target as HTMLInputElement).value; }}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Protocols</label>
|
||||
<div class="protocol-selector">
|
||||
${ALL_PROTOCOLS.map(
|
||||
(proto) => html`
|
||||
<button
|
||||
class="protocol-chip ${this.createProtocols.includes(proto) ? 'selected' : ''}"
|
||||
@click=${() => this.toggleProtocol(proto)}
|
||||
>${proto}</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.organizations.length > 0
|
||||
? html`
|
||||
<div class="form-group">
|
||||
<label class="form-label">Organization (optional)</label>
|
||||
<select
|
||||
class="form-select"
|
||||
@change=${(e: Event) => { this.createOrgId = (e.target as HTMLSelectElement).value; }}
|
||||
>
|
||||
<option value="">No organization (personal token)</option>
|
||||
${this.organizations.map(
|
||||
(org) => html`<option value=${org.id}>${org.displayName || org.name}</option>`
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Expires In (days, 0 = never)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
@input=${(e: InputEvent) => { this.createExpiryDays = parseInt((e.target as HTMLInputElement).value) || 0; }}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="form-submit" @click=${this.handleCreate}>Create Token</button>
|
||||
<button class="form-cancel" @click=${() => { this.showCreateForm = false; this.requestUpdate(); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderToken(token: ISgToken): TemplateResult {
|
||||
const isExpired = token.expiresAt && new Date(token.expiresAt) < new Date();
|
||||
|
||||
return html`
|
||||
<div class="token-row">
|
||||
<div class="token-info">
|
||||
<div class="token-name-row">
|
||||
<span class="token-name">${token.name}</span>
|
||||
<span class="token-prefix">${token.tokenPrefix}...</span>
|
||||
</div>
|
||||
<div class="token-protocols">
|
||||
${token.protocols.map(
|
||||
(p) => html`<span class="token-protocol">${p}</span>`
|
||||
)}
|
||||
</div>
|
||||
<div class="token-meta">
|
||||
<span>Created ${this.formatDate(token.createdAt)}</span>
|
||||
${token.lastUsedAt ? html`<span>Last used ${this.formatDate(token.lastUsedAt)}</span>` : ''}
|
||||
<span>${token.usageCount} uses</span>
|
||||
${token.expiresAt
|
||||
? isExpired
|
||||
? html`<span class="token-expired">Expired</span>`
|
||||
: html`<span>Expires ${this.formatDate(token.expiresAt)}</span>`
|
||||
: html`<span>No expiry</span>`}
|
||||
${token.organizationId ? html`<span>Org-scoped</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-actions">
|
||||
<button class="revoke-btn" @click=${() => this.handleRevoke(token.id)}>Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private toggleProtocol(proto: TSgProtocol) {
|
||||
if (this.createProtocols.includes(proto)) {
|
||||
this.createProtocols = this.createProtocols.filter((p) => p !== proto);
|
||||
} else {
|
||||
this.createProtocols = [...this.createProtocols, proto];
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleCreate() {
|
||||
if (!this.createName.trim()) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('create', {
|
||||
detail: {
|
||||
name: this.createName.trim(),
|
||||
protocols: this.createProtocols,
|
||||
scopes: [{ protocol: '*' as const, actions: ['read', 'write'] as const }],
|
||||
organizationId: this.createOrgId || undefined,
|
||||
expiresInDays: this.createExpiryDays || undefined,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
this.showCreateForm = false;
|
||||
this.createName = '';
|
||||
this.createProtocols = [];
|
||||
this.createOrgId = '';
|
||||
this.createExpiryDays = 0;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleRevoke(tokenId: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('revoke', {
|
||||
detail: { tokenId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
ts_web/index.ts
Normal file
2
ts_web/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './elements/index.js';
|
||||
export * from './pages/index.js';
|
||||
160
ts_web/interfaces.ts
Normal file
160
ts_web/interfaces.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// ============================================================================
|
||||
// Catalog Component Interfaces
|
||||
// These are the prop shapes accepted by sg-* components.
|
||||
// They are decoupled from backend models for reusability.
|
||||
// ============================================================================
|
||||
|
||||
export type TSgProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems';
|
||||
|
||||
export type TSgOrgRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export interface ISgStat {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
trendValue?: string;
|
||||
}
|
||||
|
||||
export interface ISgOrganization {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
avatarUrl?: string;
|
||||
isPublic: boolean;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ISgOrganizationDetail extends ISgOrganization {
|
||||
website?: string;
|
||||
usedStorageBytes: number;
|
||||
storageQuotaBytes: number;
|
||||
}
|
||||
|
||||
export interface ISgOrganizationMember {
|
||||
userId: string;
|
||||
role: TSgOrgRole;
|
||||
addedAt: string;
|
||||
user: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ISgOrgRedirect {
|
||||
id: string;
|
||||
oldName: string;
|
||||
organizationId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ISgRepository {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
protocol: TSgProtocol;
|
||||
visibility: 'public' | 'private' | 'internal';
|
||||
isPublic: boolean;
|
||||
packageCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ISgPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
protocol: TSgProtocol;
|
||||
organizationId: string;
|
||||
repositoryId: string;
|
||||
latestVersion?: string;
|
||||
isPrivate: boolean;
|
||||
downloadCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ISgPackageDetail extends ISgPackage {
|
||||
distTags: Record<string, string>;
|
||||
versions: string[];
|
||||
starCount: number;
|
||||
storageBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ISgPackageVersion {
|
||||
version: string;
|
||||
publishedAt: string;
|
||||
size: number;
|
||||
downloads: number;
|
||||
}
|
||||
|
||||
export interface ISgToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
protocols: TSgProtocol[];
|
||||
scopes: ISgTokenScope[];
|
||||
organizationId?: string;
|
||||
expiresAt?: string;
|
||||
lastUsedAt?: string;
|
||||
usageCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ISgTokenScope {
|
||||
protocol: TSgProtocol | '*';
|
||||
organizationId?: string;
|
||||
actions: ('read' | 'write' | 'delete' | '*')[];
|
||||
}
|
||||
|
||||
export interface ISgUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
isSystemAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface ISgSession {
|
||||
id: string;
|
||||
userAgent: string;
|
||||
ipAddress: string;
|
||||
isValid: boolean;
|
||||
lastActivityAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ISgAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: 'oidc' | 'ldap';
|
||||
}
|
||||
|
||||
export interface ISgAuthProviderDetail extends ISgAuthProvider {
|
||||
status: 'active' | 'disabled' | 'testing';
|
||||
priority: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastTestedAt?: string;
|
||||
lastTestResult?: 'success' | 'failure';
|
||||
lastTestError?: string;
|
||||
}
|
||||
|
||||
export interface ISgPlatformSettings {
|
||||
localAuthEnabled: boolean;
|
||||
allowUserRegistration: boolean;
|
||||
sessionDurationMinutes: number;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
export interface ISgDashboardStats {
|
||||
organizationCount: number;
|
||||
packageCount: number;
|
||||
totalDownloads: number;
|
||||
tokenCount: number;
|
||||
}
|
||||
1
ts_web/pages/index.ts
Normal file
1
ts_web/pages/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './mainpage.js';
|
||||
43
ts_web/pages/mainpage.ts
Normal file
43
ts_web/pages/mainpage.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
export const mainpage = () => html`
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #111;
|
||||
}
|
||||
.demo-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.demo-section {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
padding: 48px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 16px 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
<div class="demo-container">
|
||||
<div class="demo-section">
|
||||
<h1>Stack.Gallery Catalog</h1>
|
||||
<p>Component catalog for the Stack.Gallery Registry UI. Use <code>tswatch</code> to start the development server.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user