fix(auth): reject invalid bearer tokens instead of falling back
Modify the authentication handler to return an unauthorized error when an invalid or disabled bearer token is provided, rather than silently falling back to an anonymous request. This ensures that clients attempting to authenticate but failing (due to expired, malformed, or disabled tokens) are explicitly notified of the auth failure instead of proceeding anonymously. True anonymous requests without any Authorization header remain supported.
This commit is contained in:
431
backend/static/css/00-base.css
Normal file
431
backend/static/css/00-base.css
Normal file
@@ -0,0 +1,431 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
--card: #18181b;
|
||||
--card-foreground: #fafafa;
|
||||
--muted: #27272a;
|
||||
--muted-foreground: #a1a1aa;
|
||||
--accent: #27272a;
|
||||
--accent-foreground: #fafafa;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--input: rgba(255, 255, 255, 0.15);
|
||||
--primary: #f4f4f5;
|
||||
--primary-foreground: #18181b;
|
||||
--ring: #71717a;
|
||||
--success: #86efac;
|
||||
--radius: 0.625rem;
|
||||
--shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem),
|
||||
linear-gradient(180deg, #09090b 0%, #0f0f12 100%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: -4rem;
|
||||
z-index: 10;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(9, 9, 11, 0.84);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
min-height: 3.5rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.nav-links,
|
||||
.footer-links,
|
||||
.inline-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--foreground);
|
||||
font-size: 2rem;
|
||||
line-height: 1.12;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.download-subtitle,
|
||||
.panel-header p {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-view {
|
||||
width: min(28rem, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 7.25rem);
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.muted-copy,
|
||||
.auth-alt {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.stack-form label,
|
||||
.inline-controls label,
|
||||
.collection-create label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
color: #fca5a5;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.checkbox-field input {
|
||||
width: 1rem;
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-field span {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
label span {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-footer,
|
||||
.result-header {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-footer p,
|
||||
#result-meta {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.button,
|
||||
button {
|
||||
min-height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: #e4e4e7;
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border-color: var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.button-outline:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
border-color: rgba(248, 113, 113, 0.28);
|
||||
background: rgba(127, 29, 29, 0.3);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background: rgba(127, 29, 29, 0.55);
|
||||
}
|
||||
|
||||
.button-wide {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
margin: 0.8rem 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
padding: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 1rem 0 0;
|
||||
color: #fecaca;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.button-sm {
|
||||
min-height: 1.85rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Badge variants */
|
||||
.badge-active {
|
||||
background: rgba(134, 239, 172, 0.12);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.badge-disabled {
|
||||
background: rgba(252, 165, 165, 0.1);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.badge-expired {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Nav username indicator in header */
|
||||
.nav-username {
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
281
backend/static/css/10-layout.css
Normal file
281
backend/static/css/10-layout.css
Normal file
@@ -0,0 +1,281 @@
|
||||
.app-shell {
|
||||
width: min(86rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0;
|
||||
display: grid;
|
||||
grid-template-columns: 14rem minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
align-self: start;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(24, 24, 27, 0.58);
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.62rem 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-link:hover,
|
||||
.sidebar-link.is-active {
|
||||
border-color: var(--border);
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.admin-shell .app-sidebar {
|
||||
border-color: rgba(125, 211, 252, 0.28);
|
||||
background: linear-gradient(180deg, rgba(8, 47, 73, 0.22), rgba(24, 24, 27, 0.58));
|
||||
}
|
||||
|
||||
.admin-shell .sidebar-link.is-active {
|
||||
border-color: rgba(125, 211, 252, 0.42);
|
||||
background: rgba(14, 116, 144, 0.24);
|
||||
}
|
||||
|
||||
.admin-shell .kicker {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.sidebar-logout {
|
||||
display: grid;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.sidebar-logout .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collection-create {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.compact-upload .drop-zone {
|
||||
min-height: 11rem;
|
||||
}
|
||||
|
||||
.dashboard-options {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.collection-tabs,
|
||||
.inline-controls {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.inline-controls input,
|
||||
.inline-controls select {
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.compact-input {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-form-narrow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.settings-form .checkbox-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-form button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
|
||||
/* Tab navigation */
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 120ms, color 120ms, border-color 120ms;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tab.is-active {
|
||||
border-color: var(--border);
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
/* Sidebar structure */
|
||||
.sidebar-sep {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Collection create dropdown */
|
||||
.new-collection-drop {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-collection-drop > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-collection-drop > summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.new-collection-body {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.5rem);
|
||||
z-index: 10;
|
||||
width: 15rem;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--card) 97%, #000);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.new-collection-body label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Copyable URL field */
|
||||
.copy-field {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.copy-field input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Settings sections */
|
||||
.settings-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.settings-section .checkbox-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-section label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Quota form in admin users table */
|
||||
.quota-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quota-form input {
|
||||
width: 6.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
302
backend/static/css/20-upload.css
Normal file
302
backend/static/css/20-upload.css
Normal file
@@ -0,0 +1,302 @@
|
||||
.upload-view {
|
||||
width: min(48rem, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 7.25rem);
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.drop-zone {
|
||||
min-height: 19rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 2rem;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(39, 39, 42, 0.42);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.is-dragging {
|
||||
border-color: var(--primary);
|
||||
background: rgba(39, 39, 42, 0.68);
|
||||
}
|
||||
|
||||
.drop-zone input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.drop-icon svg {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.drop-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.drop-copy,
|
||||
.drop-meta {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.drop-meta {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.advanced-options {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(39, 39, 42, 0.28);
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.advanced-options summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.form-footer,
|
||||
.result-header {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-footer p,
|
||||
#result-meta {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.button,
|
||||
button {
|
||||
min-height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: #e4e4e7;
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border-color: var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.button-outline:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
border-color: rgba(248, 113, 113, 0.28);
|
||||
background: rgba(127, 29, 29, 0.3);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background: rgba(127, 29, 29, 0.55);
|
||||
}
|
||||
|
||||
.button-wide {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 0.4rem;
|
||||
margin-top: 0.55rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.progress span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.upload-result {
|
||||
border-color: rgba(244, 244, 245, 0.24);
|
||||
background: rgba(244, 244, 245, 0.06);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.result-title svg {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.manage-link {
|
||||
margin: 0.9rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.manage-link a {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-list,
|
||||
.download-list {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.upload-queue {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-item,
|
||||
.download-item {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.result-item > span,
|
||||
.download-item > span {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-item strong,
|
||||
.download-item strong,
|
||||
.result-item code,
|
||||
.download-item code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: min(10rem, 32vw);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.file-progress-percent {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.file-progress {
|
||||
height: 0.35rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.result-item small,
|
||||
.download-item small,
|
||||
.result-item code,
|
||||
.download-item code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
274
backend/static/css/30-download.css
Normal file
274
backend/static/css/30-download.css
Normal file
@@ -0,0 +1,274 @@
|
||||
.download-view {
|
||||
width: min(38rem, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 7.25rem);
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.download-view-wide {
|
||||
width: min(58rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.download-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-emblem {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.file-emblem svg {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.download-item {
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.button.is-active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.file-browser {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
flex: 0 0 4.75rem;
|
||||
width: 4.75rem;
|
||||
aspect-ratio: 16 / 10;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.thumb-link img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-main {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-action [hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs {
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .thumb-link {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.file-browser.images-only .file-card:not([data-kind="image"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
width: 10.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: color-mix(in srgb, var(--card) 96%, #000);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.46);
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
width: 100%;
|
||||
min-height: 2.05rem;
|
||||
justify-content: flex-start;
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
padding: 0.42rem 0.5rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.context-menu button:hover,
|
||||
.context-menu button:focus-visible,
|
||||
.context-menu button.is-copied {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.context-menu-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1rem 0.1rem 0.2rem 0.45rem;
|
||||
}
|
||||
|
||||
.context-menu-top small {
|
||||
color: color-mix(in srgb, var(--muted-foreground) 74%, transparent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-menu-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.context-menu-icons button {
|
||||
width: 1.9rem;
|
||||
min-height: 1.9rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.context-menu hr {
|
||||
height: 1px;
|
||||
margin: 0.35rem 0.2rem;
|
||||
border: 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unlock-form {
|
||||
margin: 1rem auto 0;
|
||||
display: grid;
|
||||
max-width: 22rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.manage-details {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.manage-details div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.manage-details dt,
|
||||
.manage-details dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manage-details dt {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.manage-details dd {
|
||||
color: var(--foreground);
|
||||
font-size: 0.84rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.preview-stage img,
|
||||
.preview-stage video {
|
||||
width: 100%;
|
||||
max-height: 55vh;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-stage audio {
|
||||
width: calc(100% - 2rem);
|
||||
margin: 1rem;
|
||||
}
|
||||
104
backend/static/css/40-docs.css
Normal file
104
backend/static/css/40-docs.css
Normal file
@@ -0,0 +1,104 @@
|
||||
.admin-view {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.docs-view {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.docs-header p {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.docs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.docs-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.docs-card p {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.docs-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.endpoint-list,
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.endpoint-list div,
|
||||
.field-grid {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.endpoint-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.endpoint-list dt,
|
||||
.endpoint-list dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.endpoint-list dt,
|
||||
.field-grid span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.endpoint-list dd code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docs-steps {
|
||||
margin: 0.85rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.docs-steps li + li {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
grid-template-columns: minmax(8rem, 0.35fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.field-grid p {
|
||||
margin: 0;
|
||||
}
|
||||
174
backend/static/css/50-admin.css
Normal file
174
backend/static/css/50-admin.css
Normal file
@@ -0,0 +1,174 @@
|
||||
.admin-header,
|
||||
.table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(24, 24, 27, 0.78);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.table-header p {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--foreground);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.admin-table-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.table-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.table-header p {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Inline row edit (details/summary in table cells) */
|
||||
.row-edit {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.row-edit > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.row-edit > summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.row-edit[open] > summary {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-edit-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.row-edit-form input,
|
||||
.row-edit-form select {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
min-height: 1.9rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
}
|
||||
|
||||
.storage-edit-form {
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: end;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.storage-edit-form label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.storage-edit-form label span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.storage-edit-form textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.storage-edit-form .checkbox-field,
|
||||
.storage-edit-form button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.storage-edit-form {
|
||||
position: static;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
244
backend/static/css/60-storage.css
Normal file
244
backend/static/css/60-storage.css
Normal file
@@ -0,0 +1,244 @@
|
||||
/* ── Storage card UI ─────────────────────────────────────────────────────── */
|
||||
|
||||
.storage-stack {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.storage-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.storage-card.is-local {
|
||||
border-left: 3px solid rgba(125, 211, 252, 0.45);
|
||||
}
|
||||
|
||||
.storage-card.is-editing {
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.12);
|
||||
}
|
||||
|
||||
.storage-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.storage-card-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-card-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.storage-card-icon svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.storage-card-name {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.storage-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.storage-card-usage {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.storage-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* View-mode summary */
|
||||
.storage-card-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 1.75rem;
|
||||
padding: 0.65rem 1.1rem 0.9rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.storage-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.storage-detail > span:first-child,
|
||||
.storage-detail > code:first-child {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.storage-detail > span:last-child,
|
||||
.storage-detail > code:last-child {
|
||||
font-size: 0.82rem;
|
||||
color: var(--foreground);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.storage-detail-test > span:last-child {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.storage-detail-test.is-ok > span:last-child { color: #86efac; }
|
||||
.storage-detail-test.is-err > span:last-child { color: #fca5a5; }
|
||||
|
||||
/* Edit-mode body */
|
||||
.storage-card:not(.is-editing) .storage-card-body { display: none; }
|
||||
.storage-card.is-editing .storage-card-summary { display: none; }
|
||||
.storage-card.is-editing .storage-edit-trigger { display: none; }
|
||||
|
||||
.storage-card-body {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.storage-card-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.storage-card-fields label {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.storage-card-fields label span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.storage-card-fields textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.storage-card-fields .checkbox-field {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.storage-card-edit-bar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.storage-card-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add storage section */
|
||||
.storage-add-section {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.storage-add-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.storage-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.storage-type-option {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 0.3rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.storage-type-option:hover {
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
background: color-mix(in srgb, var(--card) 80%, rgba(14, 116, 144, 0.3));
|
||||
}
|
||||
|
||||
.storage-type-option svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.storage-type-option strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.storage-type-option span {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.storage-new-card {
|
||||
border: 1px dashed rgba(125, 211, 252, 0.4);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 90%, rgba(14, 116, 144, 0.15));
|
||||
}
|
||||
|
||||
.storage-new-card .storage-card-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.storage-new-card .storage-card-body {
|
||||
border-top: none;
|
||||
}
|
||||
59
backend/static/css/70-tokens.css
Normal file
59
backend/static/css/70-tokens.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* ── Access tokens ───────────────────────────────────────────────────────── */
|
||||
|
||||
.token-create-form {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-create-form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.token-reveal {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(134, 239, 172, 0.3);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(134, 239, 172, 0.08);
|
||||
}
|
||||
|
||||
.token-reveal-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.token-reveal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-reveal-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-reveal .muted-copy {
|
||||
margin: 0.6rem 0 0;
|
||||
}
|
||||
|
||||
.token-reveal .muted-copy code {
|
||||
word-break: break-all;
|
||||
}
|
||||
94
backend/static/css/90-responsive.css
Normal file
94
backend/static/css/90-responsive.css
Normal file
@@ -0,0 +1,94 @@
|
||||
@media (max-width: 720px) {
|
||||
.nav-links {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.upload-view,
|
||||
.download-view {
|
||||
min-height: auto;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.option-grid,
|
||||
.form-footer,
|
||||
.result-header,
|
||||
.site-footer {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.docs-grid,
|
||||
.field-grid,
|
||||
.app-shell,
|
||||
.settings-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.endpoint-list div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-actions .button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
.admin-header,
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.new-collection-body {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.storage-card-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
62
backend/static/js/00-utils.js
Normal file
62
backend/static/js/00-utils.js
Normal file
@@ -0,0 +1,62 @@
|
||||
(function () {
|
||||
window.Warpbox = window.Warpbox || {};
|
||||
|
||||
window.Warpbox.openInNewTab = function openInNewTab(url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
window.Warpbox.writeClipboard = async function writeClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
};
|
||||
|
||||
window.Warpbox.copyText = async function copyText(text, button, copiedLabel) {
|
||||
if (!text || !button) {
|
||||
return;
|
||||
}
|
||||
await window.Warpbox.writeClipboard(text);
|
||||
const previous = button.textContent;
|
||||
button.textContent = copiedLabel;
|
||||
setTimeout(() => {
|
||||
button.textContent = previous;
|
||||
}, 1400);
|
||||
};
|
||||
|
||||
window.Warpbox.formatDate = function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
window.Warpbox.formatBytes = function formatBytes(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
};
|
||||
})();
|
||||
191
backend/static/js/10-file-browser.js
Normal file
191
backend/static/js/10-file-browser.js
Normal file
@@ -0,0 +1,191 @@
|
||||
(function () {
|
||||
const fileBrowser = document.querySelector("[data-file-browser]");
|
||||
const viewButtons = document.querySelectorAll("[data-view-button]");
|
||||
const previewImages = document.querySelector("[data-preview-images]");
|
||||
const previewActions = document.querySelectorAll("[data-preview-action]");
|
||||
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
||||
|
||||
let ctrlCopyMode = false;
|
||||
let contextFile = null;
|
||||
const contextMenuCloseDistance = 80;
|
||||
|
||||
if (fileBrowser) {
|
||||
viewButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const view = button.getAttribute("data-view-button");
|
||||
fileBrowser.classList.toggle("is-list", view === "list");
|
||||
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
||||
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
});
|
||||
});
|
||||
|
||||
if (previewImages) {
|
||||
previewImages.addEventListener("click", () => {
|
||||
fileBrowser.classList.toggle("images-only");
|
||||
previewImages.classList.toggle("is-active");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fileBrowser && fileContextMenu) {
|
||||
fileBrowser.addEventListener("contextmenu", (event) => {
|
||||
const card = event.target.closest("[data-file-context]");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
contextFile = {
|
||||
previewURL: card.dataset.previewUrl,
|
||||
viewURL: card.dataset.viewUrl,
|
||||
downloadURL: card.dataset.downloadUrl,
|
||||
fileName: card.dataset.fileName,
|
||||
};
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
fileContextMenu.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-context-action]");
|
||||
if (!button || !contextFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldHide = await runContextAction(button.dataset.contextAction, contextFile);
|
||||
if (shouldHide !== false) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!fileContextMenu.contains(event.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) {
|
||||
return;
|
||||
}
|
||||
hideContextMenu();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", hideContextMenu);
|
||||
window.addEventListener("scroll", hideContextMenu, true);
|
||||
}
|
||||
|
||||
if (previewActions.length > 0) {
|
||||
previewActions.forEach((button) => {
|
||||
button.addEventListener("click", async (event) => {
|
||||
if (!event.ctrlKey && !ctrlCopyMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await copyPreviewLink(button);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => setPreviewCopyMode(false));
|
||||
}
|
||||
|
||||
async function copyPreviewLink(button) {
|
||||
await window.Warpbox.writeClipboard(button.href);
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function setPreviewCopyMode(enabled) {
|
||||
ctrlCopyMode = enabled;
|
||||
previewActions.forEach((button) => {
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
const viewIcon = button.querySelector("[data-preview-view-icon]");
|
||||
const copyIcon = button.querySelector("[data-preview-copy-icon]");
|
||||
if (label) {
|
||||
label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}
|
||||
if (viewIcon) {
|
||||
viewIcon.hidden = enabled;
|
||||
}
|
||||
if (copyIcon) {
|
||||
copyIcon.hidden = !enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runContextAction(action, file) {
|
||||
if (action === "preview") {
|
||||
window.Warpbox.openInNewTab(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "view") {
|
||||
window.Warpbox.openInNewTab(file.viewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-preview") {
|
||||
await window.Warpbox.writeClipboard(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-download") {
|
||||
await window.Warpbox.writeClipboard(file.downloadURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "download") {
|
||||
window.Warpbox.openInNewTab(file.downloadURL);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
fileContextMenu.hidden = false;
|
||||
fileContextMenu.style.left = "0px";
|
||||
fileContextMenu.style.top = "0px";
|
||||
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const left = Math.min(x, window.innerWidth - rect.width - margin);
|
||||
const top = Math.min(y, window.innerHeight - rect.height - margin);
|
||||
fileContextMenu.style.left = `${Math.max(margin, left)}px`;
|
||||
fileContextMenu.style.top = `${Math.max(margin, top)}px`;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
if (!fileContextMenu || fileContextMenu.hidden) {
|
||||
return;
|
||||
}
|
||||
fileContextMenu.hidden = true;
|
||||
contextFile = null;
|
||||
}
|
||||
|
||||
function isPointerNearContextMenu(x, y) {
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
return x >= rect.left - contextMenuCloseDistance &&
|
||||
x <= rect.right + contextMenuCloseDistance &&
|
||||
y >= rect.top - contextMenuCloseDistance &&
|
||||
y <= rect.bottom + contextMenuCloseDistance;
|
||||
}
|
||||
})();
|
||||
125
backend/static/js/20-storage-admin.js
Normal file
125
backend/static/js/20-storage-admin.js
Normal file
@@ -0,0 +1,125 @@
|
||||
(function () {
|
||||
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]");
|
||||
|
||||
function syncStorageProvider(select) {
|
||||
const formScope = select.closest("form");
|
||||
if (!formScope) {
|
||||
return;
|
||||
}
|
||||
const provider = select.value;
|
||||
const isContabo = provider === "contabo";
|
||||
formScope.querySelectorAll("[data-provider-fields]").forEach((group) => {
|
||||
const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/);
|
||||
const active = providers.includes(provider);
|
||||
group.hidden = !active;
|
||||
group.querySelectorAll("input, select, textarea").forEach((input) => {
|
||||
input.disabled = !active;
|
||||
});
|
||||
});
|
||||
const tls = formScope.querySelector('input[name="use_ssl"]');
|
||||
const pathStyle = formScope.querySelector('input[name="path_style"]');
|
||||
if (tls) {
|
||||
tls.checked = isContabo || tls.checked;
|
||||
tls.disabled = isContabo;
|
||||
}
|
||||
if (pathStyle) {
|
||||
pathStyle.checked = isContabo || pathStyle.checked;
|
||||
pathStyle.disabled = isContabo;
|
||||
}
|
||||
}
|
||||
|
||||
storageProviderSelects.forEach((select) => {
|
||||
select.addEventListener("change", () => syncStorageProvider(select));
|
||||
syncStorageProvider(select);
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-edit-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const card = button.closest(".storage-card");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
card.classList.add("is-editing");
|
||||
const providerSelect = card.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-cancel-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const card = button.closest(".storage-card");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
const form = card.querySelector("form");
|
||||
if (form) {
|
||||
form.reset();
|
||||
}
|
||||
card.classList.remove("is-editing");
|
||||
});
|
||||
});
|
||||
|
||||
const storageAddTrigger = document.querySelector(".storage-add-trigger");
|
||||
const storageTypePicker = document.querySelector(".storage-type-picker");
|
||||
const storageNewCard = document.querySelector(".storage-new-card");
|
||||
|
||||
const providerLabels = {
|
||||
s3: "S3 bucket",
|
||||
contabo: "Contabo Object Storage",
|
||||
sftp: "SFTP",
|
||||
smb: "Samba",
|
||||
webdav: "WebDAV",
|
||||
};
|
||||
|
||||
if (storageAddTrigger && storageTypePicker) {
|
||||
storageAddTrigger.addEventListener("click", () => {
|
||||
storageTypePicker.hidden = !storageTypePicker.hidden;
|
||||
if (storageNewCard && !storageTypePicker.hidden) {
|
||||
storageNewCard.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
storageTypePicker.querySelectorAll(".storage-type-option").forEach((option) => {
|
||||
option.addEventListener("click", () => {
|
||||
const provider = option.dataset.provider;
|
||||
if (!storageNewCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerSelect = storageNewCard.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
providerSelect.value = provider;
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
|
||||
const typeBadge = storageNewCard.querySelector(".storage-new-type-badge");
|
||||
if (typeBadge) {
|
||||
typeBadge.textContent = providerLabels[provider] || provider;
|
||||
}
|
||||
|
||||
const iconEl = storageNewCard.querySelector(".storage-new-icon");
|
||||
const optIcon = option.querySelector("svg");
|
||||
if (iconEl && optIcon) {
|
||||
iconEl.innerHTML = optIcon.outerHTML;
|
||||
}
|
||||
|
||||
storageTypePicker.hidden = true;
|
||||
storageNewCard.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (storageNewCard) {
|
||||
const cancelBtn = storageNewCard.querySelector(".storage-new-cancel");
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
storageNewCard.hidden = true;
|
||||
if (storageTypePicker) {
|
||||
storageTypePicker.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
14
backend/static/js/30-token-copy.js
Normal file
14
backend/static/js/30-token-copy.js
Normal file
@@ -0,0 +1,14 @@
|
||||
(function () {
|
||||
const tokenCopyBtn = document.querySelector("[data-token-copy]");
|
||||
if (!tokenCopyBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokenCopyBtn.addEventListener("click", () => {
|
||||
const valueEl = document.querySelector("[data-token-value]");
|
||||
if (!valueEl) {
|
||||
return;
|
||||
}
|
||||
window.Warpbox.copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied");
|
||||
});
|
||||
})();
|
||||
252
backend/static/js/40-upload.js
Normal file
252
backend/static/js/40-upload.js
Normal file
@@ -0,0 +1,252 @@
|
||||
(function () {
|
||||
const form = document.querySelector("#upload-form");
|
||||
const dropZone = document.querySelector(".drop-zone");
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
const fileSummary = document.querySelector("#file-summary");
|
||||
const progress = document.querySelector("#upload-progress");
|
||||
const uploadStatus = document.querySelector("#upload-status");
|
||||
const result = document.querySelector("#upload-result");
|
||||
const resultMeta = document.querySelector("#result-meta");
|
||||
const resultList = document.querySelector("#result-list");
|
||||
const uploadQueue = document.querySelector("#upload-queue");
|
||||
const totalProgressBar = document.querySelector("#total-progress-bar");
|
||||
const copyURL = document.querySelector("#copy-url");
|
||||
const openBox = document.querySelector("#open-box");
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
let latestBoxURL = "";
|
||||
let selectedFiles = [];
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
updateSelectedState(event.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => updateSelectedState(fileInput.files));
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
updateStatus("Choose at least one file first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const formData = new FormData(form);
|
||||
selectedFiles = Array.from(fileInput.files);
|
||||
renderQueue(selectedFiles, "queued");
|
||||
setLoading(true, submit);
|
||||
|
||||
try {
|
||||
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
form.reset();
|
||||
updateSelectedState([]);
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
});
|
||||
|
||||
if (copyURL) {
|
||||
copyURL.addEventListener("click", () => {
|
||||
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectedState(files) {
|
||||
selectedFiles = Array.from(files || []);
|
||||
const count = selectedFiles.length || 0;
|
||||
const title = dropZone.querySelector(".drop-title");
|
||||
if (title) {
|
||||
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
||||
}
|
||||
if (fileSummary) {
|
||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||
}
|
||||
if (count > 0) {
|
||||
renderQueue(selectedFiles, "queued");
|
||||
} else if (uploadQueue) {
|
||||
uploadQueue.hidden = true;
|
||||
uploadQueue.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(isLoading, submit) {
|
||||
if (progress) {
|
||||
progress.hidden = !isLoading;
|
||||
}
|
||||
if (submit) {
|
||||
submit.disabled = isLoading;
|
||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||
}
|
||||
updateStatus(isLoading ? "Transferring files..." : "");
|
||||
setTotalProgress(isLoading ? 0 : 100);
|
||||
}
|
||||
|
||||
function updateStatus(message) {
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
if (!result || !resultList || !resultMeta || !openBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestBoxURL = payload.boxUrl;
|
||||
result.hidden = false;
|
||||
openBox.href = payload.boxUrl;
|
||||
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`;
|
||||
if (manageLink) {
|
||||
const anchor = manageLink.querySelector("a");
|
||||
manageLink.hidden = !payload.manageUrl;
|
||||
if (anchor && payload.manageUrl) {
|
||||
anchor.href = payload.manageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
resultList.replaceChildren();
|
||||
payload.files.forEach((file) => {
|
||||
resultList.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: `${file.size} · ${file.url}`,
|
||||
progress: 100,
|
||||
status: "complete",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function uploadWithProgress(url, formData, files) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", url);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
updateStatus("Uploading...");
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
updateStatus(`${percent}%`);
|
||||
setTotalProgress(percent);
|
||||
setFileProgress(files, percent);
|
||||
});
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(request.responseText || "{}");
|
||||
} catch (error) {
|
||||
reject(new Error("Upload response could not be read"));
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(payload.error || "Upload failed"));
|
||||
return;
|
||||
}
|
||||
setTotalProgress(100);
|
||||
setFileProgress(files, 100);
|
||||
resolve(payload);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
||||
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
request.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
uploadQueue.hidden = files.length === 0;
|
||||
uploadQueue.replaceChildren();
|
||||
files.forEach((file) => {
|
||||
uploadQueue.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: window.Warpbox.formatBytes(file.size),
|
||||
progress: status === "queued" ? 0 : 100,
|
||||
status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function createFileRow(file) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "result-item upload-file-row";
|
||||
row.dataset.fileName = file.name;
|
||||
|
||||
const body = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.className = "file-name";
|
||||
name.textContent = file.name;
|
||||
name.title = file.name;
|
||||
const meta = document.createElement("code");
|
||||
meta.textContent = file.meta;
|
||||
body.append(name, meta);
|
||||
|
||||
const side = document.createElement("div");
|
||||
side.className = "file-progress-side";
|
||||
const percent = document.createElement("span");
|
||||
percent.className = "file-progress-percent";
|
||||
percent.textContent = `${file.progress}%`;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "progress file-progress";
|
||||
const fill = document.createElement("span");
|
||||
fill.style.transform = `scaleX(${file.progress / 100})`;
|
||||
bar.append(fill);
|
||||
side.append(percent, bar);
|
||||
|
||||
row.append(body, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
function setTotalProgress(percent) {
|
||||
if (totalProgressBar) {
|
||||
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
||||
}
|
||||
}
|
||||
|
||||
function setFileProgress(files, totalPercent) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
const count = files.length || 1;
|
||||
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
|
||||
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
|
||||
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
|
||||
const percent = row.querySelector(".file-progress-percent");
|
||||
const fill = row.querySelector(".file-progress span");
|
||||
if (percent) {
|
||||
percent.textContent = `${progress}%`;
|
||||
}
|
||||
if (fill) {
|
||||
fill.style.transform = `scaleX(${progress / 100})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,636 +0,0 @@
|
||||
(function () {
|
||||
const form = document.querySelector("#upload-form");
|
||||
const dropZone = document.querySelector(".drop-zone");
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
const fileSummary = document.querySelector("#file-summary");
|
||||
const progress = document.querySelector("#upload-progress");
|
||||
const uploadStatus = document.querySelector("#upload-status");
|
||||
const result = document.querySelector("#upload-result");
|
||||
const resultMeta = document.querySelector("#result-meta");
|
||||
const resultList = document.querySelector("#result-list");
|
||||
const uploadQueue = document.querySelector("#upload-queue");
|
||||
const totalProgressBar = document.querySelector("#total-progress-bar");
|
||||
const copyURL = document.querySelector("#copy-url");
|
||||
const openBox = document.querySelector("#open-box");
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
const fileBrowser = document.querySelector("[data-file-browser]");
|
||||
const viewButtons = document.querySelectorAll("[data-view-button]");
|
||||
const previewImages = document.querySelector("[data-preview-images]");
|
||||
const previewActions = document.querySelectorAll("[data-preview-action]");
|
||||
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
||||
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]");
|
||||
let ctrlCopyMode = false;
|
||||
let contextFile = null;
|
||||
const contextMenuCloseDistance = 80;
|
||||
|
||||
if (fileBrowser) {
|
||||
viewButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const view = button.getAttribute("data-view-button");
|
||||
fileBrowser.classList.toggle("is-list", view === "list");
|
||||
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
||||
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
});
|
||||
});
|
||||
|
||||
if (previewImages) {
|
||||
previewImages.addEventListener("click", () => {
|
||||
fileBrowser.classList.toggle("images-only");
|
||||
previewImages.classList.toggle("is-active");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fileBrowser && fileContextMenu) {
|
||||
fileBrowser.addEventListener("contextmenu", (event) => {
|
||||
const card = event.target.closest("[data-file-context]");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
contextFile = {
|
||||
previewURL: card.dataset.previewUrl,
|
||||
viewURL: card.dataset.viewUrl,
|
||||
downloadURL: card.dataset.downloadUrl,
|
||||
fileName: card.dataset.fileName,
|
||||
};
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
fileContextMenu.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-context-action]");
|
||||
if (!button || !contextFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldHide = await runContextAction(button.dataset.contextAction, contextFile);
|
||||
if (shouldHide !== false) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!fileContextMenu.contains(event.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) {
|
||||
return;
|
||||
}
|
||||
hideContextMenu();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", hideContextMenu);
|
||||
window.addEventListener("scroll", hideContextMenu, true);
|
||||
}
|
||||
|
||||
if (previewActions.length > 0) {
|
||||
previewActions.forEach((button) => {
|
||||
button.addEventListener("click", async (event) => {
|
||||
if (!event.ctrlKey && !ctrlCopyMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await copyPreviewLink(button);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
setPreviewCopyMode(false);
|
||||
});
|
||||
}
|
||||
|
||||
function syncStorageProvider(select) {
|
||||
const formScope = select.closest("form");
|
||||
if (!formScope) {
|
||||
return;
|
||||
}
|
||||
const provider = select.value;
|
||||
const isContabo = provider === "contabo";
|
||||
formScope.querySelectorAll("[data-provider-fields]").forEach((group) => {
|
||||
const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/);
|
||||
const active = providers.includes(provider);
|
||||
group.hidden = !active;
|
||||
group.querySelectorAll("input, select, textarea").forEach((input) => {
|
||||
input.disabled = !active;
|
||||
});
|
||||
});
|
||||
const tls = formScope.querySelector('input[name="use_ssl"]');
|
||||
const pathStyle = formScope.querySelector('input[name="path_style"]');
|
||||
if (tls) {
|
||||
tls.checked = isContabo || tls.checked;
|
||||
tls.disabled = isContabo;
|
||||
}
|
||||
if (pathStyle) {
|
||||
pathStyle.checked = isContabo || pathStyle.checked;
|
||||
pathStyle.disabled = isContabo;
|
||||
}
|
||||
}
|
||||
|
||||
if (storageProviderSelects.length > 0) {
|
||||
storageProviderSelects.forEach((select) => {
|
||||
select.addEventListener("change", () => syncStorageProvider(select));
|
||||
syncStorageProvider(select);
|
||||
});
|
||||
}
|
||||
|
||||
/* Storage card edit / cancel toggles */
|
||||
document.querySelectorAll(".storage-edit-trigger").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const card = btn.closest(".storage-card");
|
||||
if (!card) return;
|
||||
card.classList.add("is-editing");
|
||||
const providerSelect = card.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-cancel-trigger").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const card = btn.closest(".storage-card");
|
||||
if (!card) return;
|
||||
const form = card.querySelector("form");
|
||||
if (form) form.reset();
|
||||
card.classList.remove("is-editing");
|
||||
});
|
||||
});
|
||||
|
||||
/* Add storage: type picker */
|
||||
const storageAddTrigger = document.querySelector(".storage-add-trigger");
|
||||
const storageTypePicker = document.querySelector(".storage-type-picker");
|
||||
const storageNewCard = document.querySelector(".storage-new-card");
|
||||
|
||||
const providerLabels = {
|
||||
s3: "S3 bucket",
|
||||
contabo: "Contabo Object Storage",
|
||||
sftp: "SFTP",
|
||||
smb: "Samba",
|
||||
webdav: "WebDAV",
|
||||
};
|
||||
|
||||
const providerIconSVGs = {
|
||||
s3: storageNewCard && storageNewCard.querySelector(".storage-new-icon") ? storageNewCard.querySelector(".storage-new-icon").innerHTML : "",
|
||||
contabo: "",
|
||||
sftp: "",
|
||||
smb: "",
|
||||
webdav: "",
|
||||
};
|
||||
|
||||
if (storageAddTrigger && storageTypePicker) {
|
||||
storageAddTrigger.addEventListener("click", () => {
|
||||
storageTypePicker.hidden = !storageTypePicker.hidden;
|
||||
if (storageNewCard && !storageTypePicker.hidden) {
|
||||
storageNewCard.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
storageTypePicker.querySelectorAll(".storage-type-option").forEach((opt) => {
|
||||
opt.addEventListener("click", () => {
|
||||
const provider = opt.dataset.provider;
|
||||
if (!storageNewCard) return;
|
||||
|
||||
const providerSelect = storageNewCard.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
providerSelect.value = provider;
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
|
||||
const typeBadge = storageNewCard.querySelector(".storage-new-type-badge");
|
||||
if (typeBadge) typeBadge.textContent = providerLabels[provider] || provider;
|
||||
|
||||
const iconEl = storageNewCard.querySelector(".storage-new-icon");
|
||||
const optIcon = opt.querySelector("svg");
|
||||
if (iconEl && optIcon) {
|
||||
iconEl.innerHTML = optIcon.outerHTML;
|
||||
}
|
||||
|
||||
storageTypePicker.hidden = true;
|
||||
storageNewCard.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (storageNewCard) {
|
||||
const cancelBtn = storageNewCard.querySelector(".storage-new-cancel");
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
storageNewCard.hidden = true;
|
||||
if (storageTypePicker) storageTypePicker.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* Access token: copy one-time secret */
|
||||
const tokenCopyBtn = document.querySelector("[data-token-copy]");
|
||||
if (tokenCopyBtn) {
|
||||
tokenCopyBtn.addEventListener("click", () => {
|
||||
const valueEl = document.querySelector("[data-token-value]");
|
||||
if (!valueEl) return;
|
||||
copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
let latestBoxURL = "";
|
||||
let selectedFiles = [];
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
updateSelectedState(event.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
updateSelectedState(fileInput.files);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
updateStatus("Choose at least one file first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const formData = new FormData(form);
|
||||
selectedFiles = Array.from(fileInput.files);
|
||||
renderQueue(selectedFiles, "queued");
|
||||
setLoading(true, submit);
|
||||
|
||||
try {
|
||||
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
form.reset();
|
||||
updateSelectedState([]);
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
});
|
||||
|
||||
if (copyURL) {
|
||||
copyURL.addEventListener("click", () => {
|
||||
copyText(latestBoxURL, copyURL, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectedState(files) {
|
||||
selectedFiles = Array.from(files || []);
|
||||
const count = selectedFiles.length || 0;
|
||||
const title = dropZone.querySelector(".drop-title");
|
||||
if (title) {
|
||||
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
||||
}
|
||||
if (fileSummary) {
|
||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||
}
|
||||
if (count > 0) {
|
||||
renderQueue(selectedFiles, "queued");
|
||||
} else if (uploadQueue) {
|
||||
uploadQueue.hidden = true;
|
||||
uploadQueue.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(isLoading, submit) {
|
||||
if (progress) {
|
||||
progress.hidden = !isLoading;
|
||||
}
|
||||
if (submit) {
|
||||
submit.disabled = isLoading;
|
||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||
}
|
||||
updateStatus(isLoading ? "Transferring files..." : "");
|
||||
setTotalProgress(isLoading ? 0 : 100);
|
||||
}
|
||||
|
||||
function updateStatus(message) {
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
if (!result || !resultList || !resultMeta || !openBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestBoxURL = payload.boxUrl;
|
||||
result.hidden = false;
|
||||
openBox.href = payload.boxUrl;
|
||||
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
|
||||
if (manageLink) {
|
||||
const anchor = manageLink.querySelector("a");
|
||||
manageLink.hidden = !payload.manageUrl;
|
||||
if (anchor && payload.manageUrl) {
|
||||
anchor.href = payload.manageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
resultList.replaceChildren();
|
||||
payload.files.forEach((file) => {
|
||||
resultList.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: `${file.size} · ${file.url}`,
|
||||
progress: 100,
|
||||
status: "complete",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function uploadWithProgress(url, formData, files) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", url);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
updateStatus("Uploading...");
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
updateStatus(`${percent}%`);
|
||||
setTotalProgress(percent);
|
||||
setFileProgress(files, percent);
|
||||
});
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(request.responseText || "{}");
|
||||
} catch (error) {
|
||||
reject(new Error("Upload response could not be read"));
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(payload.error || "Upload failed"));
|
||||
return;
|
||||
}
|
||||
setTotalProgress(100);
|
||||
setFileProgress(files, 100);
|
||||
resolve(payload);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
||||
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
request.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
uploadQueue.hidden = files.length === 0;
|
||||
uploadQueue.replaceChildren();
|
||||
files.forEach((file) => {
|
||||
uploadQueue.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: formatBytes(file.size),
|
||||
progress: status === "queued" ? 0 : 100,
|
||||
status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function createFileRow(file) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "result-item upload-file-row";
|
||||
row.dataset.fileName = file.name;
|
||||
|
||||
const body = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.className = "file-name";
|
||||
name.textContent = file.name;
|
||||
name.title = file.name;
|
||||
const meta = document.createElement("code");
|
||||
meta.textContent = file.meta;
|
||||
body.append(name, meta);
|
||||
|
||||
const side = document.createElement("div");
|
||||
side.className = "file-progress-side";
|
||||
const percent = document.createElement("span");
|
||||
percent.className = "file-progress-percent";
|
||||
percent.textContent = `${file.progress}%`;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "progress file-progress";
|
||||
const fill = document.createElement("span");
|
||||
fill.style.transform = `scaleX(${file.progress / 100})`;
|
||||
bar.append(fill);
|
||||
side.append(percent, bar);
|
||||
|
||||
row.append(body, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
function setTotalProgress(percent) {
|
||||
if (totalProgressBar) {
|
||||
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
||||
}
|
||||
}
|
||||
|
||||
function setFileProgress(files, totalPercent) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
const count = files.length || 1;
|
||||
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
|
||||
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
|
||||
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
|
||||
const percent = row.querySelector(".file-progress-percent");
|
||||
const fill = row.querySelector(".file-progress span");
|
||||
if (percent) {
|
||||
percent.textContent = `${progress}%`;
|
||||
}
|
||||
if (fill) {
|
||||
fill.style.transform = `scaleX(${progress / 100})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function copyText(text, button, copiedLabel) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
await writeClipboard(text);
|
||||
const previous = button.textContent;
|
||||
button.textContent = copiedLabel;
|
||||
setTimeout(() => {
|
||||
button.textContent = previous;
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
async function copyPreviewLink(button) {
|
||||
await writeClipboard(button.href);
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function setPreviewCopyMode(enabled) {
|
||||
ctrlCopyMode = enabled;
|
||||
previewActions.forEach((button) => {
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
const viewIcon = button.querySelector("[data-preview-view-icon]");
|
||||
const copyIcon = button.querySelector("[data-preview-copy-icon]");
|
||||
if (label) {
|
||||
label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}
|
||||
if (viewIcon) {
|
||||
viewIcon.hidden = enabled;
|
||||
}
|
||||
if (copyIcon) {
|
||||
copyIcon.hidden = !enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runContextAction(action, file) {
|
||||
if (action === "preview") {
|
||||
openInNewTab(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "view") {
|
||||
openInNewTab(file.viewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-preview") {
|
||||
await writeClipboard(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-download") {
|
||||
await writeClipboard(file.downloadURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "download") {
|
||||
openInNewTab(file.downloadURL);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
fileContextMenu.hidden = false;
|
||||
fileContextMenu.style.left = "0px";
|
||||
fileContextMenu.style.top = "0px";
|
||||
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const left = Math.min(x, window.innerWidth - rect.width - margin);
|
||||
const top = Math.min(y, window.innerHeight - rect.height - margin);
|
||||
fileContextMenu.style.left = `${Math.max(margin, left)}px`;
|
||||
fileContextMenu.style.top = `${Math.max(margin, top)}px`;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
if (!fileContextMenu || fileContextMenu.hidden) {
|
||||
return;
|
||||
}
|
||||
fileContextMenu.hidden = true;
|
||||
contextFile = null;
|
||||
}
|
||||
|
||||
function isPointerNearContextMenu(x, y) {
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
return x >= rect.left - contextMenuCloseDistance &&
|
||||
x <= rect.right + contextMenuCloseDistance &&
|
||||
y >= rect.top - contextMenuCloseDistance &&
|
||||
y <= rect.bottom + contextMenuCloseDistance;
|
||||
}
|
||||
|
||||
function openInNewTab(url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async function writeClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user