Compare commits
7 Commits
a5aa45759c
...
d48eaf388c
| Author | SHA1 | Date | |
|---|---|---|---|
| d48eaf388c | |||
| 1004b4ab7f | |||
| 4de027cfc3 | |||
| 5329844264 | |||
| 04df7dfb83 | |||
| a12c73eba8 | |||
| eb2c7ab45c |
@@ -5,8 +5,8 @@ MAX_CONCURRENT=3
|
|||||||
ADMIN_PASSWORD=test123
|
ADMIN_PASSWORD=test123
|
||||||
TIMEZONE=America/Edmonton
|
TIMEZONE=America/Edmonton
|
||||||
|
|
||||||
# Public uploader page (optional) — disabled by default
|
# Public uploader page (optional)
|
||||||
PUBLIC_UPLOAD_PAGE_ENABLED=false
|
PUBLIC_UPLOAD_PAGE_ENABLED=true
|
||||||
|
|
||||||
# Local dedupe cache (SQLite)
|
# Local dedupe cache (SQLite)
|
||||||
STATE_DB=./data/state.db
|
STATE_DB=./data/state.db
|
||||||
@@ -20,8 +20,8 @@ LOG_LEVEL=INFO
|
|||||||
# Chunked uploads (to work around 100MB proxy limits)
|
# Chunked uploads (to work around 100MB proxy limits)
|
||||||
# Enable to send files in chunks from browser to this service
|
# Enable to send files in chunks from browser to this service
|
||||||
# Recommended chunk size for Cloudflare Tunnel is <= 95MB.
|
# Recommended chunk size for Cloudflare Tunnel is <= 95MB.
|
||||||
CHUNKED_UPLOADS_ENABLED=false
|
CHUNKED_UPLOADS_ENABLED=true
|
||||||
CHUNK_SIZE_MB=95
|
CHUNK_SIZE_MB=50
|
||||||
|
|
||||||
# Custom session secrets
|
# Custom session secrets
|
||||||
# By default, a random one is generated
|
# By default, a random one is generated
|
||||||
|
|||||||
29
app/app.py
29
app/app.py
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -216,7 +217,7 @@ def read_exif_datetimes(file_bytes: bytes):
|
|||||||
try:
|
try:
|
||||||
return datetime.strptime(s, "%Y:%m:%d %H:%M:%S")
|
return datetime.strptime(s, "%Y:%m:%d %H:%M:%S")
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None, None
|
||||||
if isinstance(dt_original, str):
|
if isinstance(dt_original, str):
|
||||||
created = parse_dt(dt_original)
|
created = parse_dt(dt_original)
|
||||||
if isinstance(dt_modified, str):
|
if isinstance(dt_modified, str):
|
||||||
@@ -226,6 +227,19 @@ def read_exif_datetimes(file_bytes: bytes):
|
|||||||
return created, modified
|
return created, modified
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
Normalizes string, converts to lowercase, removes non-alpha characters,
|
||||||
|
and converts spaces to hyphens. Max length is 64.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
value = str(value).strip()[:64]
|
||||||
|
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
|
||||||
|
value = re.sub(r'[-\s]+', '-', value)
|
||||||
|
return value.strip('-')
|
||||||
|
|
||||||
|
|
||||||
def _hash_password(pw: str) -> str:
|
def _hash_password(pw: str) -> str:
|
||||||
"""Return PBKDF2-SHA256 hash of a password."""
|
"""Return PBKDF2-SHA256 hash of a password."""
|
||||||
try:
|
try:
|
||||||
@@ -254,7 +268,7 @@ def _verify_password(stored: str, pw: Optional[str]) -> bool:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_or_create_album_dir(album_name: str) -> str:
|
def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = None) -> str:
|
||||||
"""Get or create a directory for an album. Returns the path."""
|
"""Get or create a directory for an album. Returns the path."""
|
||||||
if not album_name or not isinstance(album_name, str):
|
if not album_name or not isinstance(album_name, str):
|
||||||
album_name = "public"
|
album_name = "public"
|
||||||
@@ -263,6 +277,10 @@ def get_or_create_album_dir(album_name: str) -> str:
|
|||||||
try:
|
try:
|
||||||
tz = pytz.timezone(SETTINGS.timezone)
|
tz = pytz.timezone(SETTINGS.timezone)
|
||||||
today = datetime.now(tz).strftime('%Y-%m-%d')
|
today = datetime.now(tz).strftime('%Y-%m-%d')
|
||||||
|
if public_subfolder:
|
||||||
|
slug = slugify(public_subfolder)
|
||||||
|
if slug:
|
||||||
|
today = f"{today}-{slug}"
|
||||||
save_dir = os.path.join("./data/uploads", "public", today)
|
save_dir = os.path.join("./data/uploads", "public", today)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Timezone logic failed, falling back to 'public' album. Timezone: %s. Error: %s", SETTINGS.timezone, e)
|
logger.warning("Timezone logic failed, falling back to 'public' album. Timezone: %s. Error: %s", SETTINGS.timezone, e)
|
||||||
@@ -364,6 +382,7 @@ async def api_upload(
|
|||||||
last_modified: Optional[int] = Form(None),
|
last_modified: Optional[int] = Form(None),
|
||||||
invite_token: Optional[str] = Form(None),
|
invite_token: Optional[str] = Form(None),
|
||||||
fingerprint: Optional[str] = Form(None),
|
fingerprint: Optional[str] = Form(None),
|
||||||
|
public_folder_name: Optional[str] = Form(None),
|
||||||
):
|
):
|
||||||
"""Receive a file, check duplicates, forward to Immich; stream progress via WS."""
|
"""Receive a file, check duplicates, forward to Immich; stream progress via WS."""
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
@@ -481,7 +500,7 @@ async def api_upload(
|
|||||||
await send_progress(session_id, item_id, "error", 100, "Public uploads disabled")
|
await send_progress(session_id, item_id, "error", 100, "Public uploads disabled")
|
||||||
return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
|
return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
|
||||||
try:
|
try:
|
||||||
save_dir = get_or_create_album_dir(album_for_saving)
|
save_dir = get_or_create_album_dir(album_for_saving, public_folder_name)
|
||||||
safe_name = sanitize_filename(file.filename)
|
safe_name = sanitize_filename(file.filename)
|
||||||
save_path = os.path.join(save_dir, safe_name)
|
save_path = os.path.join(save_dir, safe_name)
|
||||||
# Avoid overwriting
|
# Avoid overwriting
|
||||||
@@ -588,6 +607,7 @@ async def api_upload_chunk_init(request: Request) -> JSONResponse:
|
|||||||
"last_modified": (data or {}).get("last_modified"),
|
"last_modified": (data or {}).get("last_modified"),
|
||||||
"invite_token": (data or {}).get("invite_token"),
|
"invite_token": (data or {}).get("invite_token"),
|
||||||
"content_type": (data or {}).get("content_type") or "application/octet-stream",
|
"content_type": (data or {}).get("content_type") or "application/octet-stream",
|
||||||
|
"public_folder_name": (data or {}).get("public_folder_name"),
|
||||||
"created_at": datetime.utcnow().isoformat(),
|
"created_at": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
with open(os.path.join(d, "meta.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(d, "meta.json"), "w", encoding="utf-8") as f:
|
||||||
@@ -659,6 +679,7 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
|
|||||||
meta = json.load(f)
|
meta = json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
meta = {}
|
meta = {}
|
||||||
|
public_folder_name = meta.get("public_folder_name")
|
||||||
total_chunks = int(meta.get("total_chunks") or (data or {}).get("total_chunks") or 0)
|
total_chunks = int(meta.get("total_chunks") or (data or {}).get("total_chunks") or 0)
|
||||||
if total_chunks <= 0:
|
if total_chunks <= 0:
|
||||||
return JSONResponse({"error": "missing_total"}, status_code=400)
|
return JSONResponse({"error": "missing_total"}, status_code=400)
|
||||||
@@ -811,7 +832,7 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
|
|||||||
return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
|
return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
save_dir = get_or_create_album_dir(album_for_saving)
|
save_dir = get_or_create_album_dir(album_for_saving, public_folder_name)
|
||||||
safe_name = sanitize_filename(file_like_name)
|
safe_name = sanitize_filename(file_like_name)
|
||||||
save_path = os.path.join(save_dir, safe_name)
|
save_path = os.path.join(save_dir, safe_name)
|
||||||
if os.path.exists(save_path):
|
if os.path.exists(save_path):
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ async function uploadWhole(next){
|
|||||||
form.append('session_id', sessionId);
|
form.append('session_id', sessionId);
|
||||||
form.append('last_modified', next.file.lastModified || '');
|
form.append('last_modified', next.file.lastModified || '');
|
||||||
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
|
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
|
||||||
|
const publicFolderName = document.getElementById('publicFolderName')?.value?.trim();
|
||||||
|
if (publicFolderName) form.append('public_folder_name', publicFolderName);
|
||||||
form.append('fingerprint', FINGERPRINT);
|
form.append('fingerprint', FINGERPRINT);
|
||||||
const res = await fetch('/api/upload', { method:'POST', body: form });
|
const res = await fetch('/api/upload', { method:'POST', body: form });
|
||||||
const body = await res.json().catch(()=>({}));
|
const body = await res.json().catch(()=>({}));
|
||||||
@@ -240,6 +242,7 @@ async function uploadWhole(next){
|
|||||||
async function uploadChunked(next){
|
async function uploadChunked(next){
|
||||||
const chunkBytes = Math.max(1, CFG.chunk_size_mb|0) * 1024 * 1024;
|
const chunkBytes = Math.max(1, CFG.chunk_size_mb|0) * 1024 * 1024;
|
||||||
const total = Math.ceil(next.file.size / chunkBytes) || 1;
|
const total = Math.ceil(next.file.size / chunkBytes) || 1;
|
||||||
|
const publicFolderName = document.getElementById('publicFolderName')?.value?.trim();
|
||||||
// init
|
// init
|
||||||
try {
|
try {
|
||||||
await fetch('/api/upload/chunk/init', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({
|
await fetch('/api/upload/chunk/init', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({
|
||||||
@@ -250,7 +253,8 @@ async function uploadChunked(next){
|
|||||||
last_modified: next.file.lastModified || '',
|
last_modified: next.file.lastModified || '',
|
||||||
invite_token: INVITE_TOKEN || '',
|
invite_token: INVITE_TOKEN || '',
|
||||||
content_type: next.file.type || 'application/octet-stream',
|
content_type: next.file.type || 'application/octet-stream',
|
||||||
fingerprint: FINGERPRINT
|
fingerprint: FINGERPRINT,
|
||||||
|
public_folder_name: publicFolderName || ''
|
||||||
}) });
|
}) });
|
||||||
} catch {}
|
} catch {}
|
||||||
// upload parts
|
// upload parts
|
||||||
@@ -372,8 +376,7 @@ if (btnPing) btnPing.onclick = async () => {
|
|||||||
dz.addEventListener('drop', (e)=>{
|
dz.addEventListener('drop', (e)=>{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const files = Array.from(e.dataTransfer.files || []);
|
const files = Array.from(e.dataTransfer.files || []);
|
||||||
const accepted = files.filter(f => /^(image|video)\//.test(f.type) || /\.(jpe?g|png|heic|heif|webp|gif|tiff|bmp|mp4|mov|m4v|avi|mkv)$/i.test(f.name));
|
files.forEach(addItem);
|
||||||
accepted.forEach(addItem);
|
|
||||||
render();
|
render();
|
||||||
runQueue();
|
runQueue();
|
||||||
});
|
});
|
||||||
@@ -409,11 +412,7 @@ fi.onchange = () => {
|
|||||||
suppressClicksUntil = Date.now() + 800;
|
suppressClicksUntil = Date.now() + 800;
|
||||||
|
|
||||||
const files = Array.from(fi.files || []);
|
const files = Array.from(fi.files || []);
|
||||||
const accepted = files.filter(f =>
|
files.forEach(addItem);
|
||||||
/^(image|video)\//.test(f.type) ||
|
|
||||||
/\.(jpe?g|png|heic|heif|webp|gif|tiff|bmp|mp4|mov|m4v|avi|mkv)$/i.test(f.name)
|
|
||||||
);
|
|
||||||
accepted.forEach(addItem);
|
|
||||||
render();
|
render();
|
||||||
runQueue();
|
runQueue();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Image Drop Uploader</title>
|
<title>File Drop Uploader</title>
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -17,18 +17,9 @@
|
|||||||
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
|
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
|
||||||
|
|
||||||
<header class="flex items-center justify-between flex-wrap gap-2">
|
<header class="flex items-center justify-between flex-wrap gap-2">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Image Drop Uploader</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">File Drop Uploader</h1>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="/login" class="rounded-xl border px-4 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" aria-label="Login">Login</a>
|
<a href="/login" class="rounded-xl border px-4 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" aria-label="Login">Login</a>
|
||||||
<button id="btnTheme" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" title="Toggle dark mode" aria-label="Toggle theme">
|
|
||||||
<svg id="iconLight" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="btnPing" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" aria-label="Test connection">Test connection</button>
|
|
||||||
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400 hidden sm:inline"></span>
|
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400 hidden sm:inline"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -39,7 +30,7 @@
|
|||||||
<!-- upload icon -->
|
<!-- upload icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 font-medium hidden md:block">Drop images or videos here</p>
|
<p class="mt-3 font-medium hidden md:block">Drop files here</p>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
|
||||||
|
|
||||||
<!-- Mobile-safe choose control: label wraps the hidden input -->
|
<!-- Mobile-safe choose control: label wraps the hidden input -->
|
||||||
@@ -49,15 +40,15 @@
|
|||||||
<input id="fileInput"
|
<input id="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept="image/*,video/*"
|
|
||||||
class="absolute inset-0 opacity-0 cursor-pointer" />
|
class="absolute inset-0 opacity-0 cursor-pointer" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
We never show uploaded media and keep everything session-local. No account required.
|
<label for="publicFolderName" class="font-medium">Optional folder name:</label>
|
||||||
|
<input type="text" id="publicFolderName" name="publicFolderName" maxlength="64" class="mt-1 mx-auto w-full max-w-sm rounded-lg border border-gray-300 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500">
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Queue summary -->
|
<!-- Queue summary -->
|
||||||
<section id="summary" class="rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700 transition-colors">
|
<section id="summary" class="rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700 transition-colors">
|
||||||
@@ -82,7 +73,6 @@
|
|||||||
<section id="items" class="space-y-3"></section>
|
<section id="items" class="space-y-3"></section>
|
||||||
|
|
||||||
<footer class="pt-4 pb-10 text-center text-xs text-gray-500 dark:text-gray-400">
|
<footer class="pt-4 pb-10 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
Built for simple, account-less image uploads. This page never lists media from the server and only shows your current session's items.
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Image Drop Uploader (Invite)</title>
|
<title>File Drop Uploader (Invite)</title>
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
|
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
|
||||||
|
|
||||||
<header class="flex items-center justify-between flex-wrap gap-2">
|
<header class="flex items-center justify-between flex-wrap gap-2">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Image Drop Uploader</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">File Drop Uploader</h1>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a id="linkHome" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Home</a>
|
<a id="linkHome" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Home</a>
|
||||||
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
|
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
|
||||||
@@ -68,12 +68,12 @@
|
|||||||
<div id="dropHint" class="mx-auto h-12 w-12 opacity-70 hidden md:block">
|
<div id="dropHint" class="mx-auto h-12 w-12 opacity-70 hidden md:block">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 font-medium hidden md:block">Drop images or videos here</p>
|
<p class="mt-3 font-medium hidden md:block">Drop files here</p>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
|
||||||
<div class="mt-3 relative inline-block">
|
<div class="mt-3 relative inline-block">
|
||||||
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none" aria-label="Choose files">
|
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none" aria-label="Choose files">
|
||||||
Choose files
|
Choose files
|
||||||
<input id="fileInput" type="file" multiple accept="image/*,video/*" class="absolute inset-0 opacity-0 cursor-pointer" />
|
<input id="fileInput" type="file" multiple class="absolute inset-0 opacity-0 cursor-pointer" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
Reference in New Issue
Block a user