Add user database migration, core reusable components, and layout structure
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
const STATUS_LABELS = {
|
||||
draft: 'Draft', assigned: 'Assigned', scheduled: 'Scheduled',
|
||||
in_progress: 'In Progress', pending_review: 'Pending Review', closed: 'Closed',
|
||||
};
|
||||
const PRIORITY_LABELS = { low: 'Low', normal: 'Normal', high: 'High', urgent: 'Urgent' };
|
||||
|
||||
class UiBadge extends HTMLElement {
|
||||
connectedCallback() { this.#render(); }
|
||||
static get observedAttributes() { return ['type', 'value']; }
|
||||
attributeChangedCallback() { this.#render(); }
|
||||
|
||||
#render() {
|
||||
const type = this.getAttribute('type') || 'status';
|
||||
const value = this.getAttribute('value') || '';
|
||||
const label = type === 'priority'
|
||||
? (PRIORITY_LABELS[value] || value)
|
||||
: (STATUS_LABELS[value] || value.replace('_', ' '));
|
||||
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: inline-flex; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
padding: .2rem .6rem; border-radius: 999px;
|
||||
font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .04em; white-space: nowrap;
|
||||
}
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
/* Status variants */
|
||||
.s-draft { background:var(--status-draft-bg); color:var(--status-draft); }
|
||||
.s-assigned { background:var(--status-assigned-bg); color:var(--status-assigned); }
|
||||
.s-scheduled { background:var(--status-scheduled-bg); color:var(--status-scheduled); }
|
||||
.s-in_progress { background:var(--status-in_progress-bg); color:var(--status-in_progress); }
|
||||
.s-pending_review { background:var(--status-pending_review-bg); color:var(--status-pending_review); }
|
||||
.s-closed { background:var(--status-closed-bg); color:var(--status-closed); }
|
||||
|
||||
/* Priority variants */
|
||||
.p-low { background:#F1F5F9; color:var(--priority-low); }
|
||||
.p-normal { background:#E0F2FE; color:var(--priority-normal); }
|
||||
.p-high { background:#FFF7ED; color:var(--priority-high); }
|
||||
.p-urgent { background:#FEF2F2; color:var(--priority-urgent); }
|
||||
</style>
|
||||
<span class="badge ${type === 'priority' ? 'p' : 's'}-${value}">
|
||||
<span class="dot" style="background:currentColor"></span>
|
||||
${label}
|
||||
</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ui-badge', UiBadge);
|
||||
@@ -0,0 +1,47 @@
|
||||
class UiButton extends HTMLElement {
|
||||
static get observedAttributes() { return ['variant', 'size', 'loading', 'disabled']; }
|
||||
connectedCallback() { this.#render(); }
|
||||
attributeChangedCallback() { this.#render(); }
|
||||
|
||||
#render() {
|
||||
const variant = this.getAttribute('variant') || 'primary';
|
||||
const size = this.getAttribute('size') || 'md';
|
||||
const loading = this.hasAttribute('loading');
|
||||
const disabled = this.hasAttribute('disabled') || loading;
|
||||
const pad = { sm: '.35rem .75rem', md: '.5rem 1rem', lg: '.65rem 1.25rem' }[size] || '.5rem 1rem';
|
||||
const fs = { sm: '.813rem', md: '.875rem', lg: '1rem' }[size] || '.875rem';
|
||||
|
||||
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: inline-flex; }
|
||||
button {
|
||||
display: inline-flex; align-items: center; gap: .4rem;
|
||||
padding: ${pad}; border: none; border-radius: var(--radius);
|
||||
font-size: ${fs}; font-weight: 600; cursor: pointer;
|
||||
transition: opacity .15s, background .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.primary { background: var(--teal); color: #fff; }
|
||||
.primary:hover:not(:disabled) { background: var(--teal-dk); }
|
||||
.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.ghost:hover:not(:disabled) { background: var(--surface-2); }
|
||||
.danger { background: var(--danger); color: #fff; }
|
||||
.danger:hover:not(:disabled) { opacity: .88; }
|
||||
.icon { background: transparent; border: none; color: var(--text-muted); padding: .4rem; border-radius: var(--radius-sm); }
|
||||
.icon:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
|
||||
.ring {
|
||||
width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.4);
|
||||
border-top-color: #fff; border-radius: 50%;
|
||||
animation: spin .6s linear infinite; flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
<button class="${variant}" ${disabled ? 'disabled' : ''}>
|
||||
${loading ? '<div class="ring"></div>' : ''}
|
||||
<slot></slot>
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
customElements.define('ui-button', UiButton);
|
||||
@@ -0,0 +1,56 @@
|
||||
class UiDialog extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: contents; }
|
||||
dialog {
|
||||
border: none; border-radius: var(--radius-lg); padding: 0;
|
||||
background: var(--surface); color: var(--text);
|
||||
box-shadow: var(--shadow-lg); max-height: 90vh; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
dialog[open] { display: flex; }
|
||||
dialog::backdrop { background: rgba(0,0,0,.45); backdrop-filter: blur(2px); }
|
||||
.size-sm { width: min(400px, 95vw); }
|
||||
.size-md { width: min(600px, 95vw); }
|
||||
.size-lg { width: min(860px, 95vw); }
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1.1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header h2 { font-size: 1rem; font-weight: 600; }
|
||||
.close-btn {
|
||||
background: none; border: none; cursor: pointer; color: var(--text-muted);
|
||||
padding: .25rem; border-radius: var(--radius-sm); display: flex;
|
||||
transition: color .15s;
|
||||
}
|
||||
.close-btn:hover { color: var(--text); }
|
||||
.body { padding: 1.25rem; overflow-y: auto; flex: 1; }
|
||||
@media (max-width: 768px) {
|
||||
dialog { width: 100vw !important; max-height: 85vh; border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
|
||||
dialog[open] { position: fixed; bottom: 0; left: 0; margin: 0; }
|
||||
}
|
||||
</style>
|
||||
<dialog class="size-${this.getAttribute('size') || 'md'}">
|
||||
<div class="header">
|
||||
<h2>${this.getAttribute('title') || ''}</h2>
|
||||
<button class="close-btn" aria-label="Close">
|
||||
<i data-lucide="x" style="width:18px;height:18px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="body"><slot></slot></div>
|
||||
</dialog>`;
|
||||
|
||||
const dialog = this.shadowRoot.querySelector('dialog');
|
||||
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.close());
|
||||
dialog.addEventListener('click', e => { if (e.target === dialog) this.close(); });
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') this.close(); });
|
||||
if (window.lucide) lucide.createIcons({ nodes: [this.shadowRoot] });
|
||||
}
|
||||
|
||||
open() { this.shadowRoot?.querySelector('dialog')?.showModal(); }
|
||||
close() { this.shadowRoot?.querySelector('dialog')?.close(); this.dispatchEvent(new CustomEvent('ui:close', { bubbles: true, composed: true })); }
|
||||
}
|
||||
customElements.define('ui-dialog', UiDialog);
|
||||
@@ -0,0 +1,35 @@
|
||||
class UiEmpty extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const icon = this.getAttribute('icon') || 'inbox';
|
||||
const heading = this.getAttribute('heading') || 'Nothing here yet';
|
||||
const body = this.getAttribute('body') || '';
|
||||
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: flex; justify-content: center; }
|
||||
.wrap {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: .75rem; padding: 3rem 2rem; text-align: center; max-width: 360px;
|
||||
}
|
||||
.icon-wrap {
|
||||
width: 64px; height: 64px; border-radius: 50%;
|
||||
background: var(--surface-2); display: flex; align-items: center;
|
||||
justify-content: center; color: var(--text-muted);
|
||||
}
|
||||
h3 { font-size: 1rem; font-weight: 600; color: var(--text); }
|
||||
p { font-size: .875rem; color: var(--text-muted); line-height: 1.6; }
|
||||
::slotted(*) { margin-top: .5rem; }
|
||||
</style>
|
||||
<div class="wrap">
|
||||
<div class="icon-wrap">
|
||||
<i data-lucide="${icon}" style="width:28px;height:28px"></i>
|
||||
</div>
|
||||
<h3>${heading}</h3>
|
||||
${body ? `<p>${body}</p>` : ''}
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [this.shadowRoot] });
|
||||
}
|
||||
}
|
||||
customElements.define('ui-empty', UiEmpty);
|
||||
@@ -0,0 +1,21 @@
|
||||
class UiSpinner extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const size = this.getAttribute('size') || 'md';
|
||||
const px = { sm: '16px', md: '24px', lg: '40px' }[size] || '24px';
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: inline-flex; align-items: center; justify-content: center; }
|
||||
.ring {
|
||||
width: ${px}; height: ${px};
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--teal);
|
||||
border-radius: 50%;
|
||||
animation: spin .6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
<div class="ring"></div>`;
|
||||
}
|
||||
}
|
||||
customElements.define('ui-spinner', UiSpinner);
|
||||
@@ -0,0 +1,51 @@
|
||||
class UiToastContainer extends HTMLElement {
|
||||
connectedCallback() {
|
||||
Object.assign(this.style, {
|
||||
position: 'fixed', top: '1rem', right: '1rem', zIndex: '9999',
|
||||
display: 'flex', flexDirection: 'column', gap: '.5rem',
|
||||
maxWidth: '380px', pointerEvents: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
show(message, type = 'info') {
|
||||
const colors = { success: 'var(--success)', error: 'var(--danger)', info: 'var(--teal)', warning: 'var(--warning)' };
|
||||
const icons = { success: 'check-circle', error: 'x-circle', info: 'info', warning: 'alert-triangle' };
|
||||
const toast = document.createElement('div');
|
||||
Object.assign(toast.style, {
|
||||
display: 'flex', alignItems: 'center', gap: '.6rem',
|
||||
padding: '.75rem 1rem', borderRadius: '8px', color: '#fff',
|
||||
fontSize: '.875rem', fontFamily: 'inherit',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,.25)',
|
||||
background: colors[type] || colors.info,
|
||||
animation: 'toastIn .2s ease', pointerEvents: 'auto',
|
||||
minWidth: '260px',
|
||||
});
|
||||
toast.innerHTML = `<i data-lucide="${icons[type] || 'info'}" style="width:16px;height:16px;flex-shrink:0"></i><span>${message}</span>`;
|
||||
this.appendChild(toast);
|
||||
if (window.lucide) lucide.createIcons({ nodes: [toast] });
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `@keyframes toastIn{from{opacity:0;transform:translateX(1rem)}to{opacity:1;transform:none}}`;
|
||||
if (!document.getElementById('toast-keyframes')) {
|
||||
style.id = 'toast-keyframes';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transition = 'opacity .3s';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
customElements.define('ui-toast-container', UiToastContainer);
|
||||
|
||||
let _container = null;
|
||||
|
||||
export function showToast(message, type = 'info') {
|
||||
if (!_container) {
|
||||
_container = document.createElement('ui-toast-container');
|
||||
document.body.appendChild(_container);
|
||||
}
|
||||
_container.show(message, type);
|
||||
}
|
||||
Reference in New Issue
Block a user