add visitor upload for certain people only
This commit is contained in:
parent
87985b93bf
commit
2e04516a4b
@ -5,3 +5,4 @@ dist
|
|||||||
README.md
|
README.md
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
cloudflare-worker
|
||||||
|
|||||||
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# URL of your deployed Cloudflare Worker (no trailing slash)
|
||||||
|
PUBLIC_WORKER_URL=https://r2-upload-worker.YOUR_SUBDOMAIN.workers.dev
|
||||||
|
|
||||||
|
# A random secret string — must match UPLOAD_SECRET set in the Worker
|
||||||
|
PUBLIC_UPLOAD_SECRET=change-me-to-something-random
|
||||||
@ -8,6 +8,12 @@ COPY package.json pnpm-lock.yaml ./
|
|||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ARG PUBLIC_WORKER_URL
|
||||||
|
ARG PUBLIC_UPLOAD_SECRET
|
||||||
|
ENV PUBLIC_WORKER_URL=$PUBLIC_WORKER_URL
|
||||||
|
ENV PUBLIC_UPLOAD_SECRET=$PUBLIC_UPLOAD_SECRET
|
||||||
|
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
16
cloudflare-worker/package.json
Normal file
16
cloudflare-worker/package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "r2-upload-worker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.600.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.600.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^3.60.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
cloudflare-worker/src/index.js
Normal file
73
cloudflare-worker/src/index.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
|
||||||
|
function corsHeaders(origin, allowedOrigin) {
|
||||||
|
const allow = allowedOrigin || '*';
|
||||||
|
return {
|
||||||
|
'Access-Control-Allow-Origin': allow,
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Authorization',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(data, status, headers) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status: status || 200,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const cors = corsHeaders(request.headers.get('Origin'), env.ALLOWED_ORIGIN);
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { status: 204, headers: cors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname !== '/presign') {
|
||||||
|
return new Response('Not found', { status: 404, headers: cors });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return new Response('Method not allowed', { status: 405, headers: cors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = request.headers.get('Authorization');
|
||||||
|
if (!auth || auth !== `Bearer ${env.UPLOAD_SECRET}`) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401, cors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = url.searchParams.get('filename');
|
||||||
|
if (!filename) {
|
||||||
|
return json({ error: 'Missing filename parameter' }, 400, cors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = filename.replace(/[^a-zA-Z0-9.\-_]/g, '_');
|
||||||
|
const key = `videos/${Date.now()}-${safe}`;
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: 'auto',
|
||||||
|
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: env.R2_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const presignedUrl = await getSignedUrl(client, command, { expiresIn: 3600 });
|
||||||
|
|
||||||
|
return json({ url: presignedUrl, key }, 200, cors);
|
||||||
|
} catch (err) {
|
||||||
|
return json({ error: err.message }, 500, cors);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
12
cloudflare-worker/wrangler.toml
Normal file
12
cloudflare-worker/wrangler.toml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
name = "r2-upload-worker"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2024-09-23"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
# Secrets (set via `wrangler secret put <NAME>`):
|
||||||
|
# R2_ACCOUNT_ID - your Cloudflare account ID
|
||||||
|
# R2_ACCESS_KEY_ID - R2 API token access key
|
||||||
|
# R2_SECRET_ACCESS_KEY - R2 API token secret
|
||||||
|
# R2_BUCKET_NAME - your R2 bucket name
|
||||||
|
# UPLOAD_SECRET - random string, same value used in blog .env
|
||||||
|
# ALLOWED_ORIGIN - your blog's URL, e.g. https://yourdomain.com
|
||||||
@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
PUBLIC_WORKER_URL: ${PUBLIC_WORKER_URL}
|
||||||
|
PUBLIC_UPLOAD_SECRET: ${PUBLIC_UPLOAD_SECRET}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3001:80"
|
- "3001:80"
|
||||||
|
|||||||
@ -41,6 +41,14 @@ import HeaderLink from './HeaderLink.astro';
|
|||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
</h4>
|
</h4>
|
||||||
|
<h4>
|
||||||
|
<HeaderLink
|
||||||
|
href="/upload"
|
||||||
|
class="LinkText"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</HeaderLink>
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
395
src/pages/upload.astro
Normal file
395
src/pages/upload.astro
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
---
|
||||||
|
import DefaultLayout from '../layouts/DefaultLayout.astro';
|
||||||
|
|
||||||
|
const WORKER_URL = import.meta.env.PUBLIC_WORKER_URL;
|
||||||
|
const UPLOAD_SECRET = import.meta.env.PUBLIC_UPLOAD_SECRET;
|
||||||
|
---
|
||||||
|
|
||||||
|
<DefaultLayout>
|
||||||
|
<section>
|
||||||
|
<h4>Upload</h4>
|
||||||
|
|
||||||
|
<div id="drop-zone" class="drop-zone">
|
||||||
|
<p class="drop-hint">Drag & drop files here or</p>
|
||||||
|
<label for="file-input" class="file-label">Choose files</label>
|
||||||
|
<input id="file-input" type="file" accept="video/*,image/*" multiple class="file-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-grid" class="preview-grid"></div>
|
||||||
|
|
||||||
|
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
|
||||||
|
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
</section>
|
||||||
|
</DefaultLayout>
|
||||||
|
|
||||||
|
<script define:vars={{ WORKER_URL, UPLOAD_SECRET }}>
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const previewGrid = document.getElementById('preview-grid');
|
||||||
|
const uploadBtn = document.getElementById('upload-btn');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
let selectedFiles = [];
|
||||||
|
|
||||||
|
function setStatus(msg, type) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.className = 'status ' + (type || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(incoming) {
|
||||||
|
const valid = Array.from(incoming).filter(f =>
|
||||||
|
f.type.startsWith('video/') || f.type.startsWith('image/')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (valid.length !== incoming.length) {
|
||||||
|
setStatus('Some files were skipped — only videos and images are accepted.', 'error');
|
||||||
|
} else {
|
||||||
|
setStatus('', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of valid) {
|
||||||
|
if (selectedFiles.find(f => f.name === file.name && f.size === file.size)) continue;
|
||||||
|
selectedFiles.push(file);
|
||||||
|
renderPreview(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadBtn.disabled = selectedFiles.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(file) {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'preview-card';
|
||||||
|
card.dataset.name = file.name;
|
||||||
|
|
||||||
|
let media;
|
||||||
|
if (file.type.startsWith('video/')) {
|
||||||
|
media = document.createElement('video');
|
||||||
|
media.src = objectUrl;
|
||||||
|
media.controls = true;
|
||||||
|
media.muted = true;
|
||||||
|
media.playsInline = true;
|
||||||
|
} else {
|
||||||
|
media = document.createElement('img');
|
||||||
|
media.src = objectUrl;
|
||||||
|
media.alt = file.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'preview-info';
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'preview-name';
|
||||||
|
name.textContent = file.name;
|
||||||
|
|
||||||
|
const size = document.createElement('span');
|
||||||
|
size.className = 'preview-size';
|
||||||
|
size.textContent = formatSize(file.size);
|
||||||
|
|
||||||
|
const progress = document.createElement('div');
|
||||||
|
progress.className = 'preview-progress hidden';
|
||||||
|
progress.innerHTML = `<div class="preview-bar"></div><span class="preview-pct">0%</span>`;
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'preview-remove';
|
||||||
|
removeBtn.textContent = '×';
|
||||||
|
removeBtn.addEventListener('click', () => {
|
||||||
|
selectedFiles = selectedFiles.filter(f => f !== file);
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
card.remove();
|
||||||
|
uploadBtn.disabled = selectedFiles.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
info.append(name, size, progress);
|
||||||
|
card.append(media, info, removeBtn);
|
||||||
|
previewGrid.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCardProgress(filename, pct, done, error) {
|
||||||
|
const card = previewGrid.querySelector(`[data-name="${CSS.escape(filename)}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
const wrap = card.querySelector('.preview-progress');
|
||||||
|
const bar = card.querySelector('.preview-bar');
|
||||||
|
const pctEl = card.querySelector('.preview-pct');
|
||||||
|
wrap.classList.remove('hidden');
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
pctEl.textContent = pct + '%';
|
||||||
|
if (done) card.classList.add('done');
|
||||||
|
if (error) card.classList.add('errored');
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => addFiles(e.target.files));
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('drag-over');
|
||||||
|
addFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadBtn.addEventListener('click', async () => {
|
||||||
|
if (selectedFiles.length === 0) return;
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
setStatus(`Uploading ${selectedFiles.length} file(s)…`, 'info');
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(selectedFiles.map(uploadFile));
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
if (failed === 0) {
|
||||||
|
setStatus(`All ${selectedFiles.length} file(s) uploaded successfully.`, 'success');
|
||||||
|
} else {
|
||||||
|
setStatus(`${failed} file(s) failed. Check the previews.`, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
const res = await fetch(
|
||||||
|
`${WORKER_URL}/presign?filename=${encodeURIComponent(file.name)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${UPLOAD_SECRET}` } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
setCardProgress(file.name, 0, false, true);
|
||||||
|
throw new Error(body.error || `Worker responded ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = await res.json();
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PUT', url);
|
||||||
|
xhr.setRequestHeader('Content-Type', file.type);
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
setCardProgress(file.name, Math.round((e.loaded / e.total) * 100), false, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
setCardProgress(file.name, 100, true, false);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setCardProgress(file.name, 0, false, true);
|
||||||
|
reject(new Error(`R2 upload failed: ${xhr.status}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
setCardProgress(file.name, 0, false, true);
|
||||||
|
reject(new Error('Network error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
color: #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0 0 1.2em 0;
|
||||||
|
color: #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2em 1.5em;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.drag-over {
|
||||||
|
border-color: #4ecca3;
|
||||||
|
background: rgba(78, 204, 163, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-hint {
|
||||||
|
margin: 0 0 0.6em 0;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.4em 1em;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #4ecca3;
|
||||||
|
color: #4ecca3;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label:hover {
|
||||||
|
background: #4ecca3;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview grid */
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
position: relative;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card.done {
|
||||||
|
border-color: #4ecca3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card.errored {
|
||||||
|
border-color: #e05c5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card video,
|
||||||
|
.preview-card img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
padding: 0.5em 0.6em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #ccc;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-size {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
margin-top: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-progress.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: #4ecca3;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pct {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #aaa;
|
||||||
|
min-width: 3ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card:hover .preview-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload button */
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5em 1.6em;
|
||||||
|
background: #4ecca3;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:not(:disabled):hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.info { color: #aaa; }
|
||||||
|
.status.success { color: #4ecca3; }
|
||||||
|
.status.error { color: #e05c5c; }
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user