Sharing fixes, live preview
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m19s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m19s
This commit is contained in:
parent
27fd6fa4f5
commit
a52f1d63f7
4 changed files with 350 additions and 92 deletions
|
|
@ -1,10 +1,13 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.elijahkuntz.com/Elijah/drive/config"
|
"git.elijahkuntz.com/Elijah/drive/config"
|
||||||
|
|
@ -194,27 +197,58 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send file info
|
// Send file info
|
||||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
targetRelPath := filepath.Clean(filepath.FromSlash(path))
|
||||||
|
if subPath := c.Query("path"); subPath != "" {
|
||||||
|
cleanSub := filepath.Clean(filepath.FromSlash(subPath))
|
||||||
|
if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid path"})
|
||||||
|
}
|
||||||
|
targetRelPath = filepath.Join(targetRelPath, cleanSub)
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(h.Config.StorageDir, targetRelPath)
|
||||||
info, err := os.Stat(fullPath)
|
info, err := os.Stat(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// We return a signed download token or just allow the immediate request.
|
|
||||||
// For simplicity, we'll return a short-lived download token JWT.
|
|
||||||
// Actually, easier: the frontend calls GET /api/public/download/:id?pwd=xxx
|
|
||||||
|
|
||||||
mimeType := mime.TypeByExtension(filepath.Ext(info.Name()))
|
mimeType := mime.TypeByExtension(filepath.Ext(info.Name()))
|
||||||
if mimeType == "" {
|
if mimeType == "" {
|
||||||
mimeType = "application/octet-stream"
|
mimeType = "application/octet-stream"
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
response := fiber.Map{
|
||||||
"name": filepath.Base(path),
|
"name": info.Name(), // Use info.Name() instead of base of original path
|
||||||
"size": info.Size(),
|
"size": info.Size(),
|
||||||
"is_dir": info.IsDir(),
|
"is_dir": info.IsDir(),
|
||||||
"mime_type": mimeType,
|
"mime_type": mimeType,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
var files []fiber.Map
|
||||||
|
entries, err := os.ReadDir(fullPath)
|
||||||
|
if err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryInfo, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eMime := mime.TypeByExtension(filepath.Ext(entry.Name()))
|
||||||
|
if eMime == "" {
|
||||||
|
eMime = "application/octet-stream"
|
||||||
|
}
|
||||||
|
files = append(files, fiber.Map{
|
||||||
|
"name": entry.Name(),
|
||||||
|
"size": entryInfo.Size(),
|
||||||
|
"is_dir": entry.IsDir(),
|
||||||
|
"mime_type": eMime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response["files"] = files
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadPublicShare serves the file.
|
// DownloadPublicShare serves the file.
|
||||||
|
|
@ -252,11 +286,67 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
targetRelPath := filepath.Clean(filepath.FromSlash(path))
|
||||||
|
if subPath := c.Query("path"); subPath != "" {
|
||||||
|
cleanSub := filepath.Clean(filepath.FromSlash(subPath))
|
||||||
|
if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") {
|
||||||
|
return c.Status(fiber.StatusForbidden).SendString("invalid path")
|
||||||
|
}
|
||||||
|
targetRelPath = filepath.Join(targetRelPath, cleanSub)
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(h.Config.StorageDir, targetRelPath)
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).SendString("not found")
|
||||||
|
}
|
||||||
|
|
||||||
// Increment download counter
|
// Increment download counter
|
||||||
h.DB.Exec(`UPDATE share_links SET download_count = download_count + 1 WHERE token = ?`, id)
|
h.DB.Exec(`UPDATE share_links SET download_count = download_count + 1 WHERE token = ?`, id)
|
||||||
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP())
|
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s path %s", id, targetRelPath), c.IP())
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
c.Set("Content-Type", "application/zip")
|
||||||
|
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", filepath.Base(fullPath)))
|
||||||
|
|
||||||
|
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||||
|
zipWriter := zip.NewWriter(w)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
filepath.Walk(fullPath, func(p string, i os.FileInfo, e error) error {
|
||||||
|
if e != nil || p == fullPath {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, e := filepath.Rel(fullPath, p)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
if i.IsDir() {
|
||||||
|
relPath += "/"
|
||||||
|
_, e = zipWriter.Create(relPath)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
zipFile, e := zipWriter.Create(relPath)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
fsFile, e := os.Open(p)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer fsFile.Close()
|
||||||
|
|
||||||
|
// Write file to zip
|
||||||
|
_, e = bufio.NewReader(fsFile).WriteTo(zipFile)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return c.SendFile(fullPath)
|
return c.SendFile(fullPath)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { use, useState, useEffect } from 'react';
|
import { use, useState, useEffect } from 'react';
|
||||||
import { Shield, Download, Lock, File, Folder, Loader2 } from 'lucide-react';
|
import { Shield, Download, Lock, File, Folder, Loader2, ChevronRight, Home, Image as ImageIcon, Film, Music, FileText, Archive } from 'lucide-react';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface FileInfo {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
is_dir: boolean;
|
||||||
|
mime_type?: string;
|
||||||
|
files?: FileInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function PublicSharePage({ params }: { params: Promise<{ id: string }> }) {
|
export default function PublicSharePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const resolvedParams = use(params);
|
const resolvedParams = use(params);
|
||||||
const id = resolvedParams.id;
|
const id = resolvedParams.id;
|
||||||
|
|
@ -11,17 +19,18 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [requirePassword, setRequirePassword] = useState(false);
|
const [requirePassword, setRequirePassword] = useState(false);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [fileInfo, setFileInfo] = useState<{ name: string; size: number; is_dir: boolean; mime_type?: string } | null>(null);
|
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);
|
||||||
|
const [currentPath, setCurrentPath] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchShareInfo();
|
fetchShareInfo(password, currentPath);
|
||||||
}, []);
|
}, [currentPath]);
|
||||||
|
|
||||||
async function fetchShareInfo(pwd?: string) {
|
async function fetchShareInfo(pwd?: string, path?: string) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.getPublicShare(id, pwd);
|
const data = await api.getPublicShare(id, pwd, path);
|
||||||
if (data.require_password) {
|
if (data.require_password) {
|
||||||
setRequirePassword(true);
|
setRequirePassword(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -41,11 +50,12 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
|
||||||
|
|
||||||
function handlePasswordSubmit(e: React.FormEvent) {
|
function handlePasswordSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fetchShareInfo(password);
|
fetchShareInfo(password, currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload(specificPath?: string) {
|
||||||
const url = api.getPublicDownloadUrl(id, password);
|
const downloadPath = specificPath ? (currentPath ? `${currentPath}/${specificPath}` : specificPath) : currentPath;
|
||||||
|
const url = api.getPublicDownloadUrl(id, password, downloadPath);
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,21 +66,31 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
function getFileIcon(isDir: boolean, mimeType?: string) {
|
||||||
|
if (isDir) return <Folder className="w-5 h-5 text-blue-500" />;
|
||||||
|
if (mimeType?.startsWith('image/')) return <ImageIcon className="w-5 h-5 text-green-500" />;
|
||||||
|
if (mimeType?.startsWith('video/')) return <Film className="w-5 h-5 text-purple-500" />;
|
||||||
|
if (mimeType?.startsWith('audio/')) return <Music className="w-5 h-5 text-yellow-500" />;
|
||||||
|
if (mimeType === 'application/pdf') return <FileText className="w-5 h-5 text-red-500" />;
|
||||||
|
if (mimeType === 'application/zip' || mimeType === 'application/x-gzip') return <Archive className="w-5 h-5 text-orange-500" />;
|
||||||
|
return <File className="w-5 h-5 text-gray-400" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !fileInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
|
||||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
|
<Loader2 className="w-8 h-8 animate-spin text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
|
||||||
<div className="max-w-md w-full p-8 rounded-2xl text-center shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
<div className="max-w-md w-full p-8 rounded-3xl text-center bg-white/10 backdrop-blur-xl border border-white/20 shadow-2xl">
|
||||||
<Shield className="w-12 h-12 mx-auto mb-4" style={{ color: 'var(--color-danger)' }} />
|
<Shield className="w-16 h-16 mx-auto mb-6 text-red-400" />
|
||||||
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Access Denied</h1>
|
<h1 className="text-2xl font-bold mb-3 text-white">Access Denied</h1>
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>{error}</p>
|
<p className="text-white/70">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -78,84 +98,188 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
|
||||||
|
|
||||||
if (requirePassword) {
|
if (requirePassword) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
|
||||||
<div className="max-w-md w-full p-8 rounded-2xl shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
<div className="max-w-md w-full p-8 rounded-3xl bg-white/10 backdrop-blur-xl border border-white/20 shadow-2xl">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
<div className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 bg-white/5 border border-white/10">
|
||||||
<Lock className="w-8 h-8" style={{ color: 'var(--color-accent)' }} />
|
<Lock className="w-10 h-10 text-white/80" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Protected File</h1>
|
<h1 className="text-2xl font-bold mb-2 text-white">Protected File</h1>
|
||||||
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>This shared file is password protected.</p>
|
<p className="text-white/60">This shared link is password protected.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
<form onSubmit={handlePasswordSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
className="w-full px-4 py-3 rounded-xl outline-none"
|
className="w-full px-5 py-4 rounded-xl outline-none bg-black/20 border border-white/10 text-white placeholder-white/40 focus:border-white/30 transition-colors"
|
||||||
style={{ backgroundColor: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full py-3 rounded-xl text-white font-medium transition-opacity hover:opacity-90 shadow-lg"
|
className="w-full py-4 rounded-xl text-white font-semibold transition-all hover:scale-[1.02] active:scale-[0.98] shadow-lg"
|
||||||
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
|
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
|
||||||
>
|
>
|
||||||
Unlock
|
Unlock Access
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const isImage = fileInfo?.mime_type?.startsWith('image/');
|
|
||||||
const isVideo = fileInfo?.mime_type?.startsWith('video/');
|
const isImage = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('image/');
|
||||||
const isAudio = fileInfo?.mime_type?.startsWith('audio/');
|
const isVideo = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('video/');
|
||||||
const isPdf = fileInfo?.mime_type === 'application/pdf';
|
const isAudio = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('audio/');
|
||||||
const downloadUrl = fileInfo ? api.getPublicDownloadUrl(id, password) : '';
|
const isPdf = !fileInfo?.is_dir && fileInfo?.mime_type === 'application/pdf';
|
||||||
|
const downloadUrl = fileInfo ? api.getPublicDownloadUrl(id, password, currentPath) : '';
|
||||||
|
|
||||||
|
// Breadcrumbs
|
||||||
|
const pathParts = currentPath.split('/').filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
<div className="min-h-screen p-4 md:p-8 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b] text-white flex flex-col items-center">
|
||||||
<div className="max-w-md w-full p-8 rounded-2xl text-center shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
<div className={`w-full ${fileInfo?.is_dir ? 'max-w-4xl' : 'max-w-md'} transition-all duration-500`}>
|
||||||
<div className="mb-6 flex justify-center">
|
|
||||||
{fileInfo?.is_dir ? (
|
{/* Header/Card */}
|
||||||
<div className="w-24 h-24 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl overflow-hidden">
|
||||||
<Folder className="w-12 h-12" style={{ color: 'var(--color-accent)' }} />
|
|
||||||
</div>
|
<div className="p-8">
|
||||||
) : isImage ? (
|
{/* Top Bar for Directories */}
|
||||||
<img src={downloadUrl} alt={fileInfo?.name} className="w-full max-h-48 object-contain rounded-xl" />
|
{fileInfo?.is_dir && (
|
||||||
) : isVideo ? (
|
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
|
||||||
<video src={downloadUrl} controls className="w-full max-h-48 rounded-xl" />
|
<div className="flex items-center gap-2 text-white/80 overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||||||
) : isAudio ? (
|
<button onClick={() => setCurrentPath('')} className="hover:text-white transition-colors flex items-center gap-2">
|
||||||
<audio src={downloadUrl} controls className="w-full mt-4" />
|
<Home className="w-5 h-5" />
|
||||||
) : isPdf ? (
|
<span className="font-medium">Shared Folder</span>
|
||||||
<iframe src={downloadUrl} className="w-full h-64 rounded-xl border-none" />
|
</button>
|
||||||
) : (
|
{pathParts.map((part, idx) => {
|
||||||
<div className="w-24 h-24 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
const pathToHere = pathParts.slice(0, idx + 1).join('/');
|
||||||
<File className="w-12 h-12" style={{ color: 'var(--color-accent)' }} />
|
return (
|
||||||
</div>
|
<div key={pathToHere} className="flex items-center gap-2">
|
||||||
)}
|
<ChevronRight className="w-4 h-4 text-white/40" />
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPath(pathToHere)}
|
||||||
|
className="hover:text-white transition-colors font-medium truncate max-w-[150px]"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload()}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-medium transition-all hover:bg-white/10 bg-white/5 border border-white/10 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Download ZIP
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fileInfo?.is_dir ? (
|
||||||
|
// Single File View
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-8 flex justify-center">
|
||||||
|
{isImage ? (
|
||||||
|
<img src={downloadUrl} alt={fileInfo?.name} className="w-full max-h-64 object-contain rounded-2xl shadow-lg border border-white/10 bg-black/20" />
|
||||||
|
) : isVideo ? (
|
||||||
|
<video src={downloadUrl} controls className="w-full max-h-64 rounded-2xl shadow-lg border border-white/10 bg-black/20" />
|
||||||
|
) : isAudio ? (
|
||||||
|
<div className="w-full bg-black/20 p-6 rounded-2xl border border-white/10 shadow-lg">
|
||||||
|
<Music className="w-16 h-16 mx-auto mb-6 text-yellow-400 opacity-80" />
|
||||||
|
<audio src={downloadUrl} controls className="w-full" />
|
||||||
|
</div>
|
||||||
|
) : isPdf ? (
|
||||||
|
<iframe src={downloadUrl} className="w-full h-80 rounded-2xl border border-white/10 shadow-lg bg-white" />
|
||||||
|
) : (
|
||||||
|
<div className="w-32 h-32 rounded-3xl flex items-center justify-center bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-white/10 shadow-inner">
|
||||||
|
<File className="w-16 h-16 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold mb-2 truncate px-4 text-white" title={fileInfo?.name}>
|
||||||
|
{fileInfo?.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/60 mb-8 font-medium">
|
||||||
|
{formatSize(fileInfo?.size || 0)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-4 rounded-xl text-white font-semibold transition-all hover:scale-[1.02] active:scale-[0.98] shadow-[0_0_20px_rgba(99,102,241,0.4)]"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Download File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Directory Listing View
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/10 text-white/50 text-sm">
|
||||||
|
<th className="pb-4 font-medium pl-4">Name</th>
|
||||||
|
<th className="pb-4 font-medium">Size</th>
|
||||||
|
<th className="pb-4 font-medium text-right pr-4">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{fileInfo.files?.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="py-12 text-center text-white/40">
|
||||||
|
This folder is empty.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
fileInfo.files?.map((f) => (
|
||||||
|
<tr
|
||||||
|
key={f.name}
|
||||||
|
className={`group transition-colors ${f.is_dir ? 'cursor-pointer hover:bg-white/5' : 'hover:bg-white/5'}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (f.is_dir) {
|
||||||
|
setCurrentPath(currentPath ? `${currentPath}/${f.name}` : f.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="py-4 pl-4 flex items-center gap-4">
|
||||||
|
{getFileIcon(f.is_dir, f.mime_type)}
|
||||||
|
<span className="font-medium text-white/90 group-hover:text-white truncate max-w-[200px] sm:max-w-[400px]">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-white/50 text-sm">
|
||||||
|
{f.is_dir ? '--' : formatSize(f.size)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 pr-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDownload(f.name);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors ml-auto flex items-center justify-center"
|
||||||
|
title={`Download ${f.is_dir ? 'folder as ZIP' : 'file'}`}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-xl font-bold mb-1 truncate px-4" style={{ color: 'var(--color-text-primary)' }} title={fileInfo?.name}>
|
|
||||||
{fileInfo?.name}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm mb-8" style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
{fileInfo?.is_dir ? 'Folder' : formatSize(fileInfo?.size || 0)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-white font-medium transition-opacity hover:opacity-90 shadow-lg"
|
|
||||||
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export default function SharedFilesPage() {
|
||||||
const [shares, setShares] = useState<any[]>([]);
|
const [shares, setShares] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const [shareToRevoke, setShareToRevoke] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadShares();
|
loadShares();
|
||||||
|
|
@ -26,11 +27,16 @@ export default function SharedFilesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevoke(id: string) {
|
function handleRevoke(id: string) {
|
||||||
if (!confirm('Are you sure you want to revoke this share link?')) return;
|
setShareToRevoke(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRevoke() {
|
||||||
|
if (!shareToRevoke) return;
|
||||||
try {
|
try {
|
||||||
await api.revokeShare(id);
|
await api.revokeShare(shareToRevoke);
|
||||||
setShares(prev => prev.filter(s => s.id !== id));
|
setShares(prev => prev.filter(s => s.id !== shareToRevoke));
|
||||||
|
setShareToRevoke(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +126,36 @@ export default function SharedFilesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{shareToRevoke && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="w-full max-w-md p-6 rounded-2xl shadow-xl"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--color-text-primary)' }}>Revoke Share Link</h3>
|
||||||
|
<p className="text-sm mb-6" style={{ color: 'var(--color-text-secondary)' }}>Are you sure you want to revoke this share link? Anyone with the link will lose access immediately.</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShareToRevoke(null)}
|
||||||
|
className="px-4 py-2 rounded-xl text-sm font-medium transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmRevoke}
|
||||||
|
className="px-4 py-2 rounded-xl text-sm font-medium transition-colors hover:bg-red-500/20"
|
||||||
|
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: '#ef4444' }}
|
||||||
|
>
|
||||||
|
Revoke Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -351,9 +351,13 @@ class ApiClient {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPublicShare(id: string, password?: string) {
|
async getPublicShare(id: string, password?: string, path?: string) {
|
||||||
// Note: No auth headers here, so we use fetch directly to avoid interceptors
|
// Note: No auth headers here, so we use fetch directly to avoid interceptors
|
||||||
const res = await fetch(`${API_BASE}/api/public/share/${id}`, {
|
let url = `${API_BASE}/api/public/share/${id}`;
|
||||||
|
if (path) {
|
||||||
|
url += `?path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
|
|
@ -365,10 +369,14 @@ class ApiClient {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
getPublicDownloadUrl(id: string, password?: string) {
|
getPublicDownloadUrl(id: string, password?: string, path?: string) {
|
||||||
let url = `${API_BASE}/api/public/download/${id}`;
|
let url = `${API_BASE}/api/public/download/${id}`;
|
||||||
if (password) {
|
const params = new URLSearchParams();
|
||||||
url += `?pwd=${encodeURIComponent(password)}`;
|
if (password) params.set('pwd', password);
|
||||||
|
if (path) params.set('path', path);
|
||||||
|
const qs = params.toString();
|
||||||
|
if (qs) {
|
||||||
|
url += `?${qs}`;
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in a new issue