fix: S3 generic compat, nav tab reliability, single logo

S3:
- Endpoint is now optional (leave blank = AWS S3, custom = MinIO/R2/Backblaze/etc.)
- forcePathStyle only applied when a custom endpoint is set (harmless on AWS)
- initS3() no longer requires endpoint to be present
- Updated form hint to explain AWS vs generic S3 usage

Nav tabs:
- Switched admin tab active-state matching from fragile array-index to data-tab attribute
- Added user-select:none to prevent text selection on click for both nav and admin tabs
- Admin tabs now flex-wrap for narrow viewports

Logo:
- Removed wilddragon-logo.png wordmark from splash, login, and header
- Single dragon-icon.png used throughout — no CSS invert hack needed
- Cleaner header: icon + "Dragon Wind" badge only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-04-05 20:42:21 -04:00
parent 895145e1ed
commit e2c6db7113
2 changed files with 35 additions and 35 deletions

View file

@ -114,13 +114,8 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
66%{transform:translateY(-4px) rotate(-.3deg)}
}
/* Wild Dragon wordmark */
.splash-wordmark{
height:56px;max-width:360px;object-fit:contain;
/* Invert black logo to white, keep blue flame color */
filter:invert(1) drop-shadow(0 2px 12px rgba(0,0,0,.5));
animation:wordmarkIn .8s .25s cubic-bezier(.22,1,.36,1) both;
}
/* Wild Dragon wordmark — not used, dragon icon only */
.splash-wordmark{ display:none; }
@keyframes wordmarkIn{
from{opacity:0;transform:translateY(10px) scale(.97)}
to{opacity:1;transform:translateY(0) scale(1)}
@ -169,9 +164,7 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
/* Dragon icon in login */
.login-icon-wrap{display:flex;flex-direction:column;align-items:center;gap:.5rem;margin-bottom:1.5rem}
.login-dragon-icon{width:72px;height:72px;object-fit:contain;filter:drop-shadow(0 4px 16px rgba(30,75,216,.4));animation:dragonFloat 4s ease-in-out infinite}
.login-wordmark{height:32px;max-width:220px;object-fit:contain}
/* Invert black logo in dark mode only */
body:not([data-theme="light"]) .login-wordmark{filter:invert(1)}
.login-wordmark{display:none}
.login-product{font-size:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.18em;margin-top:.25rem}
.login-field{width:100%;padding:.75rem 1rem;font-family:'Outfit',sans-serif;font-size:.9rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:10px;color:var(--text-primary);outline:none;transition:all .2s;margin-bottom:.75rem}
@ -192,8 +185,7 @@ body:not([data-theme="light"]) .login-wordmark{filter:invert(1)}
.header{display:flex;align-items:center;justify-content:space-between;padding:.8rem 2rem;border-bottom:1px solid var(--border);background:rgba(6,8,14,.9);backdrop-filter:blur(20px);position:sticky;top:0;z-index:100}
.header-left{display:flex;align-items:center;gap:.85rem;min-width:0;flex:1}
.header-dragon{width:40px;height:40px;object-fit:contain;filter:drop-shadow(0 2px 8px rgba(30,75,216,.5));flex-shrink:0}
.header-wordmark{height:28px;max-width:180px;object-fit:contain;flex-shrink:0}
body:not([data-theme="light"]) .header-wordmark{filter:invert(1)}
.header-wordmark{display:none}
[data-theme="light"] .header{background:rgba(240,242,247,.9)}
[data-theme="light"] .header-dragon{filter:none}
.header-product-tag{font-family:'JetBrains Mono',monospace;font-size:.58rem;color:var(--dragon-bright);text-transform:uppercase;letter-spacing:.15em;background:var(--dragon-glow);border:1px solid rgba(224,92,26,.25);border-radius:4px;padding:.15rem .45rem;flex-shrink:0}
@ -206,7 +198,7 @@ body:not([data-theme="light"]) .header-wordmark{filter:invert(1)}
/* NAV TABS */
.nav-tabs{display:flex;padding:0 2rem;border-bottom:1px solid var(--border);background:var(--bg-secondary)}
.nav-tab{padding:.65rem 1.1rem;font-size:.78rem;font-weight:600;color:var(--text-dim);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap}
.nav-tab{padding:.65rem 1.1rem;font-size:.78rem;font-weight:600;color:var(--text-dim);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap;user-select:none;-webkit-user-select:none}
.nav-tab:hover{color:var(--text-secondary)}
.nav-tab.active{color:var(--blue-bright);border-bottom-color:var(--blue-bright)}
.nav-tab.admin-tab.active{color:var(--warning);border-bottom-color:var(--warning)}
@ -314,8 +306,8 @@ body:not([data-theme="light"]) .header-wordmark{filter:invert(1)}
.refresh-btn:hover{border-color:var(--border-bright);color:var(--text-primary)}
/* ADMIN */
.admin-tabs{display:flex;border-bottom:1px solid var(--border);margin-bottom:1.5rem}
.admin-tab{padding:.5rem 1rem;font-size:.76rem;font-weight:600;color:var(--text-dim);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
.admin-tabs{display:flex;border-bottom:1px solid var(--border);margin-bottom:1.5rem;flex-wrap:wrap}
.admin-tab{padding:.5rem 1rem;font-size:.76rem;font-weight:600;color:var(--text-dim);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;user-select:none;-webkit-user-select:none}
.admin-tab:hover{color:var(--text-secondary)}
.admin-tab.active{color:var(--blue-bright);border-bottom-color:var(--blue-bright)}
.admin-panel{display:none}
@ -526,18 +518,22 @@ body:not([data-theme="light"]) .header-wordmark{filter:invert(1)}
<div class="page" id="page-admin">
<div style="margin-top:1.5rem"></div>
<div class="admin-tabs">
<div class="admin-tab active" onclick="switchAdminTab('s3')">S3 Storage</div>
<div class="admin-tab" onclick="switchAdminTab('relay')">UDP Relay</div>
<div class="admin-tab" onclick="switchAdminTab('ampp')">AMPP</div>
<div class="admin-tab" onclick="switchAdminTab('extension')">🧩 Extension</div>
<div class="admin-tab" onclick="switchAdminTab('users')">Users</div>
<div class="admin-tab" onclick="switchAdminTab('folders')">Folders</div>
<div class="admin-tab active" data-tab="s3" onclick="switchAdminTab('s3')">S3 Storage</div>
<div class="admin-tab" data-tab="relay" onclick="switchAdminTab('relay')">UDP Relay</div>
<div class="admin-tab" data-tab="ampp" onclick="switchAdminTab('ampp')">AMPP</div>
<div class="admin-tab" data-tab="extension" onclick="switchAdminTab('extension')">🧩 Extension</div>
<div class="admin-tab" data-tab="users" onclick="switchAdminTab('users')">Users</div>
<div class="admin-tab" data-tab="folders" onclick="switchAdminTab('folders')">Folders</div>
</div>
<!-- S3 -->
<div class="admin-panel active" id="admin-s3">
<div class="section-title">S3 Storage Configuration</div>
<div class="form-group"><label class="form-label">Endpoint URL</label><input class="form-input" id="s3-endpoint" type="url" placeholder="https://s3.example.com"/></div>
<div class="form-group">
<label class="form-label">Endpoint URL <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">(optional — leave blank for AWS S3)</span></label>
<input class="form-input" id="s3-endpoint" type="url" placeholder="https://s3.example.com — or blank for AWS"/>
<div class="form-hint">For MinIO, Backblaze, Cloudflare R2, Wasabi, etc. enter the full endpoint URL. Leave blank to use standard AWS S3.</div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Region</label><input class="form-input" id="s3-region" type="text" placeholder="us-east-1"/></div>
<div class="form-group"><label class="form-label">Bucket Name</label><input class="form-input" id="s3-bucket" type="text" placeholder="mybucket"/></div>
@ -814,8 +810,8 @@ function switchPage(name) {
}
function switchAdminTab(name) {
document.querySelectorAll('.admin-tab').forEach((t,i) => {
t.classList.toggle('active', ['s3','relay','ampp','extension','users','folders'][i] === name);
document.querySelectorAll('.admin-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === name);
});
document.querySelectorAll('.admin-panel').forEach(p => p.classList.toggle('active', p.id === `admin-${name}`));
if (name === 'users') loadUsers();

View file

@ -86,32 +86,35 @@ function setTree(t) { db.folderTree = t; saveData(db); }
let s3Client = null;
function buildS3Client(cfg) {
const endpoint = cfg.endpoint || "";
const isHttpsEndpoint = endpoint.startsWith("https");
const endpoint = (cfg.endpoint || "").trim();
const hasCustomEndpoint = endpoint.length > 0;
const isHttps = !hasCustomEndpoint || endpoint.startsWith("https");
const agentOpts = { keepAlive: true, maxSockets: 25 };
const agent = isHttpsEndpoint
const agent = isHttps
? new https.Agent({ ...agentOpts, rejectUnauthorized: false })
: new http.Agent(agentOpts);
return new S3Client({
endpoint: endpoint || undefined,
// No endpoint → AWS SDK uses the official AWS S3 endpoint for the region
...(hasCustomEndpoint ? { endpoint } : {}),
region: cfg.region || "us-east-1",
credentials: {
accessKeyId: cfg.accessKeyId || "",
secretAccessKey: cfg.secretAccessKey || "",
},
forcePathStyle: true,
// forcePathStyle needed for MinIO/Ceph/generic S3; harmless for AWS
forcePathStyle: hasCustomEndpoint,
requestHandler: new NodeHttpHandler({
connectionTimeout: 15000,
socketTimeout: 300000,
...(isHttpsEndpoint ? { httpsAgent: agent } : { httpAgent: agent }),
...(isHttps ? { httpsAgent: agent } : { httpAgent: agent }),
}),
});
}
function initS3() {
const cfg = db.s3Config || {};
if (cfg.endpoint && cfg.accessKeyId && cfg.secretAccessKey && cfg.bucket) {
if (cfg.accessKeyId && cfg.secretAccessKey && cfg.bucket) {
s3Client = buildS3Client(cfg);
console.log(`[S3] Client initialized → ${cfg.endpoint} / ${cfg.bucket}`);
} else {
@ -340,8 +343,9 @@ app.get("/api/s3/config", requireAdmin, (req, res) => {
app.put("/api/s3/config", requireAdmin, (req, res) => {
const { endpoint, region, bucket, accessKeyId, secretAccessKey } = req.body;
if (!endpoint || !region || !bucket || !accessKeyId)
return res.status(400).json({ success: false, error: "endpoint, region, bucket, and accessKeyId are required" });
// endpoint is optional for AWS S3 (leave blank to use AWS default)
if (!region || !bucket || !accessKeyId)
return res.status(400).json({ success: false, error: "region, bucket, and accessKeyId are required" });
if (!db.s3Config) db.s3Config = {};
db.s3Config.endpoint = endpoint.trim();
db.s3Config.region = region.trim();
@ -363,8 +367,8 @@ app.post("/api/s3/test", requireAdmin, async (req, res) => {
accessKeyId: accessKeyId || db.s3Config?.accessKeyId || "",
secretAccessKey: secretAccessKey || db.s3Config?.secretAccessKey || "",
};
if (!testCfg.endpoint || !testCfg.bucket || !testCfg.accessKeyId || !testCfg.secretAccessKey)
return res.status(400).json({ success: false, error: "All S3 fields required to test connection" });
if (!testCfg.bucket || !testCfg.accessKeyId || !testCfg.secretAccessKey)
return res.status(400).json({ success: false, error: "Bucket, Access Key ID, and Secret Key are required to test" });
const testClient = buildS3Client(testCfg);
const testKey = `_dragonwind_test_${Date.now()}.txt`;