Initial commit

This commit is contained in:
2026-04-06 16:49:17 -04:00
commit 7ec75d0747
36 changed files with 14526 additions and 0 deletions

7946
rss/css/main.css Normal file

File diff suppressed because it is too large Load Diff

1
rss/css/main.css.map Normal file

File diff suppressed because one or more lines are too long

1082
rss/css/main.scss Normal file

File diff suppressed because it is too large Load Diff

1
rss/css/palette.css Normal file
View File

@@ -0,0 +1 @@
/* palette.scss - Full Spectrum Master Map *//*# sourceMappingURL=palette.css.map */

1
rss/css/palette.css.map Normal file
View File

@@ -0,0 +1 @@
{"version":3,"sources":["palette.scss"],"names":[],"mappings":"AAAA,4CAAA","file":"palette.css"}

43
rss/css/palette.scss Normal file
View File

@@ -0,0 +1,43 @@
/* palette.scss - Full Spectrum Master Map */
$colors: (
"gray": (
50: #fbfcfd, 100: #f8f9fa, 200: #e9ecef, 300: #dee2e6, 400: #ced4da, 500: #adb5bd, 600: #6c757d, 700: #495057, 800: #343a40, 900: #212529, 950: #121416
),
"blue": (
50: #f0f7ff, 100: #e7f1ff, 200: #cfe2ff, 300: #9ec5fe, 400: #6ea8fe, 500: #0d6efd, 600: #0a58ca, 700: #084298, 800: #052c65, 900: #031633, 950: #020b1a
),
"indigo": (
50: #f5f3ff, 100: #ede9fe, 200: #ddd6fe, 300: #c4b5fd, 400: #a78bfa, 500: #8b5cf6, 600: #7c3aed, 700: #6d28d9, 800: #5b21b6, 900: #4c1d95, 950: #2e1065
),
"purple": (
50: #faf5ff, 100: #f3e8ff, 200: #e9d5ff, 300: #d8b4fe, 400: #c084fc, 500: #a855f7, 600: #9333ea, 700: #7e22ce, 800: #6b21a8, 900: #581c87, 950: #3b0764
),
"pink": (
50: #fdf2f8, 100: #fce7f3, 200: #fbcfe8, 300: #f9a8d4, 400: #f472b6, 500: #ec4899, 600: #db2777, 700: #be185d, 800: #9d174d, 900: #831843, 950: #500724
),
"red": (
50: #fef2f2, 100: #fee2e2, 200: #fecaca, 300: #fca5a5, 400: #f87171, 500: #ef4444, 600: #dc2626, 700: #b91c1c, 800: #991b1b, 900: #7f1d1d, 950: #450a0a
),
"orange": (
50: #fff7ed, 100: #ffedd5, 200: #fed7aa, 300: #fdba74, 400: #fb923c, 500: #f97316, 600: #ea580c, 700: #c2410c, 800: #9a3412, 900: #7c2d12, 950: #431407
),
"yellow": (
50: #fefce8, 100: #fef9c3, 200: #fef08a, 300: #fde047, 400: #facc15, 500: #eab308, 600: #ca8a04, 700: #a16207, 800: #854d0e, 900: #713f12, 950: #422006
),
"green": (
50: #f0fdf4, 100: #dcfce7, 200: #bbf7d0, 300: #86efac, 400: #4ade80, 500: #22c55e, 600: #16a34a, 700: #15803d, 800: #166534, 900: #14532d, 950: #052e16
),
"emerald": (
50: #ecfdf5, 100: #d1fae5, 200: #a7f3d0, 300: #6ee7b7, 400: #34d399, 500: #10b981, 600: #059669, 700: #047857, 800: #065f46, 900: #064e3b, 950: #022c22
),
"teal": (
50: #f0fdfa, 100: #ccfbf1, 200: #99f6e4, 300: #5eead4, 400: #2dd4bf, 500: #14b8a6, 600: #0d9488, 700: #0f766e, 800: #115e59, 900: #134e4a, 950: #042f2e
),
"cyan": (
50: #ecfeff, 100: #cffafe, 200: #a5f3fc, 300: #67e8f9, 400: #22d3ee, 500: #06b6d4, 600: #0891b2, 700: #0e7490, 800: #155e75, 900: #164e63, 950: #083344
)
);
$white: #ffffff;
$black: #000000;

1001
rss/css/template.css Normal file

File diff suppressed because it is too large Load Diff

1
rss/css/template.css.map Normal file

File diff suppressed because one or more lines are too long

999
rss/css/template.scss Normal file
View File

@@ -0,0 +1,999 @@
/* templates.scss (drop-in)
App/demo templates and composed patterns.
Keep framework stuff in main.scss.
*/
@use "sass:map";
@use "./variables" as *;
/* --------------------------------------------
Demo shell + layout helpers
-------------------------------------------- */
.demo-shell { min-height: 100vh; }
.page {
margin: 0 auto;
}
/* --------------------------------------------
Topbar (glass, theme-aware via CSS vars)
-------------------------------------------- */
/* Defaults (light) */
:root {
--topbar-glass-bg: rgba(255, 255, 255, 0.72);
--topbar-glass-border: rgba(0, 0, 0, 0.08);
--topbar-glass-shadow: 0 10px 24px rgba(0, 0, 0, 0.06);
}
/* Dark overrides */
[data-theme="dark"] {
--topbar-glass-bg: rgba(15, 23, 42, 0.62); /* slate-ish */
--topbar-glass-border: rgba(255, 255, 255, 0.10);
--topbar-glass-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
}
.topbar {
position: sticky;
top: 0;
z-index: map.get($z-index, "header");
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: var(--topbar-glass-bg);
border-bottom: 1px solid var(--topbar-glass-border);
box-shadow: var(--topbar-glass-shadow);
transition:
background-color $duration-base $standard,
border-color $duration-base $standard,
box-shadow $duration-base $standard;
}
/* layout (unchanged) */
.topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: map.get($spacers, 3);
}
.topbar-left {
display: flex;
align-items: center;
gap: map.get($spacers, 3);
min-width: 0;
}
.topbar-right {
display: flex;
align-items: center;
gap: map.get($spacers, 3);
flex: 0 0 auto;
}
/* --------------------------------------------
Sidebar templates
-------------------------------------------- */
.sidebar-brand { display: flex; align-items: center; gap: map.get($spacers, 3); }
.brand-mark {
width: 38px;
height: 38px;
border-radius: 12px;
background:
radial-gradient(10px 10px at 30% 30%, rgba(255,255,255,0.55), transparent 60%),
radial-gradient(28px 28px at 70% 70%, rgba(0,0,0,0.08), transparent 55%),
var(--primary);
box-shadow: 0 10px 25px rgba(0,0,0,0.08);
flex: 0 0 auto;
}
.nav-stack { display: flex; flex-direction: column; gap: 0.25rem; }
.nav-item {
display: flex;
align-items: center;
gap: map.get($spacers, 3);
padding: 0.75rem 0.875rem;
border-radius: 0.75rem;
color: var(--text-muted);
text-decoration: none;
position: relative;
transition: background-color $duration-fast $standard, color $duration-fast $standard, transform $duration-fast $standard;
&:hover {
background: rgba(0,0,0,0.04);
color: var(--text-main);
transform: translateY(-1px);
}
}
[data-theme="dark"] .nav-item:hover { background: rgba(255,255,255,0.06); }
.nav-item.is-active {
background: rgba(20,184,166,0.12);
color: var(--text-main);
}
.nav-item.is-active::before {
content: "";
position: absolute;
left: 0.4rem;
top: 50%;
width: 6px;
height: 18px;
transform: translateY(-50%);
border-radius: 999px;
background: var(--primary);
}
/* --------------------------------------------
Hero
-------------------------------------------- */
.hero-pro {
border: 1px solid var(--border-color);
background:
radial-gradient(700px 260px at 15% 0%, rgba(20,184,166,0.22), transparent 60%),
radial-gradient(520px 240px at 90% 20%, rgba(249,115,22,0.10), transparent 55%),
var(--bg-card);
}
[data-theme="dark"] .hero-pro {
background:
radial-gradient(700px 260px at 15% 0%, rgba(20,184,166,0.16), transparent 60%),
radial-gradient(520px 240px at 90% 20%, rgba(249,115,22,0.08), transparent 55%),
var(--bg-card);
}
.hero-grid {
display: grid;
gap: map.get($spacers, 3);
grid-template-columns: 1.4fr 1fr;
@media (max-width: 992px) {
grid-template-columns: 1fr;
}
}
.kicker { display: inline-flex; align-items: center; gap: map.get($spacers, 2); }
.pill {
font-size: 0.75rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: rgba(20,184,166,0.12);
color: var(--text-main);
border: 1px solid rgba(20,184,166,0.18);
}
/* Card variant used inside hero */
.card-quick {
background: var(--bg-surface);
border: 1px solid var(--border-color);
}
/* --------------------------------------------
Stat cards
-------------------------------------------- */
.stat-title {
letter-spacing: 0.05em;
text-transform: uppercase;
font-size: 0.75rem;
color: var(--text-light);
}
.stat-value {
font-size: 1.85rem;
font-weight: 700;
margin: 0.35rem 0;
}
.stat-sub { font-size: 0.875rem; color: var(--text-muted); }
/* --------------------------------------------
Chips / filters
-------------------------------------------- */
.chip-row { display: flex; flex-wrap: wrap; gap: map.get($spacers, 2); }
.chip {
display: inline-flex;
align-items: center;
gap: map.get($spacers, 2);
padding: 0.35rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-surface);
color: var(--text-muted);
cursor: pointer;
user-select: none;
transition: transform $duration-fast $standard, box-shadow $duration-base $standard, background-color $duration-fast $standard, color $duration-fast $standard;
&:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(0,0,0,0.08);
color: var(--text-main);
}
&.is-active {
background: rgba(20,184,166,0.12);
border-color: rgba(20,184,166,0.18);
color: var(--text-main);
}
}
/* --------------------------------------------
Table polish for dashboard use
-------------------------------------------- */
.table-wrap { overflow: auto; }
.table thead th { position: sticky; top: 0; z-index: 1; }
/* Better look for “table inside card with p-0” */
.card.table-card { padding: 0; overflow: hidden; }
.table-card .table { margin: 0; }
.table-card thead th { background: var(--bg-surface); }
/* --------------------------------------------
Modal close button
-------------------------------------------- */
.btn-close {
border: none;
background: transparent;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--text-muted);
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
transition: background-color $duration-fast $standard, color $duration-fast $standard, transform $duration-fast $standard;
&:hover {
background: rgba(0,0,0,0.06);
color: var(--text-main);
transform: translateY(-1px);
}
&:active { transform: translateY(0); }
}
[data-theme="dark"] .btn-close:hover { background: rgba(255,255,255,0.08); }
/* --------------------------------------------
Progress bars (replace inline styles)
Use: <div class="progress"><div class="progress-bar progress-72"></div></div>
-------------------------------------------- */
.progress {
height: 10px;
border-radius: 999px;
background: var(--bg-surface);
overflow: hidden;
border: 1px solid var(--border-color);
}
.progress-bar {
height: 100%;
background: var(--primary);
border-radius: 999px;
transition: width $duration-slow $standard;
}
/* Common demo widths */
.progress-72 { width: 72%; }
.progress-46 { width: 46%; }
.progress-25 { width: 25%; }
/* Variant colors used in pipeline */
.progress-bar.soft { background: rgba(20,184,166,0.55); }
.progress-bar.warn { background: rgba(249,115,22,0.55); }
/* --------------------------------------------
Tiny helper you used in demo
-------------------------------------------- */
.m-0 { margin: 0 !important; }
/* --------------------------------------------
Dashboard / panel layout patterns
-------------------------------------------- */
.panel-grid {
display: grid;
gap: map.get($spacers, 4);
}
.panel-grid-2 {
display: grid;
gap: map.get($spacers, 4);
grid-template-columns: 1.15fr 0.85fr;
@media (max-width: 992px) {
grid-template-columns: 1fr;
}
}
.panel-grid-3 {
display: grid;
gap: map.get($spacers, 4);
grid-template-columns: repeat(3, minmax(0, 1fr));
@media (max-width: 992px) {
grid-template-columns: 1fr;
}
}
.panel-grid-4 {
display: grid;
gap: map.get($spacers, 4);
grid-template-columns: repeat(4, minmax(0, 1fr));
@media (max-width: 1200px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
/* --------------------------------------------
Rich panel surfaces
-------------------------------------------- */
.panel-glass {
background:
linear-gradient(180deg, rgba(255,255,255,0.78), rgba(255,255,255,0.58)),
var(--bg-card);
border: 1px solid rgba(255,255,255,0.35);
box-shadow:
0 14px 34px rgba(0,0,0,0.07),
0 2px 10px rgba(0,0,0,0.04);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
[data-theme="dark"] .panel-glass {
background:
linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)),
var(--bg-card);
border: 1px solid rgba(255,255,255,0.08);
box-shadow:
0 18px 40px rgba(0,0,0,0.28),
0 2px 10px rgba(0,0,0,0.16);
}
.panel-soft {
background:
radial-gradient(120% 140% at 0% 0%, rgba(var(--primary-rgb), 0.08), transparent 55%),
var(--bg-card);
box-shadow:
0 12px 28px rgba(0,0,0,0.06),
0 2px 8px rgba(0,0,0,0.04);
}
[data-theme="dark"] .panel-soft {
background:
radial-gradient(120% 140% at 0% 0%, rgba(var(--primary-rgb), 0.10), transparent 55%),
var(--bg-card);
box-shadow:
0 16px 34px rgba(0,0,0,0.22),
0 2px 8px rgba(0,0,0,0.14);
}
.panel-tint-teal {
background:
radial-gradient(700px 220px at 12% 0%, rgba(20,184,166,0.18), transparent 58%),
linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0.54)),
var(--bg-card);
}
.panel-tint-orange {
background:
radial-gradient(640px 220px at 90% 0%, rgba(249,115,22,0.16), transparent 58%),
linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0.54)),
var(--bg-card);
}
[data-theme="dark"] .panel-tint-teal {
background:
radial-gradient(700px 220px at 12% 0%, rgba(20,184,166,0.12), transparent 58%),
linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)),
var(--bg-card);
}
[data-theme="dark"] .panel-tint-orange {
background:
radial-gradient(640px 220px at 90% 0%, rgba(249,115,22,0.10), transparent 58%),
linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)),
var(--bg-card);
}
/* --------------------------------------------
Card headers / actions
-------------------------------------------- */
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: map.get($spacers, 3);
margin-bottom: map.get($spacers, 3);
}
.card-actions {
display: flex;
gap: map.get($spacers, 2);
flex-wrap: wrap;
}
/* --------------------------------------------
KPI cards
-------------------------------------------- */
.metric-card {
position: relative;
overflow: hidden;
box-shadow:
0 12px 28px rgba(0,0,0,0.06),
0 2px 8px rgba(0,0,0,0.04);
transition:
transform $duration-base $standard,
box-shadow $duration-base $standard,
border-color $duration-base $standard;
}
.metric-card::before {
content: "";
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 3px;
background: linear-gradient(
90deg,
rgba(20,184,166,0.95),
rgba(34,211,238,0.75),
rgba(249,115,22,0.75)
);
opacity: 0.9;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow:
0 18px 40px rgba(0,0,0,0.10),
0 4px 12px rgba(0,0,0,0.05);
}
[data-theme="dark"] .metric-card {
box-shadow:
0 18px 38px rgba(0,0,0,0.22),
0 2px 8px rgba(0,0,0,0.12);
}
[data-theme="dark"] .metric-card:hover {
box-shadow:
0 22px 46px rgba(0,0,0,0.28),
0 4px 12px rgba(0,0,0,0.16);
}
/* --------------------------------------------
Module / list panels
-------------------------------------------- */
.module-list {
display: flex;
flex-direction: column;
gap: map.get($spacers, 3);
}
.module-item {
padding: map.get($spacers, 3);
border-radius: $radius-md;
border: 1px solid var(--border-color);
background:
linear-gradient(180deg, rgba(255,255,255,0.50), rgba(255,255,255,0.20)),
var(--bg-surface);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.20),
0 8px 18px rgba(0,0,0,0.04);
display: flex;
flex-direction: column;
gap: 0.5rem;
transition:
transform $duration-fast $standard,
box-shadow $duration-base $standard,
border-color $duration-base $standard;
}
.module-item:hover {
transform: translateY(-1px);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.24),
0 14px 24px rgba(0,0,0,0.07);
}
[data-theme="dark"] .module-item {
background:
linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)),
var(--bg-surface);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.04),
0 10px 22px rgba(0,0,0,0.18);
}
[data-theme="dark"] .module-item:hover {
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 14px 28px rgba(0,0,0,0.24);
}
.module-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: map.get($spacers, 2);
}
.module-meta {
display: flex;
justify-content: space-between;
gap: map.get($spacers, 2);
font-size: 0.875rem;
color: var(--text-muted);
@media (max-width: 576px) {
flex-direction: column;
}
}
/* --------------------------------------------
Table shell polish
-------------------------------------------- */
.table-shell {
overflow: hidden;
box-shadow:
0 14px 30px rgba(0,0,0,0.06),
0 2px 8px rgba(0,0,0,0.04);
}
[data-theme="dark"] .table-shell {
box-shadow:
0 18px 36px rgba(0,0,0,0.22),
0 2px 8px rgba(0,0,0,0.14);
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: map.get($spacers, 3);
padding: map.get($spacers, 4);
border-bottom: 1px solid var(--border-color);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
}
}
/* --------------------------------------------
Sidebar surface polish
-------------------------------------------- */
.sidebar.panel-glass {
border-right: 1px solid var(--border-color);
}
/* --------------------------------------------
Split hero / form shell
-------------------------------------------- */
.split-shell {
width: min(980px, 100%);
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: map.get($spacers, 4);
align-items: stretch;
margin-inline: auto;
}
@media (max-width: 860px) {
.split-shell {
grid-template-columns: 1fr;
}
}
/* --------------------------------------------
Ambient full-page body skin
-------------------------------------------- */
body.surface-ambient {
margin: 0;
min-height: 100vh;
color: var(--text-main);
display: grid;
place-items: center;
padding: 28px;
background:
radial-gradient(1200px 700px at 15% 10%, rgba(2,207,204,.18), transparent 55%),
radial-gradient(900px 600px at 95% 25%, rgba(16,183,255,.16), transparent 55%),
radial-gradient(700px 480px at 50% 95%, rgba(124,58,237,.12), transparent 55%),
linear-gradient(180deg, #070a0f, #0b1220);
}
html[data-theme="light"] body.surface-ambient {
background:
radial-gradient(1200px 700px at 15% 10%, rgba(2,207,204,.18), transparent 55%),
radial-gradient(900px 600px at 95% 25%, rgba(16,183,255,.16), transparent 55%),
radial-gradient(700px 480px at 50% 95%, rgba(124,58,237,.12), transparent 55%),
linear-gradient(180deg, #f4f7fb, #eaf0f8);
}
.surface-noise {
position: fixed;
inset: 0;
pointer-events: none;
opacity: .10;
mix-blend-mode: overlay;
background-image:
linear-gradient(transparent 0 48%, rgba(255,255,255,.06) 48% 52%, transparent 52% 100%),
linear-gradient(90deg, transparent 0 48%, rgba(255,255,255,.05) 48% 52%, transparent 52% 100%);
background-size: 38px 38px;
filter: blur(.2px);
}
/* --------------------------------------------
Tech texture overlay
-------------------------------------------- */
.surface-noise {
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.10;
mix-blend-mode: overlay;
background-image:
linear-gradient(transparent 0 48%, rgba(255,255,255,0.06) 48% 52%, transparent 52% 100%),
linear-gradient(90deg, transparent 0 48%, rgba(255,255,255,0.05) 48% 52%, transparent 52% 100%);
background-size: 38px 38px;
filter: blur(.2px);
}
[data-theme="light"] .surface-noise {
opacity: 0.06;
mix-blend-mode: normal;
}
/* --------------------------------------------
Rich glass panel
-------------------------------------------- */
.panel-frame {
border: 1px solid rgba(255,255,255,0.10);
background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.08));
border-radius: 22px;
box-shadow: 0 30px 80px rgba(0,0,0,0.55);
overflow: hidden;
position: relative;
}
[data-theme="light"] .panel-frame {
border-color: rgba(10,20,40,0.10);
background: linear-gradient(180deg, rgba(10,20,40,0.06), rgba(10,20,40,0.08));
box-shadow: 0 30px 80px rgba(0,0,0,0.18);
}
/* --------------------------------------------
Section hero panel
-------------------------------------------- */
.panel-hero {
padding: 34px 30px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 18px;
background:
radial-gradient(520px 320px at 25% 25%, rgba(var(--primary-rgb), 0.18), transparent 60%),
radial-gradient(540px 340px at 90% 20%, rgba(16,183,255,0.14), transparent 55%),
linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03));
}
[data-theme="light"] .panel-hero {
background:
radial-gradient(520px 320px at 25% 25%, rgba(var(--primary-rgb), 0.12), transparent 60%),
radial-gradient(540px 340px at 90% 20%, rgba(16,183,255,0.10), transparent 55%),
linear-gradient(180deg, rgba(255,255,255,0.45), rgba(255,255,255,0.22));
}
.panel-hero h1 {
margin: 0;
font-size: 34px;
letter-spacing: -0.02em;
}
.panel-hero p {
margin: 10px 0 0;
color: var(--text-muted);
max-width: 52ch;
}
/* --------------------------------------------
Panel header / body
-------------------------------------------- */
.panel-bar {
padding: 26px 26px 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
border-bottom: 1px solid rgba(255,255,255,0.10);
}
[data-theme="light"] .panel-bar {
border-bottom-color: rgba(10,20,40,0.10);
}
.panel-body {
padding: 24px 26px 26px;
}
/* --------------------------------------------
Brand pieces
-------------------------------------------- */
.brand-stack {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.1;
}
.mark {
width: 44px;
height: 44px;
border-radius: 12px;
/* Using your Teal/Blue palette */
background:
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.35), transparent 65%),
linear-gradient(135deg, #02CFCC 0%, #10b7ff 100%);
box-shadow:
0 8px 24px rgba(2, 207, 204, 0.25),
inset 0 0 0 1px rgba(255, 255, 255, 0.15);
position: relative;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.mark::before {
content: "";
width: 20px;
height: 20px;
background: white;
clip-path: polygon(
100% 20%,
100% 0%,
0% 0%,
0% 100%,
100% 100%,
100% 80%,
25% 80%,
25% 20%
);
opacity: 0.95;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.mark::after {
content: "";
position: absolute;
width: 6px;
height: 6px;
background: white;
border-radius: 50%;
right: 12px;
top: 50%;
transform: translateY(-50%);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
}
.headline {
font-size: 18px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --------------------------------------------
Quiet action button
-------------------------------------------- */
.btn-quiet {
border: 1px solid rgba(255,255,255,0.10);
background: transparent;
color: var(--text-main);
border-radius: 999px;
padding: 10px 12px;
cursor: pointer;
transition:
transform .08s ease,
border-color $duration-fast $standard,
background-color $duration-fast $standard;
}
.btn-quiet:hover {
background: rgba(255,255,255,0.05);
}
.btn-quiet:active {
transform: translateY(1px);
}
[data-theme="light"] .btn-quiet {
border-color: rgba(10,20,40,0.10);
}
[data-theme="light"] .btn-quiet:hover {
background: rgba(10,20,40,0.04);
}
/* --------------------------------------------
Meta pills row
-------------------------------------------- */
.meta-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 18px;
}
/* override/extend existing .pill use nicely here */
.pill.soft {
border: 1px solid rgba(255,255,255,0.10);
color: var(--text-muted);
background: rgba(0,0,0,0.10);
padding: 8px 10px;
font-size: 12px;
display: inline-flex;
gap: 8px;
align-items: center;
}
[data-theme="light"] .pill.soft {
border-color: rgba(10,20,40,0.10);
background: rgba(255,255,255,0.35);
}
/* --------------------------------------------
Form field shell
-------------------------------------------- */
.field {
display: grid;
gap: 8px;
}
.control {
position: relative;
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
background: rgba(0,0,0,0.10);
transition:
border-color $duration-fast $standard,
box-shadow $duration-fast $standard,
transform .08s ease;
}
[data-theme="light"] .control {
border-color: rgba(10,20,40,0.10);
background: rgba(255,255,255,0.45);
}
.control:focus-within {
border-color: rgba(var(--primary-rgb), 0.55);
box-shadow: 0 0 0 4px rgba(var(--primary-rgb), 0.18);
}
.control > .input {
border: 0;
background: transparent;
box-shadow: none;
height: auto;
padding: 14px 14px;
border-radius: 16px;
}
.control > .input:focus,
.control > .input:focus-visible {
border: 0;
box-shadow: none;
outline: none;
}
.control.has-action > .input {
padding-right: 78px;
}
/* --------------------------------------------
Inline action in field
-------------------------------------------- */
.control-action {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.04);
color: var(--text-main);
border-radius: 12px;
padding: 8px 10px;
cursor: pointer;
font-size: 12px;
transition:
background-color $duration-fast $standard,
transform .08s ease;
}
.control-action:hover {
background: rgba(255,255,255,0.08);
}
.control-action:active {
transform: translateY(calc(-50% + 1px));
}
[data-theme="light"] .control-action {
border-color: rgba(10,20,40,0.10);
background: rgba(10,20,40,0.04);
}
[data-theme="light"] .control-action:hover {
background: rgba(10,20,40,0.08);
}
/* --------------------------------------------
Subtext / tiny footer
-------------------------------------------- */
.subtext {
margin-top: 10px;
color: var(--text-muted);
font-size: 13px;
line-height: 1.45;
}
.panel-foot {
margin-top: 18px;
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
color: var(--text-muted);
font-size: 12px;
border-top: 1px solid rgba(255,255,255,0.10);
padding-top: 14px;
}
[data-theme="light"] .panel-foot {
border-top-color: rgba(10,20,40,0.10);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.transparent-effect-fallback{
background: linear-gradient(180deg, rgb(255 255 255 / 6%), rgb(255 255 255 / 2%));
}
[data-theme="light"] .transparent-effect-fallback{
color: #000;
}

1
rss/css/theme.css Normal file
View File

@@ -0,0 +1 @@
/*# sourceMappingURL=theme.css.map */

1
rss/css/theme.css.map Normal file
View File

@@ -0,0 +1 @@
{"version":3,"sources":[],"names":[],"mappings":"","file":"theme.css"}

0
rss/css/theme.scss Normal file
View File

83
rss/css/variables.css Normal file
View File

@@ -0,0 +1,83 @@
/* variables.scss (drop-in) */
/* palette.scss - Full Spectrum Master Map */
/* --------------------------------------------
Helpers
-------------------------------------------- */
/* --------------------------------------------
Breakpoints
-------------------------------------------- */
/* --------------------------------------------
Spacing
-------------------------------------------- */
/* --------------------------------------------
Elevation
-------------------------------------------- */
/* --------------------------------------------
Radius
-------------------------------------------- */
/* --------------------------------------------
Typography
-------------------------------------------- */
/* --------------------------------------------
Theme tokens
-------------------------------------------- */
:root {
/* Brand */
--primary: #0f766e;
--primary-light: #ccfbf1;
--primary-dark: #042f2e;
--accent: #f97316;
/* RGB channels for focus rings etc */
--primary-rgb: 15 118 110;
/* Status */
--success: #16a34a;
--info: #0d6efd;
--warning: #eab308;
--danger: #dc2626;
/* Surfaces */
--bg-app: #fbfcfd;
--bg-surface: #f8f9fa;
--bg-card: #ffffff;
--border-color: #e9ecef;
/* Text */
--text-main: #212529;
--text-muted: #495057;
--text-light: #adb5bd;
--text-inverse: #ffffff;
--table-header-bg: var(--bg-surface);
--table-striped-bg: #fbfcfd;
--table-hover-bg: rgba(var(--primary-rgb), 0.06);
}
[data-theme=dark] {
--primary: #5eead4;
--primary-light: #115e59;
--primary-rgb: 94 234 212;
--bg-app: #121416;
--bg-surface: #212529;
--bg-card: #343a40;
--border-color: #495057;
--text-main: #f8f9fa;
--text-muted: #ced4da;
--text-light: #6c757d;
--table-header-bg: var(--bg-surface);
--table-striped-bg: rgba(255,255,255,0.03);
--table-hover-bg: rgba(var(--primary-rgb), 0.10);
}
/* --------------------------------------------
SASS aliases to CSS vars
-------------------------------------------- */
/* --------------------------------------------
Z-index
-------------------------------------------- */
/* --------------------------------------------
Grid & layout maps
-------------------------------------------- */
/* --------------------------------------------
Components baseline tokens
-------------------------------------------- */
/* sizing maps */
/* motion */
/* focus ring */
/* sidebar *//*# sourceMappingURL=variables.css.map */

View File

@@ -0,0 +1 @@
{"version":3,"sources":["variables.scss","palette.scss","variables.css"],"names":[],"mappings":"AAAA,6BAAA;ACAA,4CAAA;ADKA;;8CAAA;AASA;;8CAAA;AAYA;;8CAAA;AAgBA;;8CAAA;AAcA;;8CAAA;AAUA;;8CAAA;AA2BA;;8CAAA;AAMA;EACE,UAAA;EACA,kBAAA;EACA,wBAAA;EACA,uBAAA;EACA,iBAAA;EAGA,qCAAA;EACA,yBAAA;EAEA,WAAA;EACA,kBAAA;EACA,eAAA;EACA,kBAAA;EACA,iBAAA;EAEA,aAAA;EACA,iBAAA;EACA,qBAAA;EACA,kBAAA;EACA,uBAAA;EAEA,SAAA;EACA,oBAAA;EACA,qBAAA;EACA,qBAAA;EACA,uBAAA;EAEA,oCAAA;EACA,2BAAA;EACA,gDAAA;AEjFF;;AFoFA;EACE,kBAAA;EACA,wBAAA;EACA,yBAAA;EAEA,iBAAA;EACA,qBAAA;EACA,kBAAA;EACA,uBAAA;EAEA,oBAAA;EACA,qBAAA;EACA,qBAAA;EAEA,oCAAA;EACA,0CAAA;EACA,gDAAA;AEpFF;;AFuFA;;8CAAA;AAuBA;;8CAAA;AAiBA;;8CAAA;AAgCA;;8CAAA;AA8BA,gBAAA;AAgCA,WAAA;AAYA,eAAA;AAIA,YAAA","file":"variables.css"}

305
rss/css/variables.scss Normal file
View File

@@ -0,0 +1,305 @@
/* variables.scss (drop-in) */
@use "sass:map";
@use "sass:color";
@use "./palette" as *;
/* --------------------------------------------
Helpers
-------------------------------------------- */
@function rgb-channels($c) {
@return #{color.channel($c, "red", $space: rgb)}
#{color.channel($c, "green", $space: rgb)}
#{color.channel($c, "blue", $space: rgb)};
}
/* --------------------------------------------
Breakpoints
-------------------------------------------- */
$breakpoints: (
"xs": 0,
"sm": 576px,
"md": 768px,
"lg": 992px,
"xl": 1200px,
"xxl": 1400px
);
/* --------------------------------------------
Spacing
-------------------------------------------- */
$spacer: 1rem;
$spacers: (
0: 0,
1: $spacer * 0.25,
2: $spacer * 0.5,
3: $spacer,
4: $spacer * 1.5,
5: $spacer * 2,
6: $spacer * 3,
7: $spacer * 4
);
/* --------------------------------------------
Elevation
-------------------------------------------- */
$elevation: (
0: none,
1: (0 2px 1px -1px rgba(0,0,0,.2), 0 1px 1px 0 rgba(0,0,0,.14), 0 1px 3px 0 rgba(0,0,0,.12)),
2: (0 3px 1px -2px rgba(0,0,0,.2), 0 2px 2px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12)),
4: (0 2px 4px -1px rgba(0,0,0,.2), 0 4px 5px 0 rgba(0,0,0,.14), 0 1px 10px 0 rgba(0,0,0,.12)),
8: (0 5px 5px -3px rgba(0,0,0,.2), 0 8px 10px 1px rgba(0,0,0,.14), 0 3px 14px 2px rgba(0,0,0,.12)),
12:(0 7px 8px -4px rgba(0,0,0,.2), 0 12px 17px 2px rgba(0,0,0,.14), 0 5px 22px 4px rgba(0,0,0,.12)),
16:(0 8px 10px -5px rgba(0,0,0,.2), 0 16px 24px 2px rgba(0,0,0,.14), 0 6px 30px 5px rgba(0,0,0,.12)),
24:(0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12))
);
/* --------------------------------------------
Radius
-------------------------------------------- */
$radius-none: 0;
$radius-sm: 0.25rem;
$radius-md: 0.5rem;
$radius-lg: 1rem;
$radius-xl: 1.5rem;
$radius-pill: 50rem;
/* --------------------------------------------
Typography
-------------------------------------------- */
$font-family-sans: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
$font-family-alt: "Poppins", sans-serif;
$weight-light: 300;
$weight-normal: 400;
$weight-medium: 500;
$weight-bold: 700;
$font-sizes: (
"h1": 2.5rem,
"h2": 2rem,
"h3": 1.75rem,
"h4": 1.5rem,
"h5": 1.25rem,
"h6": 1rem,
"body": 1rem,
"small": 0.875rem,
"xs": 0.75rem
);
$line-height-tight: 1.2;
$line-height-base: 1.5;
$line-height-loose: 1.8;
/* --------------------------------------------
Theme tokens
-------------------------------------------- */
$primary-color: map.get(map.get($colors, "teal"), 700);
$primary-color-dark: map.get(map.get($colors, "teal"), 300);
:root {
/* Brand */
--primary: #{$primary-color};
--primary-light: #{map.get(map.get($colors, "teal"), 100)};
--primary-dark: #{map.get(map.get($colors, "teal"), 950)};
--accent: #{map.get(map.get($colors, "orange"), 500)};
/* RGB channels for focus rings etc */
--primary-rgb: #{rgb-channels($primary-color)};
/* Status */
--success: #{map.get(map.get($colors, "green"), 600)};
--info: #{map.get(map.get($colors, "blue"), 500)};
--warning: #{map.get(map.get($colors, "yellow"), 500)};
--danger: #{map.get(map.get($colors, "red"), 600)};
/* Surfaces */
--bg-app: #{map.get(map.get($colors, "gray"), 50)};
--bg-surface: #{map.get(map.get($colors, "gray"), 100)};
--bg-card: #{$white};
--border-color: #{map.get(map.get($colors, "gray"), 200)};
/* Text */
--text-main: #{map.get(map.get($colors, "gray"), 900)};
--text-muted: #{map.get(map.get($colors, "gray"), 700)};
--text-light: #{map.get(map.get($colors, "gray"), 500)};
--text-inverse: #{$white};
--table-header-bg: var(--bg-surface);
--table-striped-bg: #{map.get(map.get($colors, "gray"), 50)};
--table-hover-bg: rgba(var(--primary-rgb), 0.06);
}
[data-theme="dark"] {
--primary: #{$primary-color-dark};
--primary-light: #{map.get(map.get($colors, "teal"), 800)};
--primary-rgb: #{rgb-channels($primary-color-dark)};
--bg-app: #{map.get(map.get($colors, "gray"), 950)};
--bg-surface: #{map.get(map.get($colors, "gray"), 900)};
--bg-card: #{map.get(map.get($colors, "gray"), 800)};
--border-color: #{map.get(map.get($colors, "gray"), 700)};
--text-main: #{map.get(map.get($colors, "gray"), 100)};
--text-muted: #{map.get(map.get($colors, "gray"), 400)};
--text-light: #{map.get(map.get($colors, "gray"), 600)};
--table-header-bg: var(--bg-surface);
--table-striped-bg: rgba(255,255,255,0.03);
--table-hover-bg: rgba(var(--primary-rgb), 0.10);
}
/* --------------------------------------------
SASS aliases to CSS vars
-------------------------------------------- */
$primary: var(--primary);
$primary-light: var(--primary-light);
$primary-dark: var(--primary-dark);
$accent: var(--accent);
$success: var(--success);
$info: var(--info);
$warning: var(--warning);
$danger: var(--danger);
$bg-app: var(--bg-app);
$bg-surface: var(--bg-surface);
$bg-card: var(--bg-card);
$border-color: var(--border-color);
$text-main: var(--text-main);
$text-muted: var(--text-muted);
$text-light: var(--text-light);
$text-inverse: var(--text-inverse);
/* --------------------------------------------
Z-index
-------------------------------------------- */
$z-index: (
"deep": -1,
"default": 1,
"sticky": 100,
"sidebar": 200,
"header": 300,
"backdrop": 400,
"modal": 500,
"dropdown": 600,
"popover": 700,
"tooltip": 800,
"toast": 900
);
/* --------------------------------------------
Grid & layout maps
-------------------------------------------- */
$container-max-widths: (
"sm": 540px,
"md": 720px,
"lg": 960px,
"xl": 1140px,
"xxl": 1320px
);
$grid-columns: 12;
$grid-gutter-width: map.get($spacers, 3);
$flex-directions: (row, row-reverse, column, column-reverse);
$justify-content: (
"start": flex-start,
"end": flex-end,
"center": center,
"between": space-between,
"around": space-around,
"evenly": space-evenly
);
$align-items: (
"start": flex-start,
"end": flex-end,
"center": center,
"baseline": baseline,
"stretch": stretch
);
$display-values: (none, block, inline, inline-block, flex, inline-flex, grid);
/* --------------------------------------------
Components baseline tokens
-------------------------------------------- */
$input-height: 2.5rem;
$input-height-sm: 2rem;
$input-height-lg: 3.25rem;
$input-padding-y: map.get($spacers, 2);
$input-padding-x: map.get($spacers, 3);
$label-margin-bottom: map.get($spacers, 1);
$label-font-size: map.get($font-sizes, "small");
$label-font-weight: $weight-medium;
$card-padding: map.get($spacers, 4);
$card-border-width: 1px;
$card-border-radius: $radius-md;
$table-cell-padding-y: map.get($spacers, 3);
$table-cell-padding-x: map.get($spacers, 3);
$table-header-bg: var(--table-header-bg);
$table-header-color: $text-muted;
$table-striped-bg: var(--table-striped-bg);
$table-hover-bg: var(--table-hover-bg);
$scrollbar-width: 8px;
$scrollbar-track: map.get(map.get($colors, "gray"), 100);
$scrollbar-thumb: map.get(map.get($colors, "gray"), 300);
$scrollbar-thumb-hover: map.get(map.get($colors, "gray"), 400);
/* sizing maps */
$sizes: (
25: 25%,
33: 33.33%,
50: 50%,
66: 66.66%,
75: 75%,
100: 100%,
"auto": auto,
"screen-v": 100vh,
"screen-h": 100vw
);
$spacing-properties: ("m": "margin", "p": "padding");
$spacing-sides: (
"t": "top",
"b": "bottom",
"s": "start",
"e": "end",
"x": ("left", "right"),
"y": ("top", "bottom"),
"a": ""
);
$object-fits: (contain, cover, fill, scale-down);
$aspect-ratios: (
"1x1": 100%,
"4x3": 75%,
"16x9": 56.25%,
"21x9": 42.85%
);
/* motion */
$standard: cubic-bezier(0.4, 0, 0.2, 1);
$duration-fast: 150ms;
$duration-base: 250ms;
$duration-slow: 400ms;
$hover-brightness: 95%;
$active-brightness: 90%;
$hover-lift: translateY(-2px);
$hover-shadow: map.get($elevation, 4);
/* focus ring */
$focus-ring-width: 3px;
$focus-ring-color: rgba(var(--primary-rgb), 0.35);
/* sidebar */
$sidebar-width: 280px;
$sidebar-collapsed: 84px;

910
rss/js/main.js Normal file
View File

@@ -0,0 +1,910 @@
/* ============================================================
VOR Bootstrap / Globals
============================================================ */
window.VOR = window.VOR || {};
VOR.locale = VOR.locale ?? 'en-US';
VOR.currency = VOR.currency ?? 'EUR';
/* ============================================================
Network / API
============================================================ */
VOR.post = async function (data = {}, target, method, opts = {}) {
const {
url = "/rss/php/handler.php",
timeoutMs = 15000,
credentials = "same-origin",
} = opts;
if (!target || !method) {
return { ok: false, error: "Missing target/method" };
}
const fd = new FormData();
const append = (k, v) => {
if (v === undefined || v === null) return;
if (v instanceof File || v instanceof Blob) {
fd.append(k, v);
return;
}
if (v instanceof FileList) {
for (const f of v) fd.append(k, f);
return;
}
if (Array.isArray(v)) {
for (const item of v) append(`${k}[]`, item);
return;
}
if (typeof v === "object") {
fd.append(k, JSON.stringify(v));
return;
}
fd.append(k, String(v));
};
for (const [k, v] of Object.entries(data || {})) {
append(k, v);
}
fd.append("target", target);
fd.append("method", method);
const ctrl = timeoutMs ? new AbortController() : null;
const t = timeoutMs ? setTimeout(() => ctrl.abort(), timeoutMs) : null;
try {
const res = await fetch(url, {
method: "POST",
body: fd,
credentials,
signal: ctrl?.signal,
});
try {
const resp = await res.json();
if (!res.ok) {
return {
ok: false,
error: resp?.message || `HTTP ${res.status}`,
status: res.status,
raw: resp,
};
}
if (!resp || typeof resp !== "object") {
return {
ok: false,
error: "Bas json response",
status: res.status,
};
}
if (resp.status == "success") {
return {
ok: true,
data: resp.message,
status: res.status,
raw: resp,
};
}
return {
ok: false,
error: resp.message ?? "Failed",
status: res.status,
raw: resp,
};
} catch (err) {
return { ok: false, error: "Could not parse JSON", status: res.status };
}
} catch (errno) {
const msg =
errno?.name === "AbortError" ? "Timeout" : errno?.message || String(errno);
return { ok: false, error: msg };
} finally {
if (t) clearTimeout(t);
}
};
/* ============================================================
UI: Table
============================================================ */
VOR.uiTable = function ({
el,
headers = [],
data = [],
sortable = true,
emptyText = "No data",
classArr = ["table"],
editRenderer = null,
} = {}) {
const container = typeof el === "string" ? document.querySelector(el) : el;
if (!container) return;
VOR.clearHtml(container);
const table = VOR.createEl("table", classArr);
const thead = VOR.createEl("thead");
const tbody = VOR.createEl("tbody");
let sortState = { key: null, dir: "asc" };
let openEditorRow = null;
function closeEditor() {
if (openEditorRow) {
openEditorRow.remove();
openEditorRow = null;
}
}
function sortData(rows) {
if (!sortable || !sortState.key) return rows;
return [...rows].sort((a, b) => {
const v1 = a[sortState.key];
const v2 = b[sortState.key];
if (v1 === v2) return 0;
if (v1 == null) return 1;
if (v2 == null) return -1;
if (typeof v1 === "number" && typeof v2 === "number") {
return sortState.dir === "asc" ? v1 - v2 : v2 - v1;
}
return sortState.dir === "asc"
? String(v1).localeCompare(String(v2))
: String(v2).localeCompare(String(v1));
});
}
function expandRow(tr, rowData) {
if (openEditorRow && openEditorRow.previousSibling === tr) {
closeEditor();
return;
}
closeEditor();
if (typeof editRenderer !== "function") return;
const editorTr = VOR.createEl("tr", ["row-editor"]);
const td = VOR.createEl("td", null, null, { colspan: headers.length });
const content = editRenderer(rowData);
if (content instanceof HTMLElement) td.append(content);
editorTr.append(td);
tr.after(editorTr);
openEditorRow = editorTr;
}
function renderBody(rows) {
VOR.clearHtml(tbody);
closeEditor();
if (!rows.length) {
const tr = VOR.createEl("tr");
const td = VOR.createEl("td", ["empty"], emptyText, {
colspan: headers.length,
});
tr.append(td);
tbody.append(tr);
return;
}
rows.forEach((row) => {
const tr = VOR.createEl("tr");
headers.forEach((h) => {
const td = VOR.createEl("td");
const value = row[h.key];
if (h.action) {
const btn = VOR.createEl(
"button",
["table-action", `is-${h.action}`],
h.format && typeof h.format === "function"
? h.format(value, row)
: value ?? ""
);
btn.addEventListener("click", (e) => {
e.stopPropagation();
switch (h.action) {
case "visit":
if (h.actionConfig?.url) {
const url = h.actionConfig.url(row);
if (url) window.location.href = url;
}
break;
case "toggle":
if (h.actionConfig?.selector) {
const sel = h.actionConfig.selector(row);
if (sel) VOR.uiToggle(sel, true);
}
break;
case "edit-expand":
expandRow(tr, row);
break;
}
});
td.append(btn);
} else {
const text =
h.format && typeof h.format === "function"
? h.format(value, row)
: value ?? "";
td.append(document.createTextNode(text));
}
tr.append(td);
});
tbody.append(tr);
});
}
function renderHeader() {
const tr = VOR.createEl("tr");
headers.forEach((h) => {
const th = VOR.createEl(
"th",
sortable && h.sortable !== false ? ["sortable"] : null,
h.label
);
if (sortable && h.sortable !== false) {
th.addEventListener("click", () => {
closeEditor();
if (sortState.key === h.key) {
sortState.dir = sortState.dir === "asc" ? "desc" : "asc";
} else {
sortState.key = h.key;
sortState.dir = "asc";
}
renderBody(sortData(data));
});
}
tr.append(th);
});
thead.append(tr);
}
renderHeader();
renderBody(data);
table.append(thead, tbody);
container.append(table);
};
/* ============================================================
State Store
============================================================ */
VOR.state = (function () {
const store = {};
return {
set(key, value) {
store[key] = value;
},
get(key) {
return store[key];
},
update(key, fn) {
if (typeof fn === "function") {
store[key] = fn(store[key]);
}
},
remove(key) {
delete store[key];
},
};
})();
/* ============================================================
UI: Alerts / Confirm / Form / Dropdown / Pagination / Badge
============================================================ */
VOR.uiAlert = function (message, type = "info", timeout = 4000) {
let container = document.querySelector(".vor-alert-stack");
if (!container) {
container = VOR.createEl("div", ["vor-alert-stack"]);
document.body.append(container);
}
const alert = VOR.createEl("div", ["vor-alert", `is-${type}`]);
alert.append(document.createTextNode(message));
container.append(alert);
setTimeout(() => {
alert.remove();
if (!container.children.length) container.remove();
}, timeout);
};
VOR.uiConfirm = function ({
title = "Confirm",
text = "",
confirmLabel = "Confirm",
cancelLabel = "Cancel",
onConfirm = null,
} = {}) {
const overlay = VOR.createEl("div", ["modal-overlay", "is-active"]);
overlay.setAttribute("aria-hidden", "false");
const content = VOR.createEl("div", ["modal-content", "vor-modal-sm"]);
const header = VOR.createEl("div", ["modal-header"]);
const titleEl = VOR.createEl("h3", ["modal-title"], title);
const closeBtn = VOR.createEl("button", ["btn-close"], "×", {
type: "button",
"aria-label": "Close"
});
closeBtn.addEventListener("click", () => {
overlay.remove();
document.body.classList.remove("lock-scroll");
});
header.append(titleEl, closeBtn);
const body = VOR.createEl("div", ["modal-body"]);
body.append(VOR.createEl("p", null, text));
const footer = VOR.createEl("div", ["modal-footer"]);
const cancelBtn = VOR.createEl("button", ["btn-gray-light"], cancelLabel, {
type: "button"
});
const confirmBtn = VOR.createEl("button", ["btn-red"], confirmLabel, {
type: "button"
});
cancelBtn.addEventListener("click", () => {
overlay.remove();
document.body.classList.remove("lock-scroll");
});
confirmBtn.addEventListener("click", () => {
if (typeof onConfirm === "function") onConfirm();
overlay.remove();
document.body.classList.remove("lock-scroll");
});
footer.append(cancelBtn, confirmBtn);
content.append(header, body, footer);
overlay.append(content);
document.body.append(overlay);
document.body.classList.add("lock-scroll");
};
VOR.uiForm = function ({ fields = [], actions = [] } = {}) {
const form = VOR.createEl("form", ["vor-form"]);
fields.forEach((f) => {
const group = VOR.createEl("div", ["form-group"]);
if (f.label) {
group.append(VOR.createEl("label", null, f.label));
}
const input = VOR.createInput({
type: f.type || "text",
name: f.name,
value: f.value ?? null,
options: f.options ?? null,
classArr: ["input"],
});
group.append(input);
form.append(group);
});
if (actions.length) {
const actionWrap = VOR.createEl("div", ["form-actions"]);
actions.forEach((a) => {
const btn = VOR.createEl(
"button",
["btn", a.variant ? `is-${a.variant}` : null].filter(Boolean),
a.label,
{ type: a.type || "button" }
);
if (a.onClick) btn.addEventListener("click", a.onClick);
actionWrap.append(btn);
});
form.append(actionWrap);
}
return form;
};
VOR.uiDropdown = function ({ trigger, items = [] } = {}) {
const menu = VOR.createEl("div", ["vor-dropdown"]);
items.forEach((i) => {
const item = VOR.createEl("div", ["dropdown-item"], i.label);
item.addEventListener("click", () => {
if (typeof i.action === "function") i.action();
menu.remove();
});
menu.append(item);
});
document.body.append(menu);
const rect = trigger.getBoundingClientRect();
menu.style.position = "absolute";
menu.style.top = `${rect.bottom + window.scrollY}px`;
menu.style.left = `${rect.left + window.scrollX}px`;
document.addEventListener("click", () => menu.remove(), { once: true });
};
VOR.uiPagination = function ({ page = 1, totalPages = 1, onChange = null } = {}) {
const wrap = VOR.createEl("div", ["vor-pagination"]);
for (let i = 1; i <= totalPages; i++) {
const btn = VOR.createEl(
"button",
["page-btn", i === page ? "is-active" : null].filter(Boolean),
i
);
btn.addEventListener("click", () => {
if (typeof onChange === "function") onChange(i);
});
wrap.append(btn);
}
return wrap;
};
VOR.uiBadge = function (text, type = "neutral") {
return VOR.createEl("span", ["badge", `is-${type}`], text);
};
/* ============================================================
UI: Loading / Field Errors / Empty State
============================================================ */
VOR.uiLoader = function (el, state = true) {
if (!el) return;
if (state) {
el.dataset.originalText = el.textContent;
el.textContent = "Loading...";
el.disabled = true;
} else {
el.textContent = el.dataset.originalText || "";
el.disabled = false;
}
};
VOR.uiFieldError = function (formEl, errors = {}) {
if (!formEl) return;
formEl.querySelectorAll(".field-error").forEach((e) => e.remove());
Object.entries(errors).forEach(([name, msg]) => {
const field = formEl.querySelector(`[name="${name}"]`);
if (!field) return;
const err = VOR.createEl("div", ["field-error"], msg);
field.closest(".form-group")?.append(err);
});
};
VOR.uiEmpty = function ({ title = "", text = "", action = null } = {}) {
const wrap = VOR.createEl("div", ["vor-empty"]);
if (title) wrap.append(VOR.createEl("h3", null, title));
if (text) wrap.append(VOR.createEl("p", null, text));
if (action) {
const btn = VOR.createEl("button", ["btn"], action.label);
btn.addEventListener("click", action.onClick);
wrap.append(btn);
}
return wrap;
};
/* ============================================================
DOM Helpers
============================================================ */
VOR.createEl = function (tag, classArr = null, text = null, attrs = null) {
const el = document.createElement(tag);
if (text !== null && text !== undefined) {
el.append(document.createTextNode(String(text)));
}
if (classArr?.length) {
el.classList.add(...classArr);
}
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
if (v === null || v === undefined) continue;
if (k.startsWith("data-")) {
el.setAttribute(k, String(v));
} else if (k.startsWith("on") && typeof v === "function") {
el.addEventListener(k.slice(2), v);
} else if (k in el) {
el[k] = v;
} else {
el.setAttribute(k, String(v));
}
}
}
return el;
};
VOR.createInput = function ({
type = "text",
classArr = null,
placeholder = "",
options = null,
name,
id = null,
dataset = null,
value = null,
} = {}) {
if (!name) {
throw new Error("Create input: Name is required");
}
const el =
type === "select" ? VOR.createEl("select", classArr) : VOR.createEl("input", classArr);
el.name = name;
if (id) el.id = id;
if (dataset) {
for (const [k, v] of Object.entries(dataset)) {
if (v !== undefined && v !== null) el.dataset[k] = String(v);
}
}
if (type !== "select") {
el.type = type;
if (placeholder && !["checkbox", "radio", "hidden"].includes(type)) {
el.placeholder = placeholder;
}
if (["checkbox", "radio"].includes(type)) {
if (value === true) {
el.checked = true;
} else if (value !== null && value !== undefined) {
el.value = String(value);
}
} else if (value !== null && value !== undefined) {
el.value = String(value);
}
} else if (options) {
for (const [val, txt] of Object.entries(options)) {
const opt = document.createElement("option");
opt.value = String(val);
opt.textContent = String(txt);
if (value !== null && value !== undefined && String(val) === String(value)) {
opt.selected = true;
}
el.appendChild(opt);
}
}
return el;
};
VOR.clearHtml = function (el) {
if (!el) return el;
el.replaceChildren();
return el;
};
VOR.getDefaultId = function () {
const id = window.location.pathname.replace(/\/$/, "").split("/").pop();
const num = parseInt(id, 10);
return Number.isFinite(num) ? num : null;
};
VOR.getRandomId = function () {
return crypto?.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
};
VOR.prettyNum = function (
val,
{ type = "decimal", locale = VOR.locale, currency = VOR.currency } = {}
) {
if (val === null || val === undefined || val === "") return "";
const opts = { style: type };
if (type === "currency") opts.currency = currency;
const n = typeof val === "string" ? Number(val) : val;
return new Intl.NumberFormat(locale, opts).format(n);
};
/* ============================================================
Theme / Modals / Toggles
============================================================ */
VOR.uiGetTheme = function () {
const t = document.documentElement.getAttribute("data-theme");
return t === "dark" || t === "light" ? t : "light";
};
VOR.uiSetTheme = function (theme = "light") {
const next = theme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("vor_theme", next);
};
VOR.uiToggleTheme = function () {
const next = VOR.uiGetTheme() === "dark" ? "light" : "dark";
VOR.uiSetTheme(next);
};
VOR.uiCloseAllModals = function () {
document.querySelectorAll(".modal-overlay.is-active").forEach((m) => {
m.classList.remove("is-active");
m.setAttribute("aria-hidden", "true");
});
document.body.classList.remove("lock-scroll");
};
VOR.uiToggle = function (selector, forceState = null) {
const el = document.querySelector(selector);
if (!el) return;
const isDrawer = el.classList.contains("drawer");
const isModal = el.classList.contains("modal-overlay");
const activeClass = isDrawer ? "is-open" : "is-active";
const nextState = forceState !== null ? !!forceState : !el.classList.contains(activeClass);
el.classList.toggle(activeClass, nextState);
el.setAttribute("aria-hidden", nextState ? "false" : "true");
if (isModal) {
document.body.classList.toggle("lock-scroll", nextState);
}
};
/* ============================================================
Global Event Wiring
============================================================ */
VOR.initGlobalEvents = function () {
const app = document.body;
app.addEventListener("click", (e) => {
const toggleBtn = e.target.closest("[data-toggle]");
if (toggleBtn) {
const sel = toggleBtn.dataset.toggle;
if (sel) VOR.uiToggle(sel);
return;
}
const themeBtn = e.target.closest("[data-theme-toggle]");
if (themeBtn) {
const v = (themeBtn.dataset.themeToggle || "").trim();
if (v === "light" || v === "dark") {
VOR.uiSetTheme(v);
} else {
VOR.uiToggleTheme();
}
return;
}
const accordionHeader = e.target.closest(".accordion-header");
if (accordionHeader) {
const item = accordionHeader.closest(".accordion-item");
if (item) item.classList.toggle("is-active");
return;
}
const chip = e.target.closest("[data-chip]");
if (chip) {
const scope = chip.parentElement || document;
scope
.querySelectorAll("[data-chip].is-active")
.forEach((c) => c.classList.remove("is-active"));
chip.classList.add("is-active");
return;
}
const modalOverlay = e.target.closest(".modal-overlay");
if (modalOverlay && modalOverlay.classList.contains("is-active")) {
if (e.target.closest(".modal-content")) return;
modalOverlay.classList.remove("is-active");
modalOverlay.setAttribute("aria-hidden", "true");
document.body.classList.remove("lock-scroll");
return;
}
const drawerModal = e.target.closest(".drawer-modal");
if (drawerModal && drawerModal.classList.contains("is-active")) {
if (e.target.closest(".drawer-panel")) return;
if (drawerModal.dataset.closeBackdrop === "0") return;
VOR.uiDrawerModalClose(drawerModal);
return;
}
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const openModal = document.querySelector(".modal-overlay.is-active");
if (openModal) {
openModal.classList.remove("is-active");
openModal.setAttribute("aria-hidden", "true");
if (!document.querySelector(".modal-overlay.is-active, .drawer-modal.is-active")) {
document.body.classList.remove("lock-scroll");
}
return;
}
const openDrawerModal = document.querySelector(".drawer-modal.is-active");
if (openDrawerModal) {
if (openDrawerModal.dataset.closeEsc === "0") return;
VOR.uiDrawerModalClose(openDrawerModal);
return;
}
const openDrawer = document.querySelector(".drawer.is-open");
if (openDrawer) {
openDrawer.classList.remove("is-open");
openDrawer.setAttribute("aria-hidden", "true");
}
});
};
/* ============================================================
UI: Drawer Modal (off-canvas modal)
============================================================ */
VOR.uiDrawerModalOpen = function ({
id = "vor-drawer-modal",
title = "",
content = null,
side = "right",
width = null,
onClose = null,
closeOnBackdrop = true,
closeOnEsc = true,
showHeader = true,
showFooter = false,
footer = null,
} = {}) {
const existing = document.getElementById(id);
if (existing) existing.remove();
const overlay = VOR.createEl("div", ["drawer-modal"], null, {
id,
"data-modal-type": "drawer",
"data-close-backdrop": closeOnBackdrop ? "1" : "0",
"data-close-esc": closeOnEsc ? "1" : "0",
});
if (side === "left") overlay.classList.add("is-left");
const panel = VOR.createEl("div", ["drawer-panel"]);
if (width) panel.style.width = String(width);
if (showHeader) {
const header = VOR.createEl("div", ["drawer-panel-header"]);
const h = VOR.createEl("div");
if (title) h.append(VOR.createEl("div", ["h5"], title));
const closeBtn = VOR.createEl("button", ["btn-close"], "×", {
type: "button",
"aria-label": "Close",
"data-drawer-close": "1",
});
header.append(h, closeBtn);
panel.append(header);
}
const body = VOR.createEl("div", ["drawer-panel-body"]);
if (content instanceof HTMLElement) {
body.append(content);
} else if (typeof content === "string") {
body.innerHTML = content;
} else {
}
panel.append(body);
if (showFooter) {
const foot = VOR.createEl("div", ["drawer-panel-footer"]);
if (footer instanceof HTMLElement) foot.append(footer);
else if (typeof footer === "string") foot.innerHTML = footer;
panel.append(foot);
}
overlay.append(panel);
document.body.append(overlay);
document.body.classList.add("lock-scroll");
requestAnimationFrame(() => {
overlay.classList.add("is-active");
});
overlay.addEventListener("click", (e) => {
if (e.target.closest("[data-drawer-close]")) {
VOR.uiDrawerModalClose(id, onClose);
}
});
overlay.__vorOnClose = typeof onClose === "function" ? onClose : null;
setTimeout(() => {
const focusable = panel.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus?.();
}, 0);
return { overlay, panel, body };
};
VOR.uiDrawerModalClose = function (id = "vor-drawer-modal", onClose = null) {
const overlay = typeof id === "string" ? document.getElementById(id) : id;
if (!overlay) return;
overlay.classList.remove("is-active");
const cb = typeof onClose === "function" ? onClose : overlay.__vorOnClose;
setTimeout(() => {
overlay.remove();
if (!document.querySelector(".modal-overlay.is-active, .drawer-modal.is-active")) {
document.body.classList.remove("lock-scroll");
}
if (typeof cb === "function") cb();
}, 180);
};
/* ============================================================
Boot
============================================================ */
document.addEventListener("DOMContentLoaded", () => {
const savedTheme = localStorage.getItem("vor_theme");
VOR.uiSetTheme(savedTheme === "dark" ? "dark" : "light");
VOR.initGlobalEvents();
});

View File

@@ -0,0 +1,8 @@
{
"owner": {
"home": "dashboard"
},
"user": {
"home": "home"
}
}

View File

@@ -0,0 +1,5 @@
{
"1": [
"CREATE TABLE IF NOT EXISTS users (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50), email VARCHAR(90), password TEXT)"
]
}

27
rss/json/pages/404.json Normal file
View File

@@ -0,0 +1,27 @@
{
"layout": {
"header": "default",
"body": "index",
"footer": "default",
"template": "main_layout"
},
"meta": {
"title": "404",
"description": "Template",
"robots": "noindex, nofollow",
"og_image": ""
},
"rules": {
"restricted": false,
"redirect_login": "/login/",
"login_restricted": false,
"login_redirect": ""
},
"init": [],
"scripts": [],
"modals": [""],
"rich_data": {
"type": "",
"price": "0"
}
}

27
rss/json/pages/index.json Normal file
View File

@@ -0,0 +1,27 @@
{
"layout": {
"header": "default",
"body": "index",
"footer": "default",
"template": "main_layout"
},
"meta": {
"title": "template",
"description": "Template",
"robots": "noindex, nofollow",
"og_image": ""
},
"rules": {
"restricted": true,
"redirect_login": "/login/",
"login_restricted": false,
"login_redirect": ""
},
"init": [],
"scripts": [{"name": "app.js", "type": "module"}, {"name": "test.js", "type": "script"}],
"modals": [""],
"rich_data": {
"type": "",
"price": "0"
}
}

22
rss/php/autoload.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
spl_autoload_register(function (string $class): void {
$root = rtrim($_SERVER['DOCUMENT_ROOT'], '/');
// PSR-4-ish:
$prefix = 'Vor\\';
$baseDir = $root . '/rss/php/classes/';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require_once $file;
}
}, true, true);

View File

@@ -0,0 +1,110 @@
<?php
namespace Vor\application;
use Vor\core\Sys;
use Vor\core\Main;
class Frontend{
public static function render() {
Sys::start();
$pageName = $_GET['page'] ?? 'index';
$jsonPath = BASE . "/rss/json/pages/$pageName.json";
if (!file_exists($jsonPath)) {
http_response_code(404);
$pageName = '404';
$jsonPath = BASE . "/rss/json/pages/404.json";
}
$conf = json_decode(file_get_contents($jsonPath), true);
if (!self::validate($conf)) {
header('Location: ' . $conf['rules']['redirect_login']);
exit;
}
self::authRedirect($conf);
$viewData = [];
if (isset($conf['init']) && is_array($conf['init'])) {
foreach ($conf['init'] as $task) {
$viewData[$task['return']] = self::execute($task);
}
}
$validatedScripts = [];
$pageScript = "/rss/js/pages/{$conf['layout']['body']}.js";
if(file_exists(BASE . $pageScript)){
$validatedScripts[] = ['src' => $pageScript, 'type' => 'module'];
}
foreach(($conf['scripts'] ?? []) as $s){
if($src = self::getScriptPath($s)){
$validatedScripts[] = ['src' => $src, 'type' => $s['type'] == 'module' ? 'module' : 'text/javascript'];
}
}
return [
'header' => BASE . "/rss/php/views/headers/" . ($conf['layout']['header'] ?? 'default') . ".php",
'view' => BASE . "/rss/php/views/pages/$pageName.php",
'footer' => BASE . "/rss/php/views/footers/" . ($conf['layout']['footer'] ?? 'default') . ".php",
'data' => self::clean($viewData),
'scripts' => $validatedScripts,
'conf' => $conf
];
}
private static function validate($c) {
$restricted = $c['rules']['restricted'] ?? false;
if (!$restricted) return true;
return $_SERVER['VOR_AUTH'];
}
private static function authRedirect($c){
$loginRestricted = $c['rules']['login_restricted'] ?? false;
if($loginRestricted && isset($_SERVER['VOR_AUTH'])){
header('location: ' . $c['rules']['login_redirect'] ?? '/');
exit();
}
}
private static function execute($f) {
$className = "\\Vor\\application\\" . $f['class'];
if (class_exists($className)) {
$instance = new $className();
$method = $f['function'];
if (method_exists($instance, $method)) {
return $instance->$method();
}
}
return null;
}
public static function clean($data){
if(is_array($data)){
return array_map([self::class, 'clean'], $data);
}
return htmlspecialchars(trim((string)$data), ENT_QUOTES, 'UTF-8');
}
public static function loadScripts($conf) {
if (isset($conf['scripts']) && is_array($conf['scripts'])) {
foreach ($conf['scripts'] as $script) {
echo '<script src="/rss/js/' . $script . '.js"></script>' . PHP_EOL;
}
}
}
public static function getScriptPath($script){
$folder = ($script['type'] ?? '') === 'module' ? 'modules' : 'scripts';
$name = $script['name'];
$path = "/rss/js/$folder/$name";
return file_exists(BASE . $path) ? $path : null;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Vor\application\user;
use Vor\core\Sys;
use Vor\core\Main;
use Exception;
class Auth{
public $username;
public $email;
public $password;
public static function isAuth(){
$authData = $_SERVER['VOR_AUTH'] ?? false;
if(!$authData){
return false;
}
return (int)Sys::session('uid') === (int)$authData['uid'];
}
public function login(){
if(isset($this->email) && isset($this->password)){
$userData = Main::select('users', ['email', 'username', 'password', 'id'], ['email' => trim($this->email)]);
if(!$userData){
return false;
}
if(password_verify($this->password, $userData['password'])){
$sid = bin2hex(random_bytes(16));
$payload = [
'uid' => (int)$userData['id'],
'sid' => $sid,
'exp' => time() + 86400
];
if(Sys::cookieSet('v_auth', $payload)){
Sys::validateSession($sid);
Sys::session('uid', (int)$userData['id']);
Sys::session('logged_in_at', time());
return true;
}
}
}
return false;
}
public function logout(){
Sys::cookieClear('v_auth');
if(session_status() === PHP_SESSION_ACTIVE){
session_unset();
session_destroy();
}
return true;
}
}
?>

View File

@@ -0,0 +1,212 @@
<?php
namespace Vor\core;
use PDO;
use Exception;
use Vor\core\Sys;
class Main{
/**
* Static query functions
*/
public static function insert($table, $data, $whitelist = []){
if(!self::assertIdent($table)){
throw new \InvalidArgumentException('Invalid table name');
}
$db = Sys::getConnection();
if(!empty($whitelist)){
$data = array_intersect_key($data, array_flip($whitelist));
}
if(empty($data)){
return false;
}
$fields = array_keys($data);
$cols = implode(', ', $fields);
$placeholders = ':' . implode(', :' , $fields);
$query = "INSERT INTO $table ($cols) VALUES ($placeholders)";
try{
$stmt = $db->prepare($query);
foreach($data as $key => $val){
$stmt->bindValue(':' . $key, $val);
}
if($stmt->execute()){
return $db->lastInsertId();
}
}catch(Exception $e) {
error_log($e->getMessage());
return false;
}
}
public static function update($table, $data, $where, $whitelist = []){
if(!self::assertIdent($table)){
throw new \InvalidArgumentException('Invalid table name');
}
$db = Sys::getConnection();
if(!empty($whitelist)){
$data = array_intersect_key($data, array_flip($whitelist));
}
if(empty($data)){
return false;
}
$setParts = [];
$whereParts = [];
// Set
foreach($data as $key => $val){
$setParts[] = "$key = :$key";
}
$setSql = implode(', ', $setParts);
// Where
foreach($where as $key => $val){
$whereParts[] = "$key = :w_$key";
}
$whereSql = implode(' AND ', $whereParts);
$query = "UPDATE $table SET $setSql WHERE $whereSql";
try{
$stmt = $db->prepare($query);
foreach($data as $key => $val){
$stmt->bindValue(':' . $key, $val);
}
foreach($where as $key => $val){
$stmt->bindValue(':w_' . $key, $val);
}
return $stmt->execute();
}catch(Exception $e){
error_log($e->getMessage());
return false;
}
}
public static function select($table, $list = [], $where = [], $multiple = false, $assoc = true){
if(!self::assertIdent($table)){
throw new \InvalidArgumentException('Invalid table name');
}
foreach($list as $col){
if(!self::assertIdent($col)){
throw new \InvalidArgumentException('Invalid where name');
}
}
foreach($where as $k => $_){
if(!self::assertIdent($k)){
throw new \InvalidArgumentException('Invalid where key');
}
}
$db = Sys::getConnection();
if(empty($list)){
$query = "SELECT * FROM $table";
}else{
$querySelect = implode(', ', $list);
$query = "SELECT $querySelect FROM $table";
}
if(!empty($where)){
$whereParts = [];
foreach($where as $key => $val){
$whereParts[] = "$key = :w_$key";
}
$whereSql = implode(' AND ', $whereParts);
$query .= " WHERE $whereSql";
}
try{
$stmt = $db->prepare($query);
foreach($where as $key => $val){
$stmt->bindValue(':w_' . $key, $val);
}
if($stmt->execute()){
$mode = $assoc ? PDO::FETCH_ASSOC : PDO::FETCH_COLUMN;
return $multiple ? $stmt->fetchAll($mode) : $stmt->fetch($mode);
}
return false;
}catch(Exception $e){
error_log($e->getMessage());
return false;
}
}
public static function query($sql, $params = [], $multiple = true, $assoc = true){
$db = Sys::getConnection();
try{
$stmt = $db->prepare($sql);
if(!empty($params)){
foreach($params as $key => $val){
$stmt->bindValue(':' . $key, $val);
}
}
if($stmt->execute()){
$mode = $assoc ? PDO::FETCH_ASSOC : PDO::FETCH_COLUMN;
return $multiple ? $stmt->fetchAll($mode) : $stmt->fetch($mode);
}
return false;
}catch(Exception $e){
error_log($e->getMessage());
return false;
}
}
public static function curl($url, $data = [], $headers = []){
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
if(!empty($data)){
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
$headers[] = 'Content-Type: application/json';
}
if(!empty($headers)){
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
try{
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
if($error){
error_log('Curl erro: ' . $error);
return false;
}
return [
'status' => $httpCode,
'body' => json_decode($response, true) ?? $response
];
}catch(Exception $e){
error_log($e->getMessage());
return false;
}
}
public static function assertIdent($s){
return is_string($s) && preg_match('/^[a-zA-Z0-9_]+$/', $s);
}
}

View File

@@ -0,0 +1,539 @@
<?php
namespace Vor\core;
use Exception;
use PDO;
use RangeException;
class Sys
{
// Public
public $userConfigs;
// Protected
protected static $conn = null;
// Methods
/**
* System enviroment handling
*/
public static function start(){
self::loadEnv();
$auth = self::hydrate('v_auth');
if($auth){
$_SERVER['VOR_AUTH'] = $auth;
}
return $auth;
}
public static function getUserConfigs(){
if (file_exists(BASE . '/rss/json/config/groups.json')) {
return json_decode(file_get_contents(BASE . '/rss/json/config/groups.json'), true);
} else {
return [];
}
}
private static function loadEnv(){
$orgPath = $_SERVER['SERVER_ADDR'] == '127.0.0.1' ? '/' : '/../../';
if(file_exists(BASE . $orgPath . '.env')) {
$lines = file(BASE . $orgPath . '.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (isset($lines) && !empty($lines)) {
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) {
continue;
}
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
$value = trim($value, '"\'');
putenv("$name=$value");
}
}
}else{
die('Env was not found at: ' . BASE . $orgPath . '.env');
}
}
/**
* Sys database methods.
* Don't edit this file. If you want to make system changes, extend from this class.
*/
public static function getConnection(){
if (!self::$conn) {
self::createConnection();
}
return self::$conn;
}
private static function createConnection(){
self::loadEnv();
if (self::validateDBEnv()) {
try {
$dbHost = getenv('DB_HOST');
$dbUser = getenv('DB_USER');
$dbPass = getenv('DB_PASS');
$dbName = getenv('DB_NAME');
self::$conn = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4;", $dbUser, $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => true // Remember to validate max connections
]);
self::validateMigration();
} catch (Exception $e) {
error_log($e->getMessage());
return false;
}
} else {
return false;
}
}
private static function validateDBEnv(){
return (getenv('DB_HOST') && getenv('DB_USER') && getenv('DB_PASS') && getenv('DB_NAME'));
}
private static function validateMigration(){
// Validate engine information
try{
self::$conn->exec('CREATE TABLE IF NOT EXISTS engine_info (
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
meta_key VARCHAR(95),
meta_value TEXT
)');
}catch(Exception $e){
// Could not initiate first query
error_log($e->getMessage());
return;
}
// Validate or set engine version
try{
$currentVersion = self::$conn->query('SELECT meta_value FROM engine_info WHERE meta_key = "vor_db_version"')->fetch(PDO::FETCH_COLUMN);
if($currentVersion === false){
self::$conn->exec('INSERT INTO engine_info (meta_key, meta_value) VALUES ("vor_db_version", "0")');
$currentVersion = 0;
}
// Validate latest version
if(file_exists(BASE . '/rss/json/config/migrations.json')){
$migrations = json_decode(file_get_contents(BASE . '/rss/json/config/migrations.json'), true);
$latestVersion = array_key_last($migrations);
if((int)$latestVersion > (int)$currentVersion){
self::migrate();
}
}
}catch(Exception $e){
// First query ran fine but something with the updates messes up
error_log($e->getMessage());
}
}
private static function migrate(){
try{
$currentVersion = (int) self::$conn->query('SELECT meta_value FROM engine_info WHERE meta_key = "vor_db_version"')->fetch(PDO::FETCH_COLUMN) ?? 0;
}catch(Exception $e){
// Could not get version
error_log($e->getMessage());
}
try{
$migrations = json_decode(file_get_contents(BASE . '/rss/json/config/migrations.json'), true);
foreach($migrations as $index => $migration){
if((int)$index > (int)$currentVersion){
foreach($migration as $query){
self::$conn->exec($query);
}
$stmt = self::$conn->prepare("UPDATE engine_info SET meta_value = :index WHERE meta_key = 'vor_db_version'");
$stmt->bindValue(':index', (int)$index);
$stmt->execute();
}
}
}catch(Exception $e){
// Updates failed but DB is fine
error_log($e->getMessage());
}
}
/**
* System utilies
*/
public static function post($key = null){
return $key === null ? $_POST : (isset($_POST[$key]) ? $_POST[$key] : null);
}
public static function get($key = null){
return $key === null ? $_GET : (isset($_GET[$key]) ? $_GET[$key] : null);
}
public static function go($path){
header('Location: ' . $path);
exit();
}
public static function json($data, $code = 200){
header('Content-Type: application/json');
http_response_code($code);
echo json_encode($data);
exit();
}
public static function collect($json = false){
$input = file_get_contents('php://input');
if($json){
$input = json_decode($input, true);
}
return $input ?? [];
}
private static function isHttps(){
return
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') ||
(isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443);
}
public static function session($key, $value = null){
if(session_status() === PHP_SESSION_NONE){
self::validateSession();
}
if($value === null){
return $_SESSION[$key] ?? null;
}
$_SESSION[$key] = $value;
}
public static function hydrate($cookieName = 'v_auth'){
$token = $_COOKIE[$cookieName] ?? false;
if(!$token){
return false;
}
$parts = explode('.', $token, 2);
if(count($parts) !== 2){
return false;
}
$payloadB64 = str_replace(['-', '_'], ['+', '/'], $parts[0]);
$macB64 = str_replace(['-', '_'], ['+', '/'], $parts[1]);
$payload = base64_decode($payloadB64, true);
$mac = base64_decode($macB64, true);
if(!$payload || !$mac){
return false;
}
$key = getenv('TOKEN_SECRET') ?? false;
if(!$key){
return false;
}
$expectedMac = hash_hmac('sha256', $payload, $key, true);
if(!hash_equals($expectedMac, $mac)){
return false;
}
$data = json_decode($payload, true);
if(!is_array($data)){
return false;
}
$uid = isset($data['uid']) ? (int)$data['uid'] : 0;
$exp = isset($data['exp']) ? (int)$data['exp'] : 0;
$sid = isset($data['sid']) ? $data['sid'] : false;
if($uid <= 0 || $exp <= 0 || !$sid){
return false;
}
if($exp < time()){
return false;
}
self::validateSession($sid);
return $data;
}
public static function validateSession($sid = null){
if(session_status() !== PHP_SESSION_NONE){
return true;
}
ini_set('session.use_cookies', 0);
ini_set('session.use_only_cookies', 0);
if($sid){
session_id($sid);
}
return session_start();
}
public static function cookieSet($name, $val, $opts = []){
$isHttps = self::isHttps();
if(is_array($val)){
$key = getenv('TOKEN_SECRET') ?? false;
if(!$key){
return false;
}
$payload = json_encode($val, JSON_UNESCAPED_SLASHES);
$mac = hash_hmac('sha256', $payload, $key, true);
$safePayload = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($payload));
$safeMac = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($mac));
$val = $safePayload . '.' . $safeMac;
}
$defaults = [
'expires' => 0,
'path' => '/',
'domain' => '',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax'
];
$opts = array_intersect_key((array)$opts, array_flip(['expires', 'path', 'domain', 'secure', 'httponly', 'samesite']));
$final = array_merge($defaults, $opts);
if(strcasecmp($final['samesite'], 'None') === 0){
$final['secure'] = true;
}
$success = setcookie($name, (string)$val, $final);
if($success){
$_COOKIE[$name] = (string)$val;
}
return $success;
}
public static function cookieClear($name = 0){
$isHttps = self::isHttps();
$clear = function($cookie) use ($isHttps){
setcookie($cookie, '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax'
]);
unset($_COOKIE[$cookie]);
};
if(!$name){
foreach($_COOKIE as $cookie => $v){
$clear($cookie);
}
return true;
}
$clear($name);
return true;
}
public static function upload($preset, $file){
Sys::loadEnv();
if(!isset($file['error']) || is_array($file['error'])){
return ['ok' => false, 'error' => 'Invalid upload'];
}
if($file['error'] !== UPLOAD_ERR_OK){
return ['ok' => false, 'error' => 'Upload failed'];
}
$dir = getenv("UPLOAD_{$preset}_DIR");
$allowed = getenv("UPLOAD_{$preset}_ALLOWED");
$maxMb = getenv("UPLOAD_{$preset}_MAX_MB");
if(!$dir || !$allowed){
return ['ok' => false, 'error' => 'Upload preset missing'];
}
$allowedExt = array_map('trim', explode(',', strtolower($allowed)));
$maxBytes = (int)$maxMb * 1024 * 1024;
if($maxBytes > 0 && $file['size'] > $maxBytes){
return ['ok' => false, 'error' => 'File too large'];
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if(!in_array($ext, $allowedExt)){
return ['ok' => false, 'error' => 'Invalid file type'];
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if(!$mime){
return ['ok' => false, 'error' => 'Invalid file'];
}
$absDir = rtrim(BASE, '/') . '/' . ltrim($dir, '/');
if(!is_dir($absDir)){
mkdir($absDir, 0755, true);
}
$name = bin2hex(random_bytes(32)) . '.' . $ext;
$dest = rtrim($absDir, '/') . '/' . $name;
if(!move_uploaded_file($file['tmp_name'], $dest)){
return ['ok' => false, 'error' => 'Move failed'];
}
return[
'ok' => true,
'path' => $dir . '/' . $name,
'filename' => $name,
'ext' => $ext,
'mime' => $mime,
'size' => $file['size']
];
}
public static function serveFile($absPath, $opts = []){
$defaults = [
'filename' => null,
'mime' => null,
'inline' => true,
'cacheSeconds' => 86400,
'etag' => true,
'allowRange' => true,
'baseDirs' => [realpath(BASE)],
];
$o = array_merge($defaults, (array)$opts);
$real = realpath($absPath);
if($real === false || !is_file($real) || !is_readable($real)){
http_response_code(404);
exit;
}
$allowed = false;
foreach((array)$o['baseDirs'] as $base){
$baseReal = realpath($base);
if($baseReal && str_starts_with($real, rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)){
$allowed = true;
break;
}
}
if(!$allowed){
http_response_code(404);
exit;
}
$mime = $o['mime'];
if(!$mime){
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($real) ?: 'application/octet-stream';
}
$size = filesize($real);
$mtime = filemtime($real);
if($o['etag']){
$etag = '"' . sha1($real . '|' . $size . '|' . $mtime) . '"';
header('ETag: ' . $etag);
if(isset($_SERVER['HTTP_IF_NONE_MATCH']) &&trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag){
http_response_code(304);
exit;
}
}
if((int)$o['cacheSeconds'] > 0){
header('Cache-Control: public, max-age=' . (int)$o['cacheSeconds']);
}else{
header('Cache-Control: no-store');
}
header('X-Content-Type-Options: nosniff');
header('Content-Type: ' . $mime);
$downloadName = $o['filename'] ?: basename($real);
$downloadName = str_replace(["\r","\n"], '', $downloadName);
$disp = $o['inline'] ? 'inline' : 'attachment';
header('Content-Disposition: ' . $disp . '; filename="' . addslashes($downloadName) . '"');
header('Accept-Ranges: bytes');
$start = 0;
$end = $size - 1;
$status = 200;
if($o['allowRange'] && isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)){
$rangeStart = $m[1] !== '' ? (int)$m[1] : null;
$rangeEnd = $m[2] !== '' ? (int)$m[2] : null;
if($rangeStart === null && $rangeEnd !== null){
$start = max(0, $size - $rangeEnd);
}else{
$start = max(0, (int)$rangeStart);
if($rangeEnd !== null){
$end = min($end, (int)$rangeEnd);
}
}
if($start > $end || $start >= $size){
header('Content-Range: bytes */' . $size);
http_response_code(416);
exit;
}
$status = 206;
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = ($end - $start) + 1;
header('Content-Length: ' . $length);
http_response_code($status);
$fp = fopen($real, 'rb');
if($fp === false){
http_response_code(500);
exit();
}
if($start > 0){
fseek($fp, $start);
}
$chunk = 8192;
while(!feof($fp) && $length > 0){
$read = ($length > $chunk) ? $chunk : $length;
$buf = fread($fp, $read);
if($buf === false){
break;
}
echo $buf;
$length -= strlen($buf);
if(function_exists('fastcgi_finish_request') === false){
flush();
}
}
fclose($fp);
exit;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Vor\core;
class Validator{
public static function int($val) : bool{
return filter_var($val, FILTER_VALIDATE_INT) !== false;
}
public static function float($val) : bool{
return filter_var($val, FILTER_VALIDATE_FLOAT) !== false;
}
public static function string($val, int $minLen = 1): bool{
if(!is_string($val)){
return false;
}
return mb_strlen(trim($val)) >= $minLen;
}
public static function date($date, string $format = 'Y-m-d'): bool {
try{
$d = \DateTime::createFromFormat($format, (string)$date);
return $d && $d->format($format) === (string)$date;
}catch(\Throwable $e){
error_log($e->getMessage());
return false;
}
}
public static function json($val): bool{
if(!is_string($val) || $val === ''){
return false;
}
try{
json_decode($val, true, 512, JSON_THROW_ON_ERROR);
return true;
}catch(\JsonException $e){
error_log($e->getMessage());
return false;
}
}
public static function email($val): bool{
$val = trim((string)$val);
if(preg_match("/[\r\n]/", $val)){
return false;
}
return (bool) filter_var($val, FILTER_VALIDATE_EMAIL);
}
public static function domain($val): bool{
$val = trim((string)$val);
if(preg_match("/[\r\n]/", $val)){
return false;
}
return (bool) filter_var($val, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
}
public static function minLen($val, int $min): bool{
return mb_strlen(trim((string)$val)) >= $min;
}
public static function maxLen($val, int $max): bool{
return mb_strlen(trim((string)$val)) <= $max;
}
public static function regex($val, string $pattern): bool{
return (bool) preg_match($pattern, (string)$val);
}
}

56
rss/php/handler.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
define('BASE', realpath(dirname(__FILE__) . '/../../'));
require_once(BASE . '/rss/php/autoload.php');
use Vor\core\Sys;
Sys::start();
$classMap = [
// Add public allowed classes
'Frontend' => \Vor\application\Frontend::class,
];
$methodMap = [
// Add public allowed methods
'Frontend' => ['clean'],
];
$target = trim((string)($_POST['target'] ?? ''));
$method = trim((string)($_POST['method'] ?? ''));
if($target === '' || $method === ''){
Sys::json(['status' => 'fail', 'message' => 'Missing target'], 400);
}
if(!isset($classMap[$target])){
Sys::json(['status' => 'fail', 'message' => 'Invalid class'], 404);
}
if(!in_array($method, $methodMap[$target] ?? [], true)){
Sys::json(['status' => 'fail', 'message' => 'Invalid method'], 404);
}
$obj = new $classMap[$target];
foreach($_POST as $k => $v){
if($k === 'target' || $k === 'method'){
continue;
}
if(property_exists($obj, $k)){
$obj->$k = $v;
}
}
foreach(($_FILES ?? []) as $k => $v){
if(property_exists($obj, $k)){
$obj->$k = $v;
}
}
try{
$resp = $obj->$method();
Sys::json(['status' => $resp ? 'success' : 'fail', 'message' => $resp], 200);
}catch(\Throwable $e){
error_log($e->getMessage());
Sys::json(['status' => 'fail', 'message' => 'Server error'], 500);
}

148
rss/php/pagehandler.php Normal file
View File

@@ -0,0 +1,148 @@
<?php
require_once($_SERVER['DOCUMENT_ROOT'] . '/rss/php/autoload.php');
use Vor\core\Sys;
use Vor\application\Users;
// Set up class map
$classList = array(
'Sys' => Sys::class,
'Users' => Users::class
);
$page = getPage();
$res = array();
$userConfigs = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/rss/json/config/groups.json'), true);
if(isset($page['init']) && !empty($page['init'])){
foreach($page['init'] as $func){
$res[$func['return']] = initCaller($func);
}
}
// Create init function based on json files
if($page){
if(!isset($_GET['page'])){
$usr = new Users();
$sys = new Sys();
$myInfo = $usr->getMyself();
if($usr->isAuth()){
header('location: /'. $sys::getUserConfigs()[$myInfo['user_type']]['home'].'/');
}
}
}
function getPage(){
$sys = new Sys();
$user = new Users();
global $myInfo;
if(isset($_GET['page']) && !empty($_GET['page'])){
if($conf = pages($_GET['page'])){
if(validatePage($conf)){
return $conf;
}else{
$usr = new Users();
if($usr->isAuth()){
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/'.$sys::getUserConfigs()[$myInfo['user_type']]['home'].'.json')){
header('location: /'.$sys::getUserConfigs()[$myInfo['user_type']]['home'].'/');
}else{
header('location: /');
}
}else{
header('location: /');
}
}
}else{
header('location: /404/');
}
}else{
return pages('index');
}
}
function pages($p){
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/' . $p . '.json')){
return(json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/rss/json/pages/' . $p . '.json'), true));
}else{
return false;
}
}
function validatePage($c){
if($c['rules']['restricted'] === true){
if($c['rules']['loginCookie'] == true){
$usr = new Users();
if($usr->isAuth()){
$ui = $usr->getMyself();
$allowed_user_types = $c['rules']['userTypes'];
$allowed_user_groups = $c['rules']['userGroups'];
if(in_array($ui['user_type'], $allowed_user_groups) || in_array('*', $allowed_user_groups)){
return true;
}else{
return false;
}
}else{
return false;
}
}else{
return true;
}
}else{
return true;
}
}
function loadModals(){
global $page;
global $res;
if(isset($page['modals']) && !empty($page['modals'])){
foreach($page['modals'] as $modal){
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/php/models/components/modals/' . $modal . '.php')){
include($_SERVER['DOCUMENT_ROOT'] . '/rss/php/models/components/modals/' . $modal . '.php');
}
}
}
}
function getCanonical(){
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
$host = $_SERVER['HTTP_HOST'];
$requestUri = $_SERVER['REQUEST_URI'];
$canonicalUrl = $scheme . "://" . $host . $requestUri;
return $canonicalUrl;
}
function loadScripts(){
if(isset($_GET['page']) && !empty($_GET['page'])){
$page_name = $_GET['page'];
}else{
$page_name = 'index';
}
if(file_exists($_SERVER['DOCUMENT_ROOT'] . '/rss/js/' . $page_name . '.js')){
echo '<script src="/rss/js/'.$page_name.'.js"></script>';
}
}
function initCaller($func){
global $classList;
$class = $func['class'];
$method = $func['function'];
$cl = new $classList[$class];
// Set get parameters
$run = true;
if(isset($func['getValues']) && !empty($func['getValues'])){
$run = false;
foreach($func['getValues'] as $get){
if(isset($_GET[$get]) && !empty($_GET[$get])){
$run = true;
$cl->$get = $_GET[$get];
}
}
}
if($run){
$rs = $cl->$method();
return $rs;
}
}

View File

@@ -0,0 +1,6 @@
<script src="/rss/js/main.js"></script>
<?php foreach($render['scripts'] as $s):?>
<script src="<?= $s['src'] ?>" <?= $s['type'] == 'module' ? 'type="module"' : '' ?>></script>
<?php endforeach;?>
</body>
</html>

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VOR CRM UI Kit Showcase (Full)</title>
<link rel="stylesheet" href="/rss/css/main.css" />
<link rel="stylesheet" href="/rss/css/template.css" />
</head>

View File

@@ -0,0 +1,592 @@
<?php include(BASE . '/rss/php/views/components/frame.php');?>
<div class="p-2">
<div class="page">
<section class="card hero-pro mb-4">
<div class="hero-grid">
<div>
<div class="kicker">
<span class="pill">MDI 2026-ish</span>
<span class="small text-muted">tokens · utilities · components</span>
</div>
<h1 class="h3 mt-3 mb-0">Dashboard overview</h1>
<p class="body text-muted mt-2 mb-0">
This page stress-tests: grid, spacing, sizing, typography, tables, buttons, forms,
badges, scrollbar, cards, accordion, switch, drawer, modal, and theme tokens.
</p>
<div class="chip-row mt-3">
<div class="chip is-active" data-chip>Last 7 days</div>
<div class="chip" data-chip>Last 30 days</div>
<div class="chip" data-chip>This year</div>
<div class="chip" data-chip>Custom</div>
</div>
<div class="d-flex align-center gap-2 mt-3">
<span class="badge badge-gray-light">EU region</span>
<span class="badge badge-teal badge-solid">Online</span>
<span class="badge badge-red-light text-red-900">Alerts 2</span>
<span class="small text-muted">Updated Feb 16, 2026</span>
</div>
<div class="mt-3">
<div class="small text-muted">Inline code token</div>
<div class="body">
Try: <code>btn-gray-light btn-sm</code>, <code>col-lg-4</code>, <code>data-toggle</code>, <code>data-theme-toggle</code>.
</div>
</div>
</div>
<div class="card card-quick">
<div class="small text-muted">Quick actions</div>
<div class="d-flex flex-column gap-2 mt-2">
<button class="btn-teal">Create invoice</button>
<button class="btn-blue-light" data-toggle="#modalExample">Edit customer</button>
<button class="btn-gray-light">Log activity</button>
</div>
<hr class="hr">
<div class="small text-muted">System</div>
<div class="d-flex align-center gap-2 mt-2">
<label class="switch">
<input type="checkbox" checked />
<span class="slider"></span>
</label>
<span class="body m-0">Realtime sync</span>
</div>
<div class="mt-3">
<div class="small text-muted">Open modal / drawer</div>
<div class="d-flex gap-2 mt-2">
<button class="btn-gray-light btn-sm" data-toggle="#modalExample">Modal</button>
<button class="btn-gray-light btn-sm" data-toggle="#mobileDrawer">Drawer</button>
</div>
</div>
</div>
</div>
</section>
<div class="row mb-4">
<div class="col-xs-12 col-lg-6">
<div class="alert-teal">alert-teal — Success style container.</div>
</div>
<div class="col-xs-12 col-lg-6">
<div class="alert-red">alert-red — Danger style container.</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xs-12 col-md-4">
<div class="card hoverable mb-3 card-quick">
<div class="stat-title">Total revenue</div>
<div class="stat-value">€ 142,500</div>
<div class="stat-sub"><span class="text-teal-600">+12%</span> vs last month</div>
</div>
</div>
<div class="col-xs-12 col-md-4">
<div class="card hoverable mb-3">
<div class="stat-title">Active users</div>
<div class="stat-value">1,204</div>
<div class="stat-sub">
<span class="badge badge-gray-light">Stable</span>
<span class="small text-muted"> churn 1.1%</span>
</div>
</div>
</div>
<div class="col-xs-12 col-md-4">
<div class="card hoverable mb-3 card-quick">
<div class="stat-title">System status</div>
<div class="d-flex align-center gap-2 mt-2">
<span class="badge badge-teal badge-solid">Healthy</span>
<span class="small text-muted">API 124ms · DB 8ms</span>
</div>
<div class="small text-muted mt-2">Last checked: Feb 16, 2026</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xs-12 col-lg-6">
<div class="card mb-3">
<h4 class="h4 m-0">Forms</h4>
<div class="small text-muted mt-1">input, select, input-group, focus ring</div>
<div class="form-group mt-3">
<label class="label">Email</label>
<input class="input" type="email" placeholder="name@domain.com" />
</div>
<div class="form-group">
<label class="label">Status</label>
<select class="select">
<option>Active</option>
<option>Pending</option>
<option>Paused</option>
</select>
</div>
<div class="form-group">
<label class="label">Search (input-group)</label>
<div class="input-group">
<input class="input" type="text" placeholder="Search..." />
<button class="btn-teal">Go</button>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn-teal">Primary</button>
<button class="btn-gray-light">Secondary</button>
<button class="btn-gray-light btn-sm">Small</button>
<button class="btn-teal btn-lg">Large</button>
</div>
</div>
</div>
<div class="col-xs-12 col-lg-6">
<div class="card mb-3 card-quick">
<h4 class="h4 m-0">Typography & Utilities</h4>
<div class="small text-muted mt-1">sizes, weights, alignment, spacing helpers</div>
<div class="mt-3">
<div class="h1 m-0">h1</div>
<div class="h2 m-0">h2</div>
<div class="h3 m-0">h3</div>
<div class="h4 m-0">h4</div>
<div class="h5 m-0">h5</div>
<div class="h6 m-0">h6</div>
<div class="body mt-2">body text — <span class="text-muted">muted</span> — <span class="text-light">light</span></div>
<div class="small text-muted mt-1">small text</div>
</div>
<hr class="hr">
<div class="d-flex justify-between align-center">
<div class="small text-muted">Badges</div>
<div class="d-flex gap-2">
<span class="badge badge-teal">badge-teal</span>
<span class="badge badge-teal badge-solid">solid</span>
<span class="badge badge-orange-light text-orange-900">warn</span>
<span class="badge badge-red-light text-red-900">danger</span>
</div>
</div>
<div class="mt-3">
<div class="small text-muted mb-2">Spacing / size quick grid</div>
<div class="d-flex gap-2">
<div class="bg-surface border rounded p-2 w-25 text-center small">w-25</div>
<div class="bg-surface border rounded p-2 w-33 text-center small">w-33</div>
<div class="bg-surface border rounded p-2 w-50 text-center small">w-50</div>
</div>
<div class="d-flex gap-2 mt-2">
<div class="bg-surface border rounded p-2 w-66 text-center small">w-66</div>
<div class="bg-surface border rounded p-2 w-33 text-center small">w-33</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xs-12 col-lg-8">
<div class="card table-card mb-3">
<div class="p-3 border-bottom d-flex justify-between align-center">
<h5 class="h5 m-0">Recent transactions</h5>
<div class="d-flex align-center gap-2">
<button class="btn-blue-light btn-sm" id="tableEditAllBtn">Edit mode</button>
<button class="btn-gray-light btn-sm">Export</button>
<button class="btn-gray-light btn-sm">View all</button>
</div>
</div>
<div class="table-wrap">
<div id="transactionsTable"></div>
</div>
<div class="p-3 border-top">
<div class="small text-muted">Small variant</div>
<div class="table-wrap mt-2">
<table class="table table-sm striped hover">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Meta</th>
</tr>
</thead>
<tbody>
<tr>
<td>Theme</td>
<td>data-theme</td>
<td><code>light / dark</code></td>
</tr>
<tr>
<td>Drawer</td>
<td>.drawer</td>
<td><code>is-open</code></td>
</tr>
<tr>
<td>Modal</td>
<td>.modal-overlay</td>
<td><code>is-active</code></td>
</tr>
<tr>
<td>Table edit</td>
<td>.table-edit-btn</td>
<td><code>welcome.js</code></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-xs-12 col-lg-4">
<div class="card mb-3">
<h5 class="h5 m-0">Pipeline</h5>
<div class="small text-muted mt-1">progress bars + buttons</div>
<div class="mt-3">
<div class="d-flex justify-between">
<span class="small text-muted">Qualified</span>
<span class="small">18</span>
</div>
<div class="progress mt-1">
<div class="progress-bar progress-72"></div>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-between">
<span class="small text-muted">Proposal</span>
<span class="small">9</span>
</div>
<div class="progress mt-1">
<div class="progress-bar progress-46 soft"></div>
</div>
</div>
<div class="mt-3">
<div class="d-flex justify-between">
<span class="small text-muted">Negotiation</span>
<span class="small">4</span>
</div>
<div class="progress mt-1">
<div class="progress-bar progress-25 warn"></div>
</div>
</div>
<div class="d-flex align-center gap-2 mt-4">
<button class="btn-teal btn-sm">Add deal</button>
<button class="btn-gray-light btn-sm">View</button>
<button class="btn-blue-light btn-sm" data-toggle="#modalExample">Edit</button>
<button class="btn-gray-light btn-sm" data-toggle="#modalExample">Open modal</button>
</div>
</div>
<div class="card mb-3">
<h5 class="h5 m-0">Borders / radius / elevation</h5>
<div class="small text-muted mt-1">border helpers + rounded + elevation</div>
<div class="row mt-3 g-0">
<div class="col-xs-6">
<div class="bg-surface border rounded p-3 elevation-1">
<div class="small text-muted">elevation-1</div>
</div>
</div>
<div class="col-xs-6">
<div class="bg-surface border rounded-lg p-3 elevation-4">
<div class="small text-muted">elevation-4</div>
</div>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<div class="bg-surface border rounded-sm p-2 small">rounded-sm</div>
<div class="bg-surface border rounded p-2 small">rounded</div>
<div class="bg-surface border rounded-lg p-2 small">rounded-lg</div>
<div class="bg-surface border rounded-pill p-2 small">pill</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xs-12 col-lg-8">
<div class="card">
<h4 class="h4 m-0">Framework FAQ</h4>
<div class="small text-muted mt-1">accordion + utilities + theme tokens</div>
<div class="accordion-item mt-3">
<button class="accordion-header" data-accordion>
<span>How does the grid work?</span>
<span class="accordion-icon">▼</span>
</button>
<div class="accordion-body">
Negative margins + column padding keeps gutters consistent and safe for nesting.
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" data-accordion>
<span>Does ESC close modals?</span>
<span class="accordion-icon">▼</span>
</button>
<div class="accordion-body">
Yes — with your VOR event delegation, ESC closes modal first, then drawer.
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" data-accordion>
<span>Theme tokens?</span>
<span class="accordion-icon">▼</span>
</button>
<div class="accordion-body">
Toggle <code>data-theme</code> on <code>&lt;html&gt;</code>. CSS vars update automatically.
</div>
</div>
</div>
</div>
<div class="col-xs-12 col-lg-4">
<div class="card">
<h5 class="h5 m-0">Notes</h5>
<div class="small text-muted mt-1">quick sanity checks</div>
<div class="mt-3">
<div class="badge badge-teal">badge-teal</div>
<div class="badge badge-gray-light mt-2">badge-gray-light</div>
<div class="badge badge-orange-light text-orange-900 mt-2">badge-orange-light + text-orange-900</div>
</div>
<div class="mt-4">
<button class="btn-gray-light w-100">btn-gray-light</button>
<button class="btn-gray-light btn-sm w-100 mt-2">btn-gray-light btn-sm</button>
<button class="btn-teal w-100 mt-2">btn-teal</button>
</div>
<div class="mt-4">
<div class="small text-muted mb-2">Visibility / overflow</div>
<div class="bg-surface border rounded p-2 overflow-auto" style="max-height: 90px;">
<div class="small">overflow-auto box</div>
<div class="small text-muted">Line 1</div>
<div class="small text-muted">Line 2</div>
<div class="small text-muted">Line 3</div>
<div class="small text-muted">Line 4</div>
<div class="small text-muted">Line 5</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<div id="modalExample" class="modal-overlay" aria-hidden="true">
<div class="modal-content" role="dialog" aria-modal="true" aria-label="Create or edit entry">
<div class="p-4 border-bottom d-flex justify-between align-center">
<div>
<h5 class="h5 m-0" id="modalTitle">Create new project</h5>
<div class="small text-muted mt-1" id="modalSubTitle">Quick add / edit flow</div>
</div>
<button class="btn-close" data-toggle="#modalExample" aria-label="Close">×</button>
</div>
<div class="p-4">
<div class="alert-blue mb-3">alert-blue — Info message inside modal.</div>
<div class="form-group">
<label class="label">Client</label>
<input type="text" class="input" id="modalClient" placeholder="Enter client..." />
</div>
<div class="form-group">
<label class="label">Status</label>
<select class="select" id="modalStatus">
<option>Paid</option>
<option>Pending</option>
<option>Processing</option>
<option>Overdue</option>
</select>
</div>
<div class="form-group">
<label class="label">Amount</label>
<input type="text" class="input" id="modalAmount" value="" />
</div>
<div class="form-group">
<label class="label">Date</label>
<input type="text" class="input" id="modalDate" placeholder="2026-02-16" />
<div class="small text-muted mt-1">Used by the table edit action and quick-add flow.</div>
</div>
</div>
<div class="p-3 bg-surface border-top text-right">
<button class="btn-gray-light mr-2" data-toggle="#modalExample">Cancel</button>
<button class="btn-teal">Save</button>
</div>
</div>
</div>
<div id="mobileDrawer" class="drawer" aria-hidden="true">
<div class="p-4">
<div class="d-flex justify-between align-center">
<h3 class="h3 m-0">VOR Mobile</h3>
<button class="btn-close" data-toggle="#mobileDrawer" aria-label="Close">×</button>
</div>
<hr class="hr" />
<nav class="d-flex flex-column gap-2">
<a href="#" class="nav-item is-active">Dashboard</a>
<a href="#" class="nav-item">Customers</a>
<a href="#" class="nav-item">Invoices</a>
<a href="#" class="nav-item">Settings</a>
<div class="mt-3">
<div class="small text-muted mb-2">Actions</div>
<button class="btn-teal w-100 mb-2" data-theme-toggle>Toggle theme</button>
<button class="btn-blue-light w-100 mb-2" data-toggle="#modalExample">Edit item</button>
<button class="btn-teal w-100 mb-2" data-toggle="#modalExample">Open modal</button>
<button class="btn-gray-light w-100" data-toggle="#mobileDrawer">Close menu</button>
</div>
</nav>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
VOR.uiTable({
el: "#transactionsTable",
classArr: ["table", "striped", "hover"],
data: [{
client: "Acme Corp",
status: "Paid",
amount: "€ 1,200.00",
date: "Feb 14, 2026"
},
{
client: "Globex Inc",
status: "Pending",
amount: "€ 850.00",
date: "Feb 15, 2026"
},
{
client: "Initech",
status: "Processing",
amount: "€ 2,450.00",
date: "Feb 16, 2026"
},
{
client: "Umbrella",
status: "Overdue",
amount: "€ 640.00",
date: "Feb 10, 2026"
}
],
headers: [{
key: "client",
label: "Client"
},
{
key: "status",
label: "Status"
},
{
key: "amount",
label: "Amount"
},
{
key: "date",
label: "Date"
},
{
key: "edit",
label: "Edit",
sortable: false,
action: "edit-expand",
format: () => "Edit"
}
],
editRenderer: (row) => {
const wrap = VOR.createEl("div", ["p-4", "bg-surface"]);
const title = VOR.createEl("div", ["h6", "m-0", "mb-3"], `Edit ${row.client}`);
const clientGroup = VOR.createEl("div", ["form-group"]);
clientGroup.append(
VOR.createEl("label", ["label"], "Client"),
VOR.createEl("input", ["input"], null, {
type: "text",
value: row.client
})
);
const statusGroup = VOR.createEl("div", ["form-group"]);
const statusSelect = VOR.createEl("select", ["select"]);
["Paid", "Pending", "Processing", "Overdue"].forEach((status) => {
const option = VOR.createEl("option", null, status, {
value: status
});
if (status === row.status) option.selected = true;
statusSelect.append(option);
});
statusGroup.append(
VOR.createEl("label", ["label"], "Status"),
statusSelect
);
const amountGroup = VOR.createEl("div", ["form-group"]);
amountGroup.append(
VOR.createEl("label", ["label"], "Amount"),
VOR.createEl("input", ["input"], null, {
type: "text",
value: row.amount
})
);
const dateGroup = VOR.createEl("div", ["form-group"]);
dateGroup.append(
VOR.createEl("label", ["label"], "Date"),
VOR.createEl("input", ["input"], null, {
type: "text",
value: row.date
})
);
const actions = VOR.createEl("div", ["d-flex", "gap-2", "mt-3"]);
const saveBtn = VOR.createEl("button", ["btn-teal"], "Save", {
type: "button"
});
const closeBtn = VOR.createEl("button", ["btn-gray-light"], "Close", {
type: "button"
});
closeBtn.addEventListener("click", () => {
const editorRow = wrap.closest(".row-editor");
editorRow?.remove();
});
saveBtn.addEventListener("click", () => {
VOR.uiAlert(`Saved ${row.client}`, "success");
});
actions.append(saveBtn, closeBtn);
wrap.append(title, clientGroup, statusGroup, amountGroup, dateGroup, actions);
return wrap;
}
});
});
</script>