Initial commit
This commit is contained in:
8
.env_example
Normal file
8
.env_example
Normal file
@@ -0,0 +1,8 @@
|
||||
DB_HOST=
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
DB_NAME=
|
||||
TOKEN_SECRET=
|
||||
UPLOAD_{preset}_DIR=
|
||||
UPLOAD_{preset}_ALLOWED=
|
||||
UPLOAD_{preset}_MAX_MB=
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
53
.htaccess
Normal file
53
.htaccess
Normal file
@@ -0,0 +1,53 @@
|
||||
# ==============================================================================
|
||||
# CORPINTECH MASTER FRAMEWORK HTACCESS
|
||||
# ==============================================================================
|
||||
|
||||
# 1. CORE ENGINE
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# 2. FAST-TRACK STATIC ASSETS (Crucial for your 0.6s JS fix)
|
||||
# This ensures images, css, and js are served instantly without PHP intervention
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# 3. GLOBAL ROUTING (Off-branch Logic)
|
||||
# Redirect /page to /page/ for SEO consistency
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_URI} !(\.[a-zA-Z0-9]{1,5}|/)$
|
||||
RewriteRule ^(.*)$ $1/ [R=301,L]
|
||||
|
||||
# 4. API & SYSTEM REWRITES (Off-branch Logic)
|
||||
# Maps tracking requests and API calls
|
||||
RewriteRule ^api/(.*)$ sys/api/index.php [L,QSA]
|
||||
RewriteRule ^gnotr/(.*)$ resource/tracking.js [L,QSA]
|
||||
|
||||
# 5. DYNAMIC PAGE ROUTING (The Merge)
|
||||
# Property/Product IDs (CRM focus)
|
||||
RewriteRule ^property/([^/]+)/$ index.php?page=property&prop_id=$1 [L,QSA]
|
||||
|
||||
# Standard Pages
|
||||
RewriteRule ^([^/]+)/$ index.php?page=$1 [L,QSA]
|
||||
|
||||
# 6. ERROR HANDLING
|
||||
ErrorDocument 404 /index.php?page=404
|
||||
|
||||
# 7. PERFORMANCE & SECURITY (Main Branch Logic)
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresDefault "access plus 1 month"
|
||||
ExpiresByType image/x-icon "access plus 1 year"
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
Header set X-Frame-Options "DENY"
|
||||
</IfModule>
|
||||
|
||||
# Prevent directory listing
|
||||
Options -Indexes
|
||||
168
build/workflows/deploy.yml
Normal file
168
build/workflows/deploy.yml
Normal file
@@ -0,0 +1,168 @@
|
||||
name: Deploy (yggdrasil)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: [ mainhost ]
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_KEY_PATH: /home/gitea-runner/.ssh/id_ed25519
|
||||
|
||||
SSH_OPTS: >-
|
||||
-F /dev/null
|
||||
-o IdentitiesOnly=yes
|
||||
-o IdentityAgent=none
|
||||
-o PreferredAuthentications=publickey
|
||||
-o PubkeyAuthentication=yes
|
||||
-o PasswordAuthentication=no
|
||||
-o NumberOfPasswordPrompts=0
|
||||
-o BatchMode=yes
|
||||
-o ServerAliveInterval=15
|
||||
-o ServerAliveCountMax=3
|
||||
-o ConnectTimeout=20
|
||||
-o StrictHostKeyChecking=no
|
||||
|
||||
APP_ROOT: /var/www/yggdrasil
|
||||
KEEP_N: "5"
|
||||
SHARED_DIRS: "uploads:cache"
|
||||
HEALTH_URL: "https://yggdrasil.corpintech.net/"
|
||||
SERVICE_NAME: "apache2"
|
||||
|
||||
steps:
|
||||
- name: Checkout (pure git, private repo)
|
||||
env:
|
||||
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
git init -b main
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
git -c http.extraHeader="Authorization: token ${GIT_TOKEN}" \
|
||||
fetch --no-tags --depth=1 origin "${GITHUB_SHA}"
|
||||
|
||||
git checkout -q "${GITHUB_SHA}"
|
||||
|
||||
- name: SSH smoke test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "${SSH_USER}@${SSH_HOST}" true
|
||||
|
||||
- name: Deploy atomically
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
REL="$(date -u +%Y%m%d-%H%M%SZ)-${GITHUB_SHA}"
|
||||
TAR_LOCAL="release/${REL}.tar.gz"
|
||||
|
||||
mkdir -p release
|
||||
|
||||
IFS=':' read -r -a SHARED_DIR_ARR <<< "${SHARED_DIRS}"
|
||||
|
||||
EXCLUDES=(--exclude-vcs --exclude='./node_modules' --exclude='./release')
|
||||
for d in "${SHARED_DIR_ARR[@]}"; do
|
||||
EXCLUDES+=( "--exclude=./${d}" )
|
||||
done
|
||||
|
||||
tar -czf "${TAR_LOCAL}" "${EXCLUDES[@]}" .
|
||||
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "${SSH_USER}@${SSH_HOST}" \
|
||||
"set -e;
|
||||
install -d -m 2755 '${APP_ROOT}' '${APP_ROOT}/releases' '${APP_ROOT}/shared' '${APP_ROOT}/logs';"
|
||||
|
||||
for d in "${SHARED_DIR_ARR[@]}"; do
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "${SSH_USER}@${SSH_HOST}" \
|
||||
"install -d -m 2755 '${APP_ROOT}/shared/${d}'"
|
||||
done
|
||||
|
||||
scp -O $SSH_OPTS -i "$SSH_KEY_PATH" "${TAR_LOCAL}" "${SSH_USER}@${SSH_HOST}:/tmp/${REL}.tar.gz"
|
||||
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "${SSH_USER}@${SSH_HOST}" bash -s -- \
|
||||
"${APP_ROOT}" \
|
||||
"${REL}" \
|
||||
"${KEEP_N}" \
|
||||
"${SHARED_DIRS}" \
|
||||
"${HEALTH_URL}" \
|
||||
"${SERVICE_NAME}" \
|
||||
"${GITHUB_SHA}" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
APP_ROOT="$1"
|
||||
REL="$2"
|
||||
KEEP_N="$3"
|
||||
SHARED_DIRS="$4"
|
||||
HEALTH_URL="$5"
|
||||
SERVICE_NAME="$6"
|
||||
GITSHA="$7"
|
||||
|
||||
IFS=':' read -r -a SHARED_DIR_ARR <<< "${SHARED_DIRS}"
|
||||
|
||||
RELEASES="${APP_ROOT}/releases"
|
||||
SHARED="${APP_ROOT}/shared"
|
||||
CUR="${APP_ROOT}/current"
|
||||
NEW="${RELEASES}/${REL}"
|
||||
TAR="/tmp/${REL}.tar.gz"
|
||||
|
||||
echo "--> Extracting ${REL}"
|
||||
mkdir -p "${NEW}"
|
||||
tar -xzf "${TAR}" -C "${NEW}"
|
||||
rm -f "${TAR}"
|
||||
|
||||
echo "--> Linking shared dirs"
|
||||
for d in "${SHARED_DIR_ARR[@]}"; do
|
||||
echo " ${d}"
|
||||
rm -rf "${NEW:?}/${d}"
|
||||
ln -s "${SHARED}/${d}" "${NEW}/${d}"
|
||||
done
|
||||
|
||||
if [ -f "${SHARED}/.env" ]; then
|
||||
ln -sf "${SHARED}/.env" "${NEW}/.env"
|
||||
fi
|
||||
|
||||
printf "sha=%s\nbuilt_at=%s\n" "${GITSHA}" "$(date -u +%FT%TZ)" > "${NEW}/RELEASE"
|
||||
|
||||
PREV="$(readlink -f "${CUR}" 2>/dev/null || true)"
|
||||
|
||||
echo "--> Swapping symlink"
|
||||
ln -sfn "${NEW}" "${CUR}"
|
||||
|
||||
echo "--> Restarting Apache"
|
||||
sudo /usr/bin/systemctl restart "${SERVICE_NAME}"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
echo "--> Health check ${HEALTH_URL}"
|
||||
if ! curl -fsS --max-time 10 "${HEALTH_URL}" >/dev/null; then
|
||||
echo "Health check failed, rolling back..."
|
||||
if [ -n "${PREV}" ] && [ -e "${PREV}" ]; then
|
||||
ln -sfn "${PREV}" "${CUR}"
|
||||
sudo /usr/bin/systemctl restart "${SERVICE_NAME}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "--> Cleaning old releases"
|
||||
CUR_REAL="$(readlink -f "${CUR}" 2>/dev/null || true)"
|
||||
cd "${RELEASES}"
|
||||
|
||||
i=0
|
||||
for name in $(ls -1t); do
|
||||
path="${RELEASES}/${name}"
|
||||
|
||||
if [ "${path}" = "${CUR_REAL}" ] || [ "${path}" = "${PREV}" ] || [ "${path}" = "${NEW}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
i=$((i+1))
|
||||
if [ "${i}" -gt "${KEEP_N}" ]; then
|
||||
rm -rf -- "${path}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "--> Deploy complete"
|
||||
REMOTE
|
||||
16
index.php
Normal file
16
index.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
define('BASE', dirname(__FILE__));
|
||||
require_once(BASE . '/rss/php/autoload.php');
|
||||
|
||||
use Vor\application\Frontend;
|
||||
use Vor\core\Sys;
|
||||
Sys::getConnection();
|
||||
|
||||
$render = Frontend::render();
|
||||
|
||||
extract($render['data']);
|
||||
$pageConf = $render['conf'];
|
||||
|
||||
require_once($render['header']);
|
||||
require_once($render['view']);
|
||||
require_once($render['footer']);
|
||||
7946
rss/css/main.css
Normal file
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
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
1082
rss/css/main.scss
Normal file
File diff suppressed because it is too large
Load Diff
1
rss/css/palette.css
Normal file
1
rss/css/palette.css
Normal file
@@ -0,0 +1 @@
|
||||
/* palette.scss - Full Spectrum Master Map *//*# sourceMappingURL=palette.css.map */
|
||||
1
rss/css/palette.css.map
Normal file
1
rss/css/palette.css.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["palette.scss"],"names":[],"mappings":"AAAA,4CAAA","file":"palette.css"}
|
||||
43
rss/css/palette.scss
Normal file
43
rss/css/palette.scss
Normal 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
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
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
999
rss/css/template.scss
Normal 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
1
rss/css/theme.css
Normal file
@@ -0,0 +1 @@
|
||||
/*# sourceMappingURL=theme.css.map */
|
||||
1
rss/css/theme.css.map
Normal file
1
rss/css/theme.css.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":[],"names":[],"mappings":"","file":"theme.css"}
|
||||
0
rss/css/theme.scss
Normal file
0
rss/css/theme.scss
Normal file
83
rss/css/variables.css
Normal file
83
rss/css/variables.css
Normal 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 */
|
||||
1
rss/css/variables.css.map
Normal file
1
rss/css/variables.css.map
Normal 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
305
rss/css/variables.scss
Normal 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
910
rss/js/main.js
Normal 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();
|
||||
});
|
||||
8
rss/json/config/groups.json
Normal file
8
rss/json/config/groups.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"owner": {
|
||||
"home": "dashboard"
|
||||
},
|
||||
"user": {
|
||||
"home": "home"
|
||||
}
|
||||
}
|
||||
5
rss/json/config/migrations.json
Normal file
5
rss/json/config/migrations.json
Normal 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
27
rss/json/pages/404.json
Normal 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
27
rss/json/pages/index.json
Normal 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
22
rss/php/autoload.php
Normal 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);
|
||||
110
rss/php/classes/application/Frontend.php
Normal file
110
rss/php/classes/application/Frontend.php
Normal 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;
|
||||
}
|
||||
}
|
||||
63
rss/php/classes/application/user/Auth.php
Normal file
63
rss/php/classes/application/user/Auth.php
Normal 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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
212
rss/php/classes/core/Main.php
Normal file
212
rss/php/classes/core/Main.php
Normal 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);
|
||||
}
|
||||
}
|
||||
539
rss/php/classes/core/Sys.php
Normal file
539
rss/php/classes/core/Sys.php
Normal 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;
|
||||
}
|
||||
}
|
||||
78
rss/php/classes/core/Validator.php
Normal file
78
rss/php/classes/core/Validator.php
Normal 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
56
rss/php/handler.php
Normal 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
148
rss/php/pagehandler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
6
rss/php/views/footers/default.php
Normal file
6
rss/php/views/footers/default.php
Normal 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>
|
||||
11
rss/php/views/headers/default.php
Normal file
11
rss/php/views/headers/default.php
Normal 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>
|
||||
|
||||
592
rss/php/views/pages/index.php
Normal file
592
rss/php/views/pages/index.php
Normal 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><html></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>
|
||||
Reference in New Issue
Block a user