You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
964 lines
26 KiB
964 lines
26 KiB
<script> |
|
import { createEventDispatcher, onMount } from "svelte"; |
|
|
|
export let isLoggedIn = false; |
|
export let userPubkey = ""; |
|
export let userSigner = null; |
|
export let currentEffectiveRole = ""; |
|
|
|
const dispatch = createEventDispatcher(); |
|
|
|
let blobs = []; |
|
let isLoading = false; |
|
let error = ""; |
|
|
|
// Upload state |
|
let selectedFiles = []; |
|
let isUploading = false; |
|
let uploadProgress = ""; |
|
let fileInput; |
|
|
|
// Modal state |
|
let showModal = false; |
|
let selectedBlob = null; |
|
let zoomLevel = 1; |
|
const MIN_ZOOM = 0.25; |
|
const MAX_ZOOM = 4; |
|
const ZOOM_STEP = 0.25; |
|
|
|
$: canAccess = isLoggedIn && userPubkey; |
|
|
|
// Track if we've loaded once to prevent repeated loads |
|
let hasLoadedOnce = false; |
|
|
|
/** |
|
* Create Blossom auth header (kind 24242) per BUD-01 spec |
|
* @param {object} signer - The signer instance |
|
* @param {string} verb - The action verb (list, get, upload, delete) |
|
* @param {string} sha256Hex - Optional SHA256 hash for x tag |
|
* @returns {Promise<string|null>} Base64 encoded auth header or null |
|
*/ |
|
async function createBlossomAuth(signer, verb, sha256Hex = null) { |
|
if (!signer) { |
|
console.log("No signer available for Blossom auth"); |
|
return null; |
|
} |
|
|
|
try { |
|
const now = Math.floor(Date.now() / 1000); |
|
const expiration = now + 60; // 60 seconds from now |
|
|
|
const tags = [ |
|
["t", verb], |
|
["expiration", expiration.toString()], |
|
]; |
|
|
|
// Add x tag for blob-specific operations |
|
if (sha256Hex) { |
|
tags.push(["x", sha256Hex]); |
|
} |
|
|
|
const authEvent = { |
|
kind: 24242, |
|
created_at: now, |
|
tags: tags, |
|
content: `Blossom ${verb} operation`, |
|
}; |
|
|
|
const signedEvent = await signer.signEvent(authEvent); |
|
return btoa(JSON.stringify(signedEvent)); |
|
} catch (err) { |
|
console.error("Error creating Blossom auth:", err); |
|
return null; |
|
} |
|
} |
|
|
|
onMount(() => { |
|
if (canAccess && !hasLoadedOnce) { |
|
hasLoadedOnce = true; |
|
loadBlobs(); |
|
} |
|
}); |
|
|
|
// Load once when canAccess becomes true (for when user logs in after mount) |
|
$: if (canAccess && !hasLoadedOnce && !isLoading) { |
|
hasLoadedOnce = true; |
|
loadBlobs(); |
|
} |
|
|
|
async function loadBlobs() { |
|
if (!userPubkey) return; |
|
|
|
isLoading = true; |
|
error = ""; |
|
|
|
try { |
|
const url = `${window.location.origin}/blossom/list/${userPubkey}`; |
|
const authHeader = await createBlossomAuth(userSigner, "list"); |
|
const response = await fetch(url, { |
|
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, |
|
}); |
|
|
|
if (!response.ok) { |
|
throw new Error(`Failed to load blobs: ${response.statusText}`); |
|
} |
|
|
|
const data = await response.json(); |
|
// API returns 'uploaded' timestamp per BUD-02 spec |
|
blobs = Array.isArray(data) ? data : []; |
|
blobs.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0)); |
|
console.log("Loaded blobs:", blobs); |
|
} catch (err) { |
|
console.error("Error loading blobs:", err); |
|
error = err.message || "Failed to load blobs"; |
|
} finally { |
|
isLoading = false; |
|
} |
|
} |
|
|
|
function formatSize(bytes) { |
|
if (!bytes) return "0 B"; |
|
const units = ["B", "KB", "MB", "GB"]; |
|
let i = 0; |
|
let size = bytes; |
|
while (size >= 1024 && i < units.length - 1) { |
|
size /= 1024; |
|
i++; |
|
} |
|
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; |
|
} |
|
|
|
function formatDate(timestamp) { |
|
if (!timestamp) return "Unknown"; |
|
return new Date(timestamp * 1000).toLocaleString(); |
|
} |
|
|
|
function truncateHash(hash) { |
|
if (!hash) return ""; |
|
return `${hash.slice(0, 8)}...${hash.slice(-8)}`; |
|
} |
|
|
|
function getMimeCategory(mimeType) { |
|
if (!mimeType) return "unknown"; |
|
if (mimeType.startsWith("image/")) return "image"; |
|
if (mimeType.startsWith("video/")) return "video"; |
|
if (mimeType.startsWith("audio/")) return "audio"; |
|
return "file"; |
|
} |
|
|
|
function getMimeIcon(mimeType) { |
|
const category = getMimeCategory(mimeType); |
|
switch (category) { |
|
case "image": return ""; |
|
case "video": return ""; |
|
case "audio": return ""; |
|
default: return ""; |
|
} |
|
} |
|
|
|
function openModal(blob) { |
|
selectedBlob = blob; |
|
zoomLevel = 1; |
|
showModal = true; |
|
} |
|
|
|
function closeModal() { |
|
showModal = false; |
|
selectedBlob = null; |
|
zoomLevel = 1; |
|
} |
|
|
|
function zoomIn() { |
|
if (zoomLevel < MAX_ZOOM) { |
|
zoomLevel = Math.min(MAX_ZOOM, zoomLevel + ZOOM_STEP); |
|
} |
|
} |
|
|
|
function zoomOut() { |
|
if (zoomLevel > MIN_ZOOM) { |
|
zoomLevel = Math.max(MIN_ZOOM, zoomLevel - ZOOM_STEP); |
|
} |
|
} |
|
|
|
function handleKeydown(event) { |
|
if (!showModal) return; |
|
if (event.key === "Escape") { |
|
closeModal(); |
|
} else if (event.key === "+" || event.key === "=") { |
|
zoomIn(); |
|
} else if (event.key === "-") { |
|
zoomOut(); |
|
} |
|
} |
|
|
|
function getBlobUrl(blob) { |
|
// Prefer the URL from the API response (includes extension for proper MIME handling) |
|
if (blob.url) { |
|
// Already an absolute URL - return as-is |
|
if (blob.url.startsWith("http://") || blob.url.startsWith("https://")) { |
|
return blob.url; |
|
} |
|
// Starts with / - it's a path, prepend origin |
|
if (blob.url.startsWith("/")) { |
|
return `${window.location.origin}${blob.url}`; |
|
} |
|
// No protocol - looks like host:port/path, add http:// |
|
// This handles cases like "localhost:3334/blossom/..." |
|
return `http://${blob.url}`; |
|
} |
|
// Fallback: construct URL with sha256 only |
|
return `${window.location.origin}/blossom/${blob.sha256}`; |
|
} |
|
|
|
function openLoginModal() { |
|
dispatch("openLoginModal"); |
|
} |
|
|
|
async function deleteBlob(blob) { |
|
if (!confirm(`Delete blob ${truncateHash(blob.sha256)}?`)) return; |
|
|
|
try { |
|
const url = `${window.location.origin}/blossom/${blob.sha256}`; |
|
const authHeader = await createBlossomAuth(userSigner, "delete", blob.sha256); |
|
const response = await fetch(url, { |
|
method: "DELETE", |
|
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, |
|
}); |
|
|
|
if (!response.ok) { |
|
throw new Error(`Failed to delete: ${response.statusText}`); |
|
} |
|
|
|
blobs = blobs.filter(b => b.sha256 !== blob.sha256); |
|
if (selectedBlob?.sha256 === blob.sha256) { |
|
closeModal(); |
|
} |
|
} catch (err) { |
|
console.error("Error deleting blob:", err); |
|
alert(`Failed to delete blob: ${err.message}`); |
|
} |
|
} |
|
|
|
function handleFileSelect(event) { |
|
selectedFiles = Array.from(event.target.files); |
|
} |
|
|
|
function triggerFileInput() { |
|
fileInput?.click(); |
|
} |
|
|
|
async function uploadFiles() { |
|
if (selectedFiles.length === 0) return; |
|
|
|
isUploading = true; |
|
error = ""; |
|
const uploaded = []; |
|
const failed = []; |
|
|
|
for (let i = 0; i < selectedFiles.length; i++) { |
|
const file = selectedFiles[i]; |
|
uploadProgress = `Uploading ${i + 1}/${selectedFiles.length}: ${file.name}`; |
|
|
|
try { |
|
const url = `${window.location.origin}/blossom/upload`; |
|
const authHeader = await createBlossomAuth(userSigner, "upload"); |
|
|
|
const response = await fetch(url, { |
|
method: "PUT", |
|
headers: { |
|
"Content-Type": file.type || "application/octet-stream", |
|
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}), |
|
}, |
|
body: file, |
|
}); |
|
|
|
if (!response.ok) { |
|
const reason = response.headers.get("X-Reason") || response.statusText; |
|
throw new Error(reason); |
|
} |
|
|
|
const descriptor = await response.json(); |
|
console.log("Upload response:", descriptor); |
|
uploaded.push(descriptor); |
|
} catch (err) { |
|
console.error(`Error uploading ${file.name}:`, err); |
|
failed.push({ name: file.name, error: err.message }); |
|
} |
|
} |
|
|
|
isUploading = false; |
|
uploadProgress = ""; |
|
selectedFiles = []; |
|
if (fileInput) fileInput.value = ""; |
|
|
|
if (uploaded.length > 0) { |
|
await loadBlobs(); |
|
} |
|
|
|
if (failed.length > 0) { |
|
error = `Failed to upload: ${failed.map(f => f.name).join(", ")}`; |
|
} |
|
} |
|
</script> |
|
|
|
<svelte:window on:keydown={handleKeydown} /> |
|
|
|
{#if canAccess} |
|
<div class="blossom-view"> |
|
<div class="header-section"> |
|
<h3>Blossom Media Storage</h3> |
|
<button class="refresh-btn" on:click={loadBlobs} disabled={isLoading}> |
|
{isLoading ? "Loading..." : "Refresh"} |
|
</button> |
|
</div> |
|
|
|
<div class="upload-section"> |
|
<input |
|
type="file" |
|
multiple |
|
bind:this={fileInput} |
|
on:change={handleFileSelect} |
|
class="file-input-hidden" |
|
/> |
|
<button class="select-btn" on:click={triggerFileInput} disabled={isUploading}> |
|
Select Files |
|
</button> |
|
{#if selectedFiles.length > 0} |
|
<span class="selected-count">{selectedFiles.length} file(s) selected</span> |
|
<button |
|
class="upload-btn" |
|
on:click={uploadFiles} |
|
disabled={isUploading} |
|
> |
|
{isUploading ? uploadProgress : "Upload"} |
|
</button> |
|
{/if} |
|
</div> |
|
|
|
{#if error} |
|
<div class="error-message"> |
|
{error} |
|
</div> |
|
{/if} |
|
|
|
{#if isLoading && blobs.length === 0} |
|
<div class="loading">Loading blobs...</div> |
|
{:else if blobs.length === 0} |
|
<div class="empty-state"> |
|
<p>No files found in your Blossom storage.</p> |
|
</div> |
|
{:else} |
|
<div class="blob-list"> |
|
{#each blobs as blob} |
|
<div |
|
class="blob-item" |
|
on:click={() => openModal(blob)} |
|
on:keypress={(e) => e.key === "Enter" && openModal(blob)} |
|
role="button" |
|
tabindex="0" |
|
> |
|
<div class="blob-icon"> |
|
{getMimeIcon(blob.type)} |
|
</div> |
|
<div class="blob-info"> |
|
<div class="blob-hash" title={blob.sha256}> |
|
{truncateHash(blob.sha256)} |
|
</div> |
|
<div class="blob-meta"> |
|
<span class="blob-size">{formatSize(blob.size)}</span> |
|
<span class="blob-type">{blob.type || "unknown"}</span> |
|
</div> |
|
</div> |
|
<div class="blob-date"> |
|
{formatDate(blob.uploaded)} |
|
</div> |
|
<button |
|
class="delete-btn" |
|
on:click|stopPropagation={() => deleteBlob(blob)} |
|
title="Delete" |
|
> |
|
X |
|
</button> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{:else} |
|
<div class="login-prompt"> |
|
<p>Please log in to view your Blossom storage.</p> |
|
<button class="login-btn" on:click={openLoginModal}>Log In</button> |
|
</div> |
|
{/if} |
|
|
|
{#if showModal && selectedBlob} |
|
<div |
|
class="modal-overlay" |
|
on:click={closeModal} |
|
on:keypress={(e) => e.key === "Enter" && closeModal()} |
|
role="button" |
|
tabindex="0" |
|
> |
|
<div |
|
class="modal-content" |
|
on:click|stopPropagation |
|
on:keypress|stopPropagation |
|
role="dialog" |
|
> |
|
<div class="modal-header"> |
|
<div class="modal-title"> |
|
<span class="modal-hash">{truncateHash(selectedBlob.sha256)}</span> |
|
<span class="modal-type">{selectedBlob.type || "unknown"}</span> |
|
</div> |
|
<div class="modal-controls"> |
|
{#if getMimeCategory(selectedBlob.type) === "image"} |
|
<button class="zoom-btn" on:click={zoomOut} disabled={zoomLevel <= MIN_ZOOM}>-</button> |
|
<span class="zoom-level">{Math.round(zoomLevel * 100)}%</span> |
|
<button class="zoom-btn" on:click={zoomIn} disabled={zoomLevel >= MAX_ZOOM}>+</button> |
|
{/if} |
|
<button class="close-btn" on:click={closeModal}>X</button> |
|
</div> |
|
</div> |
|
<div class="modal-body"> |
|
{#if getMimeCategory(selectedBlob.type) === "image"} |
|
<div class="media-container" style="transform: scale({zoomLevel});"> |
|
<img src={getBlobUrl(selectedBlob)} alt="Blob content" /> |
|
</div> |
|
{:else if getMimeCategory(selectedBlob.type) === "video"} |
|
<div class="media-container"> |
|
<video controls src={getBlobUrl(selectedBlob)}> |
|
<track kind="captions" /> |
|
</video> |
|
</div> |
|
{:else if getMimeCategory(selectedBlob.type) === "audio"} |
|
<div class="media-container audio"> |
|
<audio controls src={getBlobUrl(selectedBlob)}></audio> |
|
</div> |
|
{:else} |
|
<div class="file-preview"> |
|
<div class="file-icon">{getMimeIcon(selectedBlob.type)}</div> |
|
<p>Preview not available for this file type.</p> |
|
<a href={getBlobUrl(selectedBlob)} target="_blank" rel="noopener noreferrer" class="download-link"> |
|
Download File |
|
</a> |
|
</div> |
|
{/if} |
|
</div> |
|
<div class="modal-footer"> |
|
<div class="blob-details"> |
|
<span>Size: {formatSize(selectedBlob.size)}</span> |
|
<span>Uploaded: {formatDate(selectedBlob.uploaded)}</span> |
|
</div> |
|
<div class="blob-url-section"> |
|
<input |
|
type="text" |
|
readonly |
|
value={getBlobUrl(selectedBlob)} |
|
class="blob-url-input" |
|
on:click={(e) => e.target.select()} |
|
/> |
|
<button |
|
class="copy-btn" |
|
on:click={() => { |
|
navigator.clipboard.writeText(getBlobUrl(selectedBlob)); |
|
}} |
|
> |
|
Copy |
|
</button> |
|
</div> |
|
<div class="modal-actions"> |
|
<a href={getBlobUrl(selectedBlob)} target="_blank" rel="noopener noreferrer" class="action-btn"> |
|
Open in New Tab |
|
</a> |
|
<button class="action-btn danger" on:click={() => deleteBlob(selectedBlob)}> |
|
Delete |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<style> |
|
.blossom-view { |
|
padding: 1em; |
|
max-width: 900px; |
|
} |
|
|
|
.header-section { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 1em; |
|
} |
|
|
|
.header-section h3 { |
|
margin: 0; |
|
color: var(--text-color); |
|
} |
|
|
|
.refresh-btn { |
|
background-color: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
padding: 0.5em 1em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 0.9em; |
|
} |
|
|
|
.refresh-btn:hover:not(:disabled) { |
|
background-color: var(--accent-hover-color); |
|
} |
|
|
|
.refresh-btn:disabled { |
|
opacity: 0.6; |
|
cursor: not-allowed; |
|
} |
|
|
|
.upload-section { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.75em; |
|
padding: 0.75em 1em; |
|
background-color: var(--card-bg); |
|
border-radius: 6px; |
|
margin-bottom: 1em; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.file-input-hidden { |
|
display: none; |
|
} |
|
|
|
.select-btn { |
|
background-color: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
padding: 0.5em 1em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 0.9em; |
|
} |
|
|
|
.select-btn:hover:not(:disabled) { |
|
background-color: var(--accent-hover-color); |
|
} |
|
|
|
.select-btn:disabled { |
|
opacity: 0.6; |
|
cursor: not-allowed; |
|
} |
|
|
|
.selected-count { |
|
color: var(--text-color); |
|
font-size: 0.9em; |
|
} |
|
|
|
.upload-btn { |
|
background-color: var(--success, #28a745); |
|
color: white; |
|
border: none; |
|
padding: 0.5em 1em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 0.9em; |
|
font-weight: bold; |
|
} |
|
|
|
.upload-btn:hover:not(:disabled) { |
|
opacity: 0.9; |
|
} |
|
|
|
.upload-btn:disabled { |
|
opacity: 0.7; |
|
cursor: not-allowed; |
|
} |
|
|
|
.error-message { |
|
background-color: var(--warning); |
|
color: var(--text-color); |
|
padding: 0.75em 1em; |
|
border-radius: 4px; |
|
margin-bottom: 1em; |
|
} |
|
|
|
.loading, .empty-state { |
|
text-align: center; |
|
padding: 2em; |
|
color: var(--text-color); |
|
opacity: 0.7; |
|
} |
|
|
|
.blob-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5em; |
|
} |
|
|
|
.blob-item { |
|
display: flex; |
|
align-items: center; |
|
gap: 1em; |
|
padding: 0.75em 1em; |
|
background-color: var(--card-bg); |
|
border-radius: 6px; |
|
cursor: pointer; |
|
transition: background-color 0.2s; |
|
} |
|
|
|
.blob-item:hover { |
|
background-color: var(--sidebar-bg); |
|
} |
|
|
|
.blob-icon { |
|
font-size: 1.5em; |
|
width: 2em; |
|
text-align: center; |
|
} |
|
|
|
.blob-info { |
|
flex: 1; |
|
min-width: 0; |
|
} |
|
|
|
.blob-hash { |
|
font-family: monospace; |
|
font-size: 0.9em; |
|
color: var(--text-color); |
|
} |
|
|
|
.blob-meta { |
|
display: flex; |
|
gap: 1em; |
|
font-size: 0.8em; |
|
color: var(--text-color); |
|
opacity: 0.7; |
|
margin-top: 0.25em; |
|
} |
|
|
|
.blob-date { |
|
font-size: 0.85em; |
|
color: var(--text-color); |
|
opacity: 0.6; |
|
white-space: nowrap; |
|
} |
|
|
|
.delete-btn { |
|
background: transparent; |
|
border: 1px solid var(--warning); |
|
color: var(--warning); |
|
width: 1.75em; |
|
height: 1.75em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 0.85em; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
.delete-btn:hover { |
|
background-color: var(--warning); |
|
color: var(--text-color); |
|
} |
|
|
|
.login-prompt { |
|
text-align: center; |
|
padding: 2em; |
|
background-color: var(--card-bg); |
|
border-radius: 8px; |
|
border: 1px solid var(--border-color); |
|
max-width: 32em; |
|
margin: 1em; |
|
} |
|
|
|
.login-prompt p { |
|
margin: 0 0 1.5rem 0; |
|
color: var(--text-color); |
|
font-size: 1.1rem; |
|
} |
|
|
|
.login-btn { |
|
background-color: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
padding: 0.75em 1.5em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-weight: bold; |
|
font-size: 0.9em; |
|
} |
|
|
|
.login-btn:hover { |
|
background-color: var(--accent-hover-color); |
|
} |
|
|
|
/* Modal styles */ |
|
.modal-overlay { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background-color: rgba(0, 0, 0, 0.8); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
z-index: 1000; |
|
} |
|
|
|
.modal-content { |
|
background-color: var(--bg-color); |
|
border-radius: 8px; |
|
max-width: 90vw; |
|
max-height: 90vh; |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
} |
|
|
|
.modal-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
padding: 0.75em 1em; |
|
border-bottom: 1px solid var(--border-color); |
|
background-color: var(--card-bg); |
|
} |
|
|
|
.modal-title { |
|
display: flex; |
|
align-items: center; |
|
gap: 1em; |
|
} |
|
|
|
.modal-hash { |
|
font-family: monospace; |
|
color: var(--text-color); |
|
} |
|
|
|
.modal-type { |
|
font-size: 0.85em; |
|
color: var(--text-color); |
|
opacity: 0.7; |
|
} |
|
|
|
.modal-controls { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5em; |
|
} |
|
|
|
.zoom-btn { |
|
background-color: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
width: 2em; |
|
height: 2em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 1em; |
|
font-weight: bold; |
|
} |
|
|
|
.zoom-btn:hover:not(:disabled) { |
|
background-color: var(--accent-hover-color); |
|
} |
|
|
|
.zoom-btn:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
.zoom-level { |
|
font-size: 0.85em; |
|
color: var(--text-color); |
|
min-width: 3em; |
|
text-align: center; |
|
} |
|
|
|
.close-btn { |
|
background: transparent; |
|
border: 1px solid var(--border-color); |
|
color: var(--text-color); |
|
width: 2em; |
|
height: 2em; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 1em; |
|
margin-left: 0.5em; |
|
} |
|
|
|
.close-btn:hover { |
|
background-color: var(--warning); |
|
border-color: var(--warning); |
|
} |
|
|
|
.modal-body { |
|
flex: 1; |
|
overflow: auto; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
padding: 1em; |
|
min-height: 200px; |
|
} |
|
|
|
.media-container { |
|
transition: transform 0.2s ease; |
|
transform-origin: center center; |
|
} |
|
|
|
.media-container img { |
|
max-width: 80vw; |
|
max-height: 70vh; |
|
object-fit: contain; |
|
} |
|
|
|
.media-container video { |
|
max-width: 80vw; |
|
max-height: 70vh; |
|
} |
|
|
|
.media-container.audio { |
|
width: 100%; |
|
padding: 2em; |
|
} |
|
|
|
.media-container audio { |
|
width: 100%; |
|
} |
|
|
|
.file-preview { |
|
text-align: center; |
|
padding: 2em; |
|
color: var(--text-color); |
|
} |
|
|
|
.file-icon { |
|
font-size: 4em; |
|
margin-bottom: 0.5em; |
|
} |
|
|
|
.download-link { |
|
display: inline-block; |
|
margin-top: 1em; |
|
padding: 0.75em 1.5em; |
|
background-color: var(--primary); |
|
color: var(--text-color); |
|
text-decoration: none; |
|
border-radius: 4px; |
|
} |
|
|
|
.download-link:hover { |
|
background-color: var(--accent-hover-color); |
|
} |
|
|
|
.modal-footer { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5em; |
|
padding: 0.75em 1em; |
|
border-top: 1px solid var(--border-color); |
|
background-color: var(--card-bg); |
|
} |
|
|
|
.blob-details { |
|
display: flex; |
|
gap: 1.5em; |
|
font-size: 0.85em; |
|
color: var(--text-color); |
|
opacity: 0.7; |
|
} |
|
|
|
.blob-url-section { |
|
display: flex; |
|
gap: 0.5em; |
|
width: 100%; |
|
} |
|
|
|
.blob-url-input { |
|
flex: 1; |
|
padding: 0.4em 0.6em; |
|
font-family: monospace; |
|
font-size: 0.85em; |
|
background-color: var(--bg-color); |
|
color: var(--text-color); |
|
border: 1px solid var(--border-color); |
|
border-radius: 4px; |
|
cursor: text; |
|
} |
|
|
|
.blob-url-input:focus { |
|
outline: none; |
|
border-color: var(--primary); |
|
} |
|
|
|
.copy-btn { |
|
padding: 0.4em 0.8em; |
|
background-color: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 0.85em; |
|
} |
|
|
|
.copy-btn:hover { |
|
background-color: var(--accent-hover-color); |
|
} |
|
|
|
.modal-actions { |
|
display: flex; |
|
gap: 0.5em; |
|
} |
|
|
|
.action-btn { |
|
padding: 0.5em 1em; |
|
background-color: var(--primary); |
|
color: var(--text-color); |
|
border: none; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
text-decoration: none; |
|
font-size: 0.9em; |
|
} |
|
|
|
.action-btn:hover { |
|
background-color: var(--accent-hover-color); |
|
} |
|
|
|
.action-btn.danger { |
|
background-color: transparent; |
|
border: 1px solid var(--warning); |
|
color: var(--warning); |
|
} |
|
|
|
.action-btn.danger:hover { |
|
background-color: var(--warning); |
|
color: var(--text-color); |
|
} |
|
|
|
@media (max-width: 600px) { |
|
.blob-item { |
|
flex-wrap: wrap; |
|
} |
|
|
|
.blob-date { |
|
width: 100%; |
|
margin-top: 0.5em; |
|
padding-left: 3em; |
|
} |
|
|
|
.modal-footer { |
|
flex-direction: column; |
|
gap: 0.75em; |
|
} |
|
|
|
.blob-details { |
|
flex-direction: column; |
|
gap: 0.25em; |
|
} |
|
} |
|
</style>
|
|
|