Initial commit: scaffold stack.gallery catalog frontend

This commit is contained in:
2026-03-21 11:04:38 +00:00
commit 1e758a6afd
31 changed files with 16405 additions and 0 deletions

9
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

0
readme.hints.md Normal file
View File

0
readme.md Normal file
View File

View 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
View 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';

View 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 }));
}
}

View 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 }));
}
}

View 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,
})
);
}
}

View 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>
`;
}
}

View 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,
}));
}
}

View 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 }));
}
}

View 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,
})
);
}
}

View 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 }));
}
}

View 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,
})
);
}
}

View 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>
`;
}
}

View 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,
})
);
}
}

View 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">&middot;</span>
<span class="meta-item">${this.formatDate(pkg.updatedAt)}</span>
<span class="meta-separator">&middot;</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,
})
);
}
}

View 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 }));
}
}

View 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,
})
);
}
}

View 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>
`;
}
}

View 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
View File

@@ -0,0 +1,2 @@
export * from './elements/index.js';
export * from './pages/index.js';

160
ts_web/interfaces.ts Normal file
View 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
View File

@@ -0,0 +1 @@
export * from './mainpage.js';

43
ts_web/pages/mainpage.ts Normal file
View 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
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"exclude": [
"dist_*/**/*.d.ts"
]
}