weshare impl
This commit is contained in:
+363
@@ -0,0 +1,363 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="WeShare/WeShare.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sk" class="h-full bg-[#070b14]">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<title>WeShare — Zdieľame medzi sebou (.NET 10)</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Custom Theme Styles -->
|
||||
<link rel="stylesheet" href="css/weshare.css" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["WeShare.styles.css"]" />
|
||||
|
||||
<ImportMap />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body class="h-full text-slate-100 font-sans antialiased select-none bg-[#070b14] overflow-x-hidden">
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
|
||||
<script src="js/weshare-uploader.js"></script>
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,92 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="min-h-screen flex flex-col relative bg-[#070b14] overflow-hidden text-slate-100">
|
||||
|
||||
<!-- Ambient Glowing Connection Mesh Background -->
|
||||
<div class="fixed inset-0 pointer-events-none overflow-hidden z-0">
|
||||
<!-- Glowing Orbs -->
|
||||
<div class="absolute -top-32 -left-32 w-[550px] h-[550px] rounded-full bg-indigo-600/15 blur-[140px] animate-glow"></div>
|
||||
<div class="absolute top-1/3 -right-32 w-[500px] h-[500px] rounded-full bg-cyan-500/15 blur-[140px] animate-glow" style="animation-delay: 2.5s;"></div>
|
||||
<div class="absolute -bottom-32 left-1/3 w-[450px] h-[450px] rounded-full bg-purple-600/15 blur-[120px] animate-glow" style="animation-delay: 1.2s;"></div>
|
||||
|
||||
<!-- Subtle SVG Connection Waves & Constellations -->
|
||||
<svg class="absolute inset-0 w-full h-full opacity-25" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="layout-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#C084FC" stop-opacity="0.6"/>
|
||||
<stop offset="50%" stop-color="#38BDF8" stop-opacity="0.8"/>
|
||||
<stop offset="100%" stop-color="#818CF8" stop-opacity="0.4"/>
|
||||
</linearGradient>
|
||||
<filter id="glow-fx" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="6" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<path d="M-100,220 C300,80 600,420 1200,240 S1800,360 2200,190" fill="none" stroke="url(#layout-grad)" stroke-width="2" filter="url(#glow-fx)" stroke-dasharray="8 6"/>
|
||||
<path d="M-50,420 C400,560 700,160 1300,380 S1700,140 2100,420" fill="none" stroke="url(#layout-grad)" stroke-width="1.5" opacity="0.6"/>
|
||||
|
||||
<circle cx="20%" cy="30%" r="3.5" fill="#38BDF8" opacity="0.7" filter="url(#glow-fx)"/>
|
||||
<circle cx="80%" cy="65%" r="4" fill="#C084FC" opacity="0.7" filter="url(#glow-fx)"/>
|
||||
<circle cx="45%" cy="75%" r="2.5" fill="#818CF8" opacity="0.6"/>
|
||||
<circle cx="85%" cy="25%" r="3" fill="#22D3EE" opacity="0.7"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Header / Navbar -->
|
||||
<header class="relative z-20 border-b border-white/5 bg-slate-950/40 backdrop-blur-md px-4 lg:px-8 py-3.5">
|
||||
<div class="max-w-6xl mx-auto flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<!-- Interconnected 'W' Logo -->
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-purple-500 via-indigo-500 to-cyan-400 p-0.5 shadow-lg shadow-indigo-500/20 group-hover:shadow-cyan-500/30 transition duration-300 flex items-center justify-center">
|
||||
<div class="w-full h-full bg-slate-950 rounded-[10px] flex items-center justify-center">
|
||||
<svg class="w-6 h-6" viewBox="0 0 32 32" fill="none">
|
||||
<circle cx="7" cy="8" r="3" fill="#C084FC" />
|
||||
<circle cx="25" cy="8" r="3" fill="#22D3EE" />
|
||||
<circle cx="16" cy="19" r="3.5" fill="#818CF8" />
|
||||
<path d="M7 11L11 25L16 19L21 25L25 11" stroke="url(#w-logo-grad)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="w-logo-grad" x1="7" y1="8" x2="25" y2="8" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#C084FC"/>
|
||||
<stop offset="0.5" stop-color="#818CF8"/>
|
||||
<stop offset="1" stop-color="#22D3EE"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-extrabold text-xl tracking-tight text-white">We<span class="text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-cyan-400">Share</span></span>
|
||||
<span class="block text-[10px] text-indigo-300 font-medium tracking-wide">Zdieľame medzi sebou</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="hidden sm:inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></span>
|
||||
Limit: 5.0 GB
|
||||
</span>
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-white/5 text-slate-300 border border-white/10 font-mono">
|
||||
.NET 10
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Dynamic Content -->
|
||||
<main class="relative z-10 flex-1 flex flex-col justify-center items-center px-4 py-8 md:py-12">
|
||||
@Body
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="relative z-20 border-t border-white/5 py-4 px-4 text-center text-xs text-slate-500">
|
||||
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<span>© @DateTime.UtcNow.Year WeShare — Blesková a bezpečná výmena súborov</span>
|
||||
<span>Zero-DB Architecture • On-the-Fly ZIP Streaming</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
@page "/d/{Id}"
|
||||
@page "/download/{Id}"
|
||||
@rendermode InteractiveServer
|
||||
@inject IShareStorageService StorageService
|
||||
@inject IPasswordHasher PasswordHasher
|
||||
@inject NavigationManager NavManager
|
||||
|
||||
<PageTitle>WeShare — Stiahnutie súborov</PageTitle>
|
||||
|
||||
<div class="w-full max-w-xl mx-auto transition-all duration-300">
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="glass-panel rounded-3xl p-12 text-center shadow-2xl">
|
||||
<div class="w-12 h-12 border-4 border-cyan-400 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p class="text-sm text-slate-300 font-medium">Načítavam informácie o zdieľaní...</p>
|
||||
</div>
|
||||
}
|
||||
else if (metadata == null)
|
||||
{
|
||||
<div class="glass-panel rounded-3xl p-10 text-center shadow-2xl space-y-4">
|
||||
<div class="w-16 h-16 rounded-3xl bg-amber-500/10 text-amber-400 border border-amber-500/20 mx-auto flex items-center justify-center">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white">Súbor neexistuje alebo expiroval</h3>
|
||||
<p class="text-xs text-slate-400 max-w-sm mx-auto">Tento odkaz na stiahnutie už nie je platný, bol vymazaný alebo vypršala jeho platnosť.</p>
|
||||
<div class="pt-2">
|
||||
<a href="/" class="px-5 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-white text-xs font-semibold transition inline-block">
|
||||
Prejsť na WeShare →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (metadata.IsExpired)
|
||||
{
|
||||
<div class="glass-panel rounded-3xl p-10 text-center shadow-2xl space-y-4">
|
||||
<div class="w-16 h-16 rounded-3xl bg-red-500/10 text-red-400 border border-red-500/20 mx-auto flex items-center justify-center">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white">Platnosť odkazu vypršala</h3>
|
||||
<p class="text-xs text-slate-400 max-w-sm mx-auto">Platnosť tohto súboru vypršala @metadata.ExpiresAtUtc?.ToString("d. M. yyyy HH:mm") UTC.</p>
|
||||
<div class="pt-2">
|
||||
<a href="/" class="px-5 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-white text-xs font-semibold transition inline-block">
|
||||
Nahrať nový súbor →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (metadata.IsPasswordProtected && !isPasswordUnlocked)
|
||||
{
|
||||
<!-- Password Prompt -->
|
||||
<div class="glass-panel rounded-3xl p-8 sm:p-10 shadow-2xl space-y-6 text-center">
|
||||
<div class="w-16 h-16 rounded-3xl bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 mx-auto flex items-center justify-center shadow-lg shadow-indigo-500/10">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-white tracking-tight">Tento súbor je chránený heslom</h3>
|
||||
<p class="text-xs text-slate-400 mt-1">Pre prístup k súborom zadajte heslo od odosielateľa</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<input type="password"
|
||||
@bind="enteredPassword"
|
||||
@bind:event="oninput"
|
||||
@onkeyup="HandlePasswordKeyUp"
|
||||
placeholder="Zadajte heslo pre odomknutie..."
|
||||
class="w-full bg-slate-900/90 border border-white/15 focus:border-indigo-400 rounded-2xl px-4 py-3 text-sm text-slate-100 text-center tracking-widest placeholder:tracking-normal placeholder:text-slate-600 focus:outline-none" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(passwordError))
|
||||
{
|
||||
<p class="text-xs text-red-400 font-medium">@passwordError</p>
|
||||
}
|
||||
|
||||
<button @onclick="UnlockWithPassword"
|
||||
class="w-full py-3.5 px-4 rounded-2xl btn-primary-glow text-white font-bold text-sm transition">
|
||||
Odomknúť a zobraziť súbory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Unlocked Download View -->
|
||||
<div class="space-y-4">
|
||||
<!-- Header Info -->
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<div>
|
||||
<span class="text-[11px] uppercase tracking-wider text-cyan-400 font-bold">Prijatý balíček súborov</span>
|
||||
<h2 class="text-2xl font-black text-white tracking-tight">WeShare Sťahovanie</h2>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs text-slate-400 block">Celková veľkosť</span>
|
||||
<span class="text-sm font-bold text-slate-200">@FormatBytes(metadata.TotalSizeBytes)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel rounded-3xl p-6 sm:p-8 shadow-2xl space-y-6">
|
||||
<!-- Meta Banner -->
|
||||
<div class="glass-subpanel rounded-2xl p-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 text-cyan-400 flex items-center justify-center shrink-0">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-slate-200">@metadata.Files.Count @(metadata.Files.Count == 1 ? "súbor" : "súborov") v balíčku</div>
|
||||
<div class="text-slate-400 text-[11px]">
|
||||
@(metadata.ExpiresAtUtc.HasValue ? $"Expiruje: {metadata.ExpiresAtUtc.Value.ToLocalTime():d. M. yyyy HH:mm}" : "Bez expirácie")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2.5 py-1 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-mono text-[11px]">
|
||||
SHA-256 Overené
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
@foreach (var file in metadata.Files)
|
||||
{
|
||||
<div class="glass-subpanel rounded-2xl p-3.5 flex items-center justify-between gap-3 hover:bg-white/5 transition">
|
||||
<div class="flex items-center gap-3 truncate max-w-[70%]">
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 text-indigo-300 flex items-center justify-center font-bold text-xs shrink-0">
|
||||
@GetFileExtensionShort(file.OriginalFileName)
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<div class="font-medium text-xs text-slate-200 truncate" title="@file.OriginalFileName">@file.OriginalFileName</div>
|
||||
<div class="text-[11px] text-slate-400">@FormatBytes(file.SizeBytes)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="@GetSingleDownloadUrl(file.Id)"
|
||||
download="@file.OriginalFileName"
|
||||
class="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-cyan-300 border border-white/10 text-xs font-semibold transition flex items-center gap-1.5 shrink-0">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span>Stiahnuť</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Big Action: Download All as ZIP (On-The-Fly Streaming) -->
|
||||
@if (metadata.Files.Count > 1)
|
||||
{
|
||||
<a href="@GetZipDownloadUrl()"
|
||||
class="w-full py-3.5 px-4 rounded-2xl btn-primary-glow text-white font-bold text-sm transition flex items-center justify-center gap-2 shadow-lg block text-center">
|
||||
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span>Stiahnuť všetko ako ZIP (@FormatBytes(metadata.TotalSizeBytes))</span>
|
||||
</a>
|
||||
}
|
||||
else if (metadata.Files.Count == 1)
|
||||
{
|
||||
<a href="@GetSingleDownloadUrl(metadata.Files[0].Id)"
|
||||
download="@metadata.Files[0].OriginalFileName"
|
||||
class="w-full py-3.5 px-4 rounded-2xl btn-primary-glow text-white font-bold text-sm transition flex items-center justify-center gap-2 shadow-lg block text-center">
|
||||
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span>Stiahnuť súbor (@FormatBytes(metadata.TotalSizeBytes))</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
<div class="pt-2 text-center">
|
||||
<a href="/" class="text-xs text-slate-400 hover:text-white underline transition">
|
||||
Chcete poslať vlastné súbory? Otvoriť WeShare →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? Id { get; set; }
|
||||
|
||||
private bool isLoading = true;
|
||||
private SharePackageMetadata? metadata;
|
||||
private bool isPasswordUnlocked = false;
|
||||
private string enteredPassword = string.Empty;
|
||||
private string passwordError = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Id))
|
||||
{
|
||||
metadata = await StorageService.GetMetadataAsync(Id);
|
||||
if (metadata != null && !metadata.IsPasswordProtected)
|
||||
{
|
||||
isPasswordUnlocked = true;
|
||||
}
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private void HandlePasswordKeyUp(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
UnlockWithPassword();
|
||||
}
|
||||
}
|
||||
|
||||
private void UnlockWithPassword()
|
||||
{
|
||||
if (metadata == null) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(enteredPassword))
|
||||
{
|
||||
passwordError = "Prosím zadajte heslo.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (PasswordHasher.VerifyPassword(enteredPassword, metadata.PasswordHash!))
|
||||
{
|
||||
isPasswordUnlocked = true;
|
||||
passwordError = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
passwordError = "Nesprávne heslo. Skúste znova.";
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSingleDownloadUrl(Guid fileId)
|
||||
{
|
||||
string pwdParam = metadata?.IsPasswordProtected == true && !string.IsNullOrEmpty(enteredPassword)
|
||||
? $"?pwd={Uri.EscapeDataString(enteredPassword)}"
|
||||
: "";
|
||||
return $"/api/download/{metadata?.Id}/file/{fileId}{pwdParam}";
|
||||
}
|
||||
|
||||
private string GetZipDownloadUrl()
|
||||
{
|
||||
string pwdParam = metadata?.IsPasswordProtected == true && !string.IsNullOrEmpty(enteredPassword)
|
||||
? $"?pwd={Uri.EscapeDataString(enteredPassword)}"
|
||||
: "";
|
||||
return $"/api/download/{metadata?.Id}/zip{pwdParam}";
|
||||
}
|
||||
|
||||
private static string GetFileExtensionShort(string filename)
|
||||
{
|
||||
var ext = Path.GetExtension(filename).TrimStart('.').ToUpperInvariant();
|
||||
return string.IsNullOrWhiteSpace(ext) ? "FILE" : (ext.Length > 4 ? ext[..4] : ext);
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] suffixes = { "B", "KB", "MB", "GB", "TB" };
|
||||
int counter = 0;
|
||||
decimal number = bytes;
|
||||
while (Math.Round(number / 1024) >= 1)
|
||||
{
|
||||
number /= 1024;
|
||||
counter++;
|
||||
}
|
||||
return $"{number:n1} {suffixes[counter]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@inject IJSRuntime JS
|
||||
@inject NavigationManager NavManager
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>WeShare — Zdieľame medzi sebou</PageTitle>
|
||||
|
||||
<div class="w-full max-w-xl mx-auto transition-all duration-300">
|
||||
|
||||
<!-- Top Card Header -->
|
||||
<div class="flex items-center justify-between mb-4 px-2">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-white tracking-tight flex items-center gap-2">
|
||||
Rýchle zdieľanie
|
||||
</h2>
|
||||
<p class="text-xs text-indigo-300 font-medium">Potiahnite súbory a pošlite priamy odkaz</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 bg-slate-900/60 border border-white/10 rounded-xl px-3 py-1.5 text-xs text-slate-300">
|
||||
<svg class="w-3.5 h-3.5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
<span>Max <strong>5 GB</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Glass Card Container (Cupertino Liquid) -->
|
||||
<div class="glass-panel rounded-3xl p-6 sm:p-8 shadow-2xl relative transition-all duration-300">
|
||||
|
||||
<!-- ================= STAV 1: DROPZONE & VÝBER SÚBOROV ================= -->
|
||||
@if (currentState == UiState.SelectFiles)
|
||||
{
|
||||
<div class="space-y-5">
|
||||
<!-- Dropzone Area -->
|
||||
<div id="weshare-dropzone"
|
||||
class="dropzone-border rounded-2xl p-8 sm:p-10 text-center cursor-pointer relative bg-white/[0.02] hover:bg-white/[0.04] transition group">
|
||||
<input type="file" id="weshare-file-input" multiple class="absolute inset-0 opacity-0 cursor-pointer z-10" />
|
||||
|
||||
<div class="flex flex-col items-center justify-center gap-3.5 pointer-events-none">
|
||||
<!-- Cloud/Share Icon with Glow -->
|
||||
<div class="w-16 h-16 rounded-2xl bg-gradient-to-tr from-cyan-500/20 to-purple-500/20 text-cyan-300 flex items-center justify-center shadow-inner group-hover:scale-105 transition-transform">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base font-semibold text-slate-100 group-hover:text-cyan-300 transition">
|
||||
Presuňte súbory sem alebo <span class="text-cyan-400 underline decoration-cyan-400/40">vyberte z disku</span>
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 mt-1">Podpora viacerých súborov naraz • Až do 5 GB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Files List -->
|
||||
@if (selectedFiles.Count > 0)
|
||||
{
|
||||
<div class="glass-subpanel rounded-2xl p-4 space-y-2.5 max-h-48 overflow-y-auto">
|
||||
<div class="flex items-center justify-between text-xs text-slate-400 pb-1 border-b border-white/5">
|
||||
<span>Vybrané súbory (@selectedFiles.Count):</span>
|
||||
<span class="font-medium text-slate-200">Spolu: @FormatBytes(TotalSelectedBytes)</span>
|
||||
</div>
|
||||
@foreach (var file in selectedFiles)
|
||||
{
|
||||
<div class="flex items-center justify-between text-xs py-1 px-2 rounded-lg bg-white/5 hover:bg-white/10 transition">
|
||||
<div class="flex items-center gap-2 truncate max-w-[80%]">
|
||||
<svg class="w-4 h-4 text-cyan-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<span class="text-slate-200 truncate">@file.Name</span>
|
||||
<span class="text-slate-500 text-[11px] shrink-0">(@FormatBytes(file.Size))</span>
|
||||
</div>
|
||||
<button @onclick="() => RemoveFile(file.FileId)" class="text-slate-500 hover:text-red-400 p-1 transition" title="Odstrániť súbor">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Parameters Grid (Expiration & Password) -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
<!-- Expiration Selection -->
|
||||
<div class="glass-subpanel rounded-2xl p-3.5 flex flex-col justify-between">
|
||||
<label class="text-xs font-medium text-slate-300 flex items-center gap-1.5 mb-2">
|
||||
<svg class="w-3.5 h-3.5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Platnosť odkazu
|
||||
</label>
|
||||
<select @bind="expirationDays" class="bg-slate-900/90 border border-white/10 rounded-xl px-3 py-2 text-xs text-slate-100 focus:outline-none focus:border-cyan-400 cursor-pointer">
|
||||
<option value="1">1 Deň</option>
|
||||
<option value="7">7 Dní (odporúčané)</option>
|
||||
<option value="30">30 Dní (1 mesiac)</option>
|
||||
<option value="0">Nikdy (bez expirácie)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Password Protection -->
|
||||
<div class="glass-subpanel rounded-2xl p-3.5 flex flex-col justify-between">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-xs font-medium text-slate-300 flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
Ochrana heslom
|
||||
</label>
|
||||
<input type="checkbox" @bind="isPasswordEnabled" class="rounded bg-slate-900 border-white/20 text-indigo-500 focus:ring-0 cursor-pointer" />
|
||||
</div>
|
||||
|
||||
@if (isPasswordEnabled)
|
||||
{
|
||||
<input type="password" @bind="passwordValue" @bind:event="oninput" placeholder="Zadajte heslo..." class="w-full bg-slate-900/90 border border-indigo-500/50 rounded-xl px-3 py-1.5 text-xs text-slate-100 focus:outline-none focus:border-indigo-400 placeholder:text-slate-600" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-[11px] text-slate-500">Bez hesla (verejný odkaz)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-300 text-xs flex items-center gap-2">
|
||||
<span>⚠️ @errorMessage</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Upload Submit Button -->
|
||||
<button @onclick="InitiateUpload"
|
||||
disabled="@(selectedFiles.Count == 0)"
|
||||
class="w-full py-3.5 px-4 rounded-2xl btn-primary-glow text-white font-bold text-sm disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||
</svg>
|
||||
<span>Nahrať @(selectedFiles.Count > 0 ? $"({selectedFiles.Count} súborov • {FormatBytes(TotalSelectedBytes)})" : "súbory")</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ================= STAV 2: UPLOADING (STREAMING PROGRESS & SENDER-RECEIVER BRIDGE) ================= -->
|
||||
@if (currentState == UiState.Uploading)
|
||||
{
|
||||
<div class="space-y-6 py-2">
|
||||
<!-- Visual 'We Share Between Us' Connection Stream -->
|
||||
<div class="glass-subpanel rounded-2xl p-4 flex items-center justify-between relative overflow-hidden">
|
||||
<!-- Sender Avatar (You) -->
|
||||
<div class="flex flex-col items-center z-10">
|
||||
<div class="w-12 h-12 rounded-full p-0.5 bg-gradient-to-tr from-purple-500 to-pink-500 shadow-lg shadow-purple-500/30">
|
||||
<div class="w-full h-full bg-slate-900 rounded-full flex items-center justify-center text-xs font-bold text-purple-300">
|
||||
JA
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-purple-300 font-semibold mt-1">Odosielateľ</span>
|
||||
</div>
|
||||
|
||||
<!-- Animated Data Bridge Beam -->
|
||||
<div class="flex-1 mx-4 flex flex-col items-center relative">
|
||||
<div class="w-full h-2 bg-slate-950 rounded-full overflow-hidden relative border border-white/5">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-purple-500 via-cyan-400 to-indigo-500 rounded-full animate-stream"></div>
|
||||
</div>
|
||||
<div class="text-[10px] text-cyan-400 font-mono mt-2 flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-cyan-400 animate-ping"></span>
|
||||
<span>priamy prenos medzi nami</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receiver Avatar (Recipient) -->
|
||||
<div class="flex flex-col items-center z-10">
|
||||
<div class="w-12 h-12 rounded-full p-0.5 bg-gradient-to-tr from-cyan-400 to-indigo-500 shadow-lg shadow-cyan-500/30">
|
||||
<div class="w-full h-full bg-slate-900 rounded-full flex items-center justify-center text-xs font-bold text-cyan-300">
|
||||
TY
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-cyan-300 font-semibold mt-1">Príjemca</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Summary & Cancel Button -->
|
||||
<div class="flex items-center justify-between pb-2 border-b border-white/10">
|
||||
<div class="truncate max-w-[70%]">
|
||||
<h4 class="font-semibold text-sm text-slate-100 truncate">@currentUploadFileName</h4>
|
||||
<p class="text-xs text-slate-400">@FormatBytes(currentUploadedBytes) z @FormatBytes(currentTotalBytes)</p>
|
||||
</div>
|
||||
|
||||
<button @onclick="CancelUpload" class="px-3 py-1.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 text-xs font-semibold transition flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
<span>Zrušiť</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar & Metrics -->
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex justify-between text-xs">
|
||||
<span class="text-slate-400">Streamovanie dát...</span>
|
||||
<span class="text-cyan-400 font-extrabold text-sm">@progressPercent.ToString("0.0")%</span>
|
||||
</div>
|
||||
|
||||
<div class="h-3 w-full bg-slate-950 rounded-full overflow-hidden p-0.5 border border-white/10 shadow-inner">
|
||||
<div class="h-full bg-gradient-to-r from-purple-500 via-indigo-500 to-cyan-400 rounded-full transition-all duration-200"
|
||||
style="width: @(progressPercent)%"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[11px] text-slate-400 pt-1">
|
||||
<span>Rýchlosť: <strong class="text-slate-200">@currentSpeedMb MB/s</strong></span>
|
||||
<span>Zostáva: <strong class="text-slate-200">@(etaSeconds > 0 ? $"{etaSeconds} sekúnd" : "dokončuje sa...")</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ================= STAV 3: SUCCESS & SHARE LINK ================= -->
|
||||
@if (currentState == UiState.Completed)
|
||||
{
|
||||
<div class="space-y-6 text-center py-2">
|
||||
<div class="w-16 h-16 rounded-3xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 mx-auto flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-white tracking-tight">Súbory sú pripravené na zdieľanie!</h3>
|
||||
<p class="text-xs text-slate-400 mt-1">
|
||||
Platnosť: @(expirationDays == 0 ? "Nikdy" : $"{expirationDays} dní") • @(isPasswordEnabled ? "Chránené heslom 🔒" : "Bez hesla 🔓") • @FormatBytes(currentTotalBytes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Copy Link Input Box -->
|
||||
<div class="glass-subpanel rounded-2xl p-2 flex items-center gap-2 border border-cyan-500/30 shadow-lg">
|
||||
<input type="text" readonly value="@completedShareUrl" class="bg-transparent text-xs text-cyan-300 font-mono flex-1 px-3 focus:outline-none select-all truncate" />
|
||||
<button @onclick="CopyLink" class="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs transition flex items-center gap-1.5 shadow">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span>@copyButtonLabel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- QR Code & Action Links -->
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 pt-2">
|
||||
<button @onclick="ToggleQrCode" class="text-xs px-3.5 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 transition flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M4 8h4m0 0V4m0 4h.01M4 16h4m0 0v4m0-4h.01"></path>
|
||||
</svg>
|
||||
<span>@(showQrCode ? "Skryť QR Kód" : "Zobraziť QR Kód")</span>
|
||||
</button>
|
||||
|
||||
<a href="@completedShareUrl" target="_blank" class="text-xs px-3.5 py-2 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/20 transition flex items-center gap-1.5">
|
||||
<span>Otvoriť náhľad príjemcu</span>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (showQrCode && !string.IsNullOrEmpty(completedShareId))
|
||||
{
|
||||
<div class="glass-subpanel rounded-2xl p-4 inline-block mx-auto border border-white/10">
|
||||
<img src="/api/share/@completedShareId/qr" alt="QR Code" class="w-44 h-44 rounded-xl mx-auto bg-white p-2" />
|
||||
<p class="text-[11px] text-slate-400 mt-2">Naskenujte pre okamžité stiahnutie do mobilu</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pt-2">
|
||||
<button @onclick="ResetForm" class="text-xs text-slate-400 hover:text-white underline transition">
|
||||
+ Nahrať ďalší súbor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private enum UiState { SelectFiles, Uploading, Completed }
|
||||
private UiState currentState = UiState.SelectFiles;
|
||||
|
||||
private List<FileUploadItemDto> selectedFiles = new();
|
||||
private int expirationDays = 7;
|
||||
private bool isPasswordEnabled = false;
|
||||
private string passwordValue = string.Empty;
|
||||
private string errorMessage = string.Empty;
|
||||
|
||||
// Upload Progress State
|
||||
private double progressPercent = 0;
|
||||
private double currentSpeedMb = 0;
|
||||
private int etaSeconds = 0;
|
||||
private long currentUploadedBytes = 0;
|
||||
private long currentTotalBytes = 0;
|
||||
private string currentUploadFileName = string.Empty;
|
||||
|
||||
// Completed State
|
||||
private string completedShareUrl = string.Empty;
|
||||
private string completedShareId = string.Empty;
|
||||
private string copyButtonLabel = "Kopírovať";
|
||||
private bool showQrCode = false;
|
||||
|
||||
private DotNetObjectReference<Home>? dotNetRef;
|
||||
|
||||
private long TotalSelectedBytes => selectedFiles.Sum(f => f.Size);
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await JS.InvokeVoidAsync("WeShareUploader.registerDotNet", dotNetRef);
|
||||
await JS.InvokeVoidAsync("WeShareUploader.initDropzone", "weshare-dropzone", "weshare-file-input");
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnFilesSelected(List<FileUploadItemDto> files)
|
||||
{
|
||||
selectedFiles.AddRange(files);
|
||||
errorMessage = string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RemoveFile(string fileId)
|
||||
{
|
||||
selectedFiles.RemoveAll(f => f.FileId == fileId);
|
||||
await JS.InvokeVoidAsync("WeShareUploader.removeSelectedFile", fileId);
|
||||
}
|
||||
|
||||
private async Task InitiateUpload()
|
||||
{
|
||||
if (selectedFiles.Count == 0) return;
|
||||
|
||||
errorMessage = string.Empty;
|
||||
progressPercent = 0;
|
||||
currentSpeedMb = 0;
|
||||
etaSeconds = 0;
|
||||
currentUploadedBytes = 0;
|
||||
currentTotalBytes = TotalSelectedBytes;
|
||||
currentUploadFileName = selectedFiles[0].Name;
|
||||
|
||||
currentState = UiState.Uploading;
|
||||
StateHasChanged();
|
||||
|
||||
string? pass = isPasswordEnabled && !string.IsNullOrWhiteSpace(passwordValue) ? passwordValue : null;
|
||||
await JS.InvokeVoidAsync("WeShareUploader.startUpload", expirationDays, pass);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnUploadProgress(UploadProgressDto progress)
|
||||
{
|
||||
progressPercent = progress.Percent;
|
||||
currentSpeedMb = progress.SpeedMb;
|
||||
etaSeconds = progress.EtaSeconds;
|
||||
currentUploadedBytes = progress.UploadedBytes;
|
||||
currentTotalBytes = progress.TotalBytes;
|
||||
if (!string.IsNullOrEmpty(progress.CurrentFileName))
|
||||
{
|
||||
currentUploadFileName = progress.CurrentFileName;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnUploadCompleted(CompleteUploadResult result)
|
||||
{
|
||||
if (result.Success)
|
||||
{
|
||||
completedShareUrl = result.ShareUrl ?? "";
|
||||
completedShareId = result.ShareId ?? "";
|
||||
currentState = UiState.Completed;
|
||||
copyButtonLabel = "Kopírovať";
|
||||
showQrCode = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentState = UiState.SelectFiles;
|
||||
errorMessage = result.Error ?? "Nastala neočakávaná chyba.";
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnUploadCancelled()
|
||||
{
|
||||
currentState = UiState.SelectFiles;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnUploadError(string error)
|
||||
{
|
||||
currentState = UiState.SelectFiles;
|
||||
errorMessage = error;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task CancelUpload()
|
||||
{
|
||||
await JS.InvokeVoidAsync("WeShareUploader.cancelUpload");
|
||||
}
|
||||
|
||||
private async Task CopyLink()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(completedShareUrl))
|
||||
{
|
||||
await JS.InvokeVoidAsync("WeShareUploader.copyToClipboard", completedShareUrl);
|
||||
copyButtonLabel = "Skopírované! ✓";
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleQrCode()
|
||||
{
|
||||
showQrCode = !showQrCode;
|
||||
}
|
||||
|
||||
private async Task ResetForm()
|
||||
{
|
||||
selectedFiles.Clear();
|
||||
await JS.InvokeVoidAsync("WeShareUploader.clearSelectedFiles");
|
||||
currentState = UiState.SelectFiles;
|
||||
passwordValue = string.Empty;
|
||||
isPasswordEnabled = false;
|
||||
errorMessage = string.Empty;
|
||||
showQrCode = false;
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] suffixes = { "B", "KB", "MB", "GB", "TB" };
|
||||
int counter = 0;
|
||||
decimal number = bytes;
|
||||
while (Math.Round(number / 1024) >= 1)
|
||||
{
|
||||
number /= 1024;
|
||||
counter++;
|
||||
}
|
||||
return $"{number:n1} {suffixes[counter]}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
dotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
public class UploadProgressDto
|
||||
{
|
||||
public double Percent { get; set; }
|
||||
public double SpeedMb { get; set; }
|
||||
public int EtaSeconds { get; set; }
|
||||
public long UploadedBytes { get; set; }
|
||||
public long TotalBytes { get; set; }
|
||||
public string? CurrentFileName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,13 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using WeShare
|
||||
@using WeShare.Components
|
||||
@using WeShare.Components.Layout
|
||||
@using WeShare.Models
|
||||
@using WeShare.Services
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QRCoder;
|
||||
using WeShare.Models;
|
||||
using WeShare.Services;
|
||||
|
||||
namespace WeShare.Endpoints;
|
||||
|
||||
public static class DownloadEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapDownloadEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// 1. Download single file
|
||||
app.MapGet("/api/download/{id}/file/{fileId:guid}", async (
|
||||
string id,
|
||||
Guid fileId,
|
||||
[FromQuery] string? pwd,
|
||||
IShareStorageService storageService,
|
||||
IPasswordHasher passwordHasher,
|
||||
HttpResponse response) =>
|
||||
{
|
||||
var metadata = await storageService.GetMetadataAsync(id);
|
||||
if (metadata == null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Share package not found or expired." });
|
||||
}
|
||||
|
||||
if (metadata.IsExpired)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status410Gone, detail: "This share package has expired.");
|
||||
}
|
||||
|
||||
if (metadata.IsPasswordProtected)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pwd) || !_passwordHasher.VerifyPassword(pwd, metadata.PasswordHash!))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
var fileItem = metadata.Files.FirstOrDefault(f => f.Id == fileId);
|
||||
if (fileItem == null)
|
||||
{
|
||||
return Results.NotFound(new { error = "File not found in this package." });
|
||||
}
|
||||
|
||||
var stream = await storageService.OpenFileReadStreamAsync(metadata, fileId);
|
||||
if (stream == null)
|
||||
{
|
||||
return Results.NotFound(new { error = "File content not found on disk." });
|
||||
}
|
||||
|
||||
await storageService.IncrementDownloadCountAsync(metadata);
|
||||
|
||||
return Results.File(
|
||||
fileStream: stream,
|
||||
contentType: fileItem.ContentType,
|
||||
fileDownloadName: fileItem.OriginalFileName,
|
||||
enableRangeProcessing: true);
|
||||
});
|
||||
|
||||
// 2. Download all files as on-the-fly ZIP stream
|
||||
app.MapGet("/api/download/{id}/zip", async (
|
||||
string id,
|
||||
[FromQuery] string? pwd,
|
||||
IShareStorageService storageService,
|
||||
IPasswordHasher passwordHasher,
|
||||
HttpContext httpContext) =>
|
||||
{
|
||||
var metadata = await storageService.GetMetadataAsync(id);
|
||||
if (metadata == null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Share package not found or expired." });
|
||||
}
|
||||
|
||||
if (metadata.IsExpired)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status410Gone, detail: "This share package has expired.");
|
||||
}
|
||||
|
||||
if (metadata.IsPasswordProtected)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pwd) || !_passwordHasher.VerifyPassword(pwd, metadata.PasswordHash!))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
string zipFileName = $"WeShare_{metadata.ShortId}.zip";
|
||||
httpContext.Response.ContentType = "application/zip";
|
||||
httpContext.Response.Headers.Append("Content-Disposition", $"attachment; filename=\"{zipFileName}\"");
|
||||
|
||||
await storageService.StreamPackageAsZipAsync(metadata, httpContext.Response.Body);
|
||||
await storageService.IncrementDownloadCountAsync(metadata);
|
||||
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
// 3. Generate QR Code for Share Link
|
||||
app.MapGet("/api/share/{id}/qr", async (
|
||||
string id,
|
||||
IShareStorageService storageService,
|
||||
HttpRequest request) =>
|
||||
{
|
||||
var metadata = await storageService.GetMetadataAsync(id);
|
||||
if (metadata == null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
string baseUrl = $"{request.Scheme}://{request.Host}";
|
||||
string shareUrl = $"{baseUrl}/d/{metadata.ShortId}";
|
||||
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
using var qrCodeData = qrGenerator.CreateQrCode(shareUrl, QRCodeGenerator.ECCLevel.Q);
|
||||
using var qrCode = new PngByteQRCode(qrCodeData);
|
||||
byte[] qrCodeImage = qrCode.GetGraphic(20);
|
||||
|
||||
return Results.File(qrCodeImage, "image/png");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static IPasswordHasher _passwordHasher = new PasswordHasher();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WeShare.Models;
|
||||
using WeShare.Services;
|
||||
|
||||
namespace WeShare.Endpoints;
|
||||
|
||||
public static class UploadEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapUploadEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/upload");
|
||||
|
||||
// 1. Init upload session
|
||||
group.MapPost("/init", async (
|
||||
[FromBody] InitUploadRequest request,
|
||||
IShareStorageService storageService) =>
|
||||
{
|
||||
if (request.Files.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "No files specified." });
|
||||
}
|
||||
|
||||
var response = await storageService.InitializeUploadSessionAsync(request);
|
||||
return Results.Ok(response);
|
||||
}).DisableAntiforgery();
|
||||
|
||||
// 2. Upload individual chunk
|
||||
group.MapPost("/chunk", async (
|
||||
HttpRequest request,
|
||||
IShareStorageService storageService) =>
|
||||
{
|
||||
if (!request.HasFormContentType)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Form-data content type required." });
|
||||
}
|
||||
|
||||
var form = await request.ReadFormAsync();
|
||||
if (!Guid.TryParse(form["sessionId"], out var sessionId) ||
|
||||
!Guid.TryParse(form["fileId"], out var fileId) ||
|
||||
!int.TryParse(form["chunkIndex"], out var chunkIndex))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid upload parameters (sessionId, fileId, chunkIndex)." });
|
||||
}
|
||||
|
||||
var file = form.Files.GetFile("chunk");
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Empty chunk payload." });
|
||||
}
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
await storageService.AppendChunkAsync(sessionId, fileId, chunkIndex, stream);
|
||||
|
||||
return Results.Ok(new { success = true, chunkIndex });
|
||||
}).DisableAntiforgery();
|
||||
|
||||
// 3. Complete upload
|
||||
group.MapPost("/complete", async (
|
||||
[FromBody] CompleteUploadDto dto,
|
||||
IShareStorageService storageService,
|
||||
HttpRequest request) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadata = await storageService.CompleteUploadSessionAsync(dto);
|
||||
string baseUrl = $"{request.Scheme}://{request.Host}";
|
||||
string shareUrl = $"{baseUrl}/d/{metadata.ShortId}";
|
||||
|
||||
return Results.Ok(new CompleteUploadResult
|
||||
{
|
||||
Success = true,
|
||||
ShareId = metadata.Id.ToString(),
|
||||
ShortId = metadata.ShortId,
|
||||
ShareUrl = shareUrl
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new CompleteUploadResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
});
|
||||
}
|
||||
}).DisableAntiforgery();
|
||||
|
||||
// 4. Cancel upload
|
||||
group.MapPost("/cancel/{sessionId:guid}", async (
|
||||
Guid sessionId,
|
||||
IShareStorageService storageService) =>
|
||||
{
|
||||
await storageService.CancelUploadSessionAsync(sessionId);
|
||||
return Results.Ok(new { success = true, message = "Session cancelled." });
|
||||
}).DisableAntiforgery();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace WeShare.Models;
|
||||
|
||||
public class SharePackageMetadata
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ShortId { get; set; } = string.Empty;
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ExpiresAtUtc { get; set; }
|
||||
public bool IsPasswordProtected { get; set; }
|
||||
public string? PasswordHash { get; set; }
|
||||
public int DownloadCount { get; set; }
|
||||
public long TotalSizeBytes { get; set; }
|
||||
public List<ShareFileItem> Files { get; set; } = new();
|
||||
|
||||
public bool IsExpired => ExpiresAtUtc.HasValue && ExpiresAtUtc.Value < DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class ShareFileItem
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
public string StoredFileName { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
public string ContentType { get; set; } = "application/octet-stream";
|
||||
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace WeShare.Models;
|
||||
|
||||
public class InitUploadRequest
|
||||
{
|
||||
public List<FileUploadItemDto> Files { get; set; } = new();
|
||||
public int ExpirationDays { get; set; } = 7; // 0 = never, 1, 7, 30
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
|
||||
public class FileUploadItemDto
|
||||
{
|
||||
public string FileId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public long Size { get; set; }
|
||||
public string Type { get; set; } = "application/octet-stream";
|
||||
}
|
||||
|
||||
public class InitUploadResponse
|
||||
{
|
||||
public Guid SessionId { get; set; }
|
||||
public long ChunkSize { get; set; }
|
||||
public string Message { get; set; } = "Upload session initialized";
|
||||
}
|
||||
|
||||
public class CompleteUploadDto
|
||||
{
|
||||
public Guid SessionId { get; set; }
|
||||
public int ExpirationDays { get; set; } = 7;
|
||||
public string? Password { get; set; }
|
||||
public List<FileUploadItemDto> Files { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CompleteUploadResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ShareId { get; set; }
|
||||
public string? ShortId { get; set; }
|
||||
public string? ShareUrl { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace WeShare.Models;
|
||||
|
||||
public class WeShareOptions
|
||||
{
|
||||
public const string SectionName = "WeShare";
|
||||
|
||||
public string UploadRootFolder { get; set; } = "Uploads";
|
||||
public long MaxUploadSizeBytes { get; set; } = 5368709120; // 5 GB default
|
||||
public long ChunkSizeBytes { get; set; } = 10485760; // 10 MB chunk default
|
||||
public bool EnableAutoCleanup { get; set; } = true;
|
||||
public int CleanupIntervalMinutes { get; set; } = 15;
|
||||
public string? BaseUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using WeShare.Components;
|
||||
using WeShare.Endpoints;
|
||||
using WeShare.Models;
|
||||
using WeShare.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure options
|
||||
builder.Services.Configure<WeShareOptions>(builder.Configuration.GetSection(WeShareOptions.SectionName));
|
||||
|
||||
// Configure Large File form options for chunk transfer
|
||||
builder.Services.Configure<FormOptions>(options =>
|
||||
{
|
||||
options.MultipartBodyLengthLimit = 50 * 1024 * 1024; // 50MB per chunk form request
|
||||
});
|
||||
|
||||
// Register services
|
||||
builder.Services.AddSingleton<IPasswordHasher, PasswordHasher>();
|
||||
builder.Services.AddSingleton<IShareStorageService, ShareStorageService>();
|
||||
builder.Services.AddHostedService<CleanupBackgroundService>();
|
||||
|
||||
// Add Blazor InteractiveServer
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAntiforgery();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Map Minimal API Endpoints
|
||||
app.MapUploadEndpoints();
|
||||
app.MapDownloadEndpoints();
|
||||
|
||||
// Map Blazor Components
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5139",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7227;http://localhost:5139",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using WeShare.Models;
|
||||
|
||||
namespace WeShare.Services;
|
||||
|
||||
public class CleanupBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IOptionsMonitor<WeShareOptions> _optionsMonitor;
|
||||
private readonly ILogger<CleanupBackgroundService> _logger;
|
||||
|
||||
public CleanupBackgroundService(
|
||||
IServiceProvider serviceProvider,
|
||||
IOptionsMonitor<WeShareOptions> optionsMonitor,
|
||||
ILogger<CleanupBackgroundService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_optionsMonitor = optionsMonitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("WeShare CleanupBackgroundService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
int intervalMinutes = Math.Max(1, options.CleanupIntervalMinutes);
|
||||
|
||||
try
|
||||
{
|
||||
if (options.EnableAutoCleanup)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var storageService = scope.ServiceProvider.GetRequiredService<IShareStorageService>();
|
||||
|
||||
int cleaned = await storageService.CleanupExpiredPackagesAsync();
|
||||
if (cleaned > 0)
|
||||
{
|
||||
_logger.LogInformation("Cleanup worker completed cycle: {Count} packages deleted.", cleaned);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("AutoCleanup is disabled in appsettings.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred during WeShare storage cleanup cycle.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("WeShare CleanupBackgroundService stopped.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using WeShare.Models;
|
||||
|
||||
namespace WeShare.Services;
|
||||
|
||||
public interface IShareStorageService
|
||||
{
|
||||
Task<InitUploadResponse> InitializeUploadSessionAsync(InitUploadRequest request);
|
||||
Task AppendChunkAsync(Guid sessionId, Guid fileId, int chunkIndex, Stream chunkStream);
|
||||
Task CancelUploadSessionAsync(Guid sessionId);
|
||||
Task<SharePackageMetadata> CompleteUploadSessionAsync(CompleteUploadDto dto);
|
||||
|
||||
Task<SharePackageMetadata?> GetMetadataAsync(string idOrShortId);
|
||||
Task<Stream?> OpenFileReadStreamAsync(SharePackageMetadata metadata, Guid fileId);
|
||||
Task StreamPackageAsZipAsync(SharePackageMetadata metadata, Stream outputStream);
|
||||
|
||||
Task IncrementDownloadCountAsync(SharePackageMetadata metadata);
|
||||
Task<int> CleanupExpiredPackagesAsync();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace WeShare.Services;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string storedHash);
|
||||
}
|
||||
|
||||
public class PasswordHasher : IPasswordHasher
|
||||
{
|
||||
private const int SaltSize = 16; // 128 bit
|
||||
private const int KeySize = 32; // 256 bit
|
||||
private const int Iterations = 100_000;
|
||||
private static readonly HashAlgorithmName Algorithm = HashAlgorithmName.SHA256;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algorithm, KeySize);
|
||||
|
||||
return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string storedHash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedHash) || string.IsNullOrWhiteSpace(password))
|
||||
return false;
|
||||
|
||||
string[] parts = storedHash.Split('.');
|
||||
if (parts.Length != 3)
|
||||
return false;
|
||||
|
||||
if (!int.TryParse(parts[0], out int iterations))
|
||||
return false;
|
||||
|
||||
byte[] salt = Convert.FromBase64String(parts[1]);
|
||||
byte[] expectedHash = Convert.FromBase64String(parts[2]);
|
||||
|
||||
byte[] actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algorithm, expectedHash.Length);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
using System.Buffers.Text;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WeShare.Models;
|
||||
|
||||
namespace WeShare.Services;
|
||||
|
||||
public class ShareStorageService : IShareStorageService
|
||||
{
|
||||
private readonly WeShareOptions _options;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly ILogger<ShareStorageService> _logger;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public ShareStorageService(
|
||||
IOptions<WeShareOptions> options,
|
||||
IPasswordHasher passwordHasher,
|
||||
ILogger<ShareStorageService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_passwordHasher = passwordHasher;
|
||||
_logger = logger;
|
||||
|
||||
EnsureDirectoryExists(GetRootDirectory());
|
||||
EnsureDirectoryExists(GetTempDirectory());
|
||||
}
|
||||
|
||||
private string GetRootDirectory()
|
||||
{
|
||||
string root = _options.UploadRootFolder;
|
||||
if (!Path.IsPathRooted(root))
|
||||
{
|
||||
root = Path.Combine(AppContext.BaseDirectory, root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private string GetTempDirectory() => Path.Combine(GetRootDirectory(), "_temp");
|
||||
|
||||
private void EnsureDirectoryExists(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<InitUploadResponse> InitializeUploadSessionAsync(InitUploadRequest request)
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
string sessionDir = Path.Combine(GetTempDirectory(), sessionId.ToString());
|
||||
EnsureDirectoryExists(sessionDir);
|
||||
|
||||
_logger.LogInformation("Initialized upload session {SessionId} for {FileCount} files", sessionId, request.Files.Count);
|
||||
|
||||
return Task.FromResult(new InitUploadResponse
|
||||
{
|
||||
SessionId = sessionId,
|
||||
ChunkSize = _options.ChunkSizeBytes,
|
||||
Message = "Upload session initialized"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task AppendChunkAsync(Guid sessionId, Guid fileId, int chunkIndex, Stream chunkStream)
|
||||
{
|
||||
string sessionDir = Path.Combine(GetTempDirectory(), sessionId.ToString());
|
||||
if (!Directory.Exists(sessionDir))
|
||||
{
|
||||
EnsureDirectoryExists(sessionDir);
|
||||
}
|
||||
|
||||
string tempFilePath = Path.Combine(sessionDir, $"{fileId}.tmp");
|
||||
|
||||
// Append chunk
|
||||
await using var fileStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 64 * 1024,
|
||||
useAsync: true);
|
||||
|
||||
await chunkStream.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
public Task CancelUploadSessionAsync(Guid sessionId)
|
||||
{
|
||||
string sessionDir = Path.Combine(GetTempDirectory(), sessionId.ToString());
|
||||
if (Directory.Exists(sessionDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(sessionDir, recursive: true);
|
||||
_logger.LogInformation("Cancelled and deleted temp upload session {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete cancelled session folder {SessionDir}", sessionDir);
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<SharePackageMetadata> CompleteUploadSessionAsync(CompleteUploadDto dto)
|
||||
{
|
||||
string tempSessionDir = Path.Combine(GetTempDirectory(), dto.SessionId.ToString());
|
||||
if (!Directory.Exists(tempSessionDir))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Upload session {dto.SessionId} was not found or expired.");
|
||||
}
|
||||
|
||||
var shareId = Guid.NewGuid();
|
||||
string shortId = EncodeShortGuid(shareId);
|
||||
string packageDir = Path.Combine(GetRootDirectory(), shareId.ToString());
|
||||
EnsureDirectoryExists(packageDir);
|
||||
|
||||
var metadata = new SharePackageMetadata
|
||||
{
|
||||
Id = shareId,
|
||||
ShortId = shortId,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
ExpiresAtUtc = dto.ExpirationDays > 0 ? DateTime.UtcNow.AddDays(dto.ExpirationDays) : null,
|
||||
IsPasswordProtected = !string.IsNullOrWhiteSpace(dto.Password),
|
||||
PasswordHash = !string.IsNullOrWhiteSpace(dto.Password) ? _passwordHasher.HashPassword(dto.Password) : null,
|
||||
DownloadCount = 0
|
||||
};
|
||||
|
||||
long totalBytes = 0;
|
||||
|
||||
foreach (var fileDto in dto.Files)
|
||||
{
|
||||
if (!Guid.TryParse(fileDto.FileId, out var fileGuid))
|
||||
{
|
||||
fileGuid = Guid.NewGuid();
|
||||
}
|
||||
|
||||
string tempFile = Path.Combine(tempSessionDir, $"{fileDto.FileId}.tmp");
|
||||
string safeFileName = Path.GetFileName(fileDto.Name);
|
||||
if (string.IsNullOrWhiteSpace(safeFileName))
|
||||
{
|
||||
safeFileName = $"file_{fileGuid}";
|
||||
}
|
||||
|
||||
// Ensure unique stored file name inside the package directory
|
||||
string targetPath = Path.Combine(packageDir, safeFileName);
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
targetPath = Path.Combine(packageDir, $"{fileGuid}_{safeFileName}");
|
||||
safeFileName = Path.GetFileName(targetPath);
|
||||
}
|
||||
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Move(tempFile, targetPath, overwrite: true);
|
||||
var fi = new FileInfo(targetPath);
|
||||
totalBytes += fi.Length;
|
||||
|
||||
metadata.Files.Add(new ShareFileItem
|
||||
{
|
||||
Id = fileGuid,
|
||||
OriginalFileName = fileDto.Name,
|
||||
StoredFileName = safeFileName,
|
||||
SizeBytes = fi.Length,
|
||||
ContentType = string.IsNullOrWhiteSpace(fileDto.Type) ? "application/octet-stream" : fileDto.Type,
|
||||
UploadedAtUtc = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
metadata.TotalSizeBytes = totalBytes;
|
||||
|
||||
// Save metadata.json
|
||||
string metadataPath = Path.Combine(packageDir, "metadata.json");
|
||||
await using (var metaStream = File.Create(metadataPath))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(metaStream, metadata, JsonOptions);
|
||||
}
|
||||
|
||||
// Clean up temp session directory
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempSessionDir, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove temp directory for session {SessionId}", dto.SessionId);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Completed share package {ShareId} with {FileCount} files ({TotalBytes} bytes)",
|
||||
shareId, metadata.Files.Count, totalBytes);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public async Task<SharePackageMetadata?> GetMetadataAsync(string idOrShortId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(idOrShortId))
|
||||
return null;
|
||||
|
||||
string root = GetRootDirectory();
|
||||
|
||||
// 1. Try if idOrShortId is a direct GUID
|
||||
if (Guid.TryParse(idOrShortId, out var directGuid))
|
||||
{
|
||||
string guidDir = Path.Combine(root, directGuid.ToString());
|
||||
string metaFile = Path.Combine(guidDir, "metadata.json");
|
||||
if (File.Exists(metaFile))
|
||||
{
|
||||
return await LoadMetadataFileAsync(metaFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try decode as ShortGuid
|
||||
if (TryDecodeShortGuid(idOrShortId, out var decodedGuid))
|
||||
{
|
||||
string decodedDir = Path.Combine(root, decodedGuid.ToString());
|
||||
string metaFile = Path.Combine(decodedDir, "metadata.json");
|
||||
if (File.Exists(metaFile))
|
||||
{
|
||||
return await LoadMetadataFileAsync(metaFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback scan of directories (Zero DB scan)
|
||||
foreach (var dir in Directory.GetDirectories(root))
|
||||
{
|
||||
if (Path.GetFileName(dir).Equals("_temp", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
string metaFile = Path.Combine(dir, "metadata.json");
|
||||
if (File.Exists(metaFile))
|
||||
{
|
||||
var meta = await LoadMetadataFileAsync(metaFile);
|
||||
if (meta != null && (meta.ShortId == idOrShortId || meta.Id.ToString("N") == idOrShortId))
|
||||
{
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SharePackageMetadata?> LoadMetadataFileAsync(string metaPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(metaPath);
|
||||
return await JsonSerializer.DeserializeAsync<SharePackageMetadata>(stream, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Stream?> OpenFileReadStreamAsync(SharePackageMetadata metadata, Guid fileId)
|
||||
{
|
||||
var fileItem = metadata.Files.FirstOrDefault(f => f.Id == fileId);
|
||||
if (fileItem == null)
|
||||
return Task.FromResult<Stream?>(null);
|
||||
|
||||
string root = GetRootDirectory();
|
||||
string filePath = Path.Combine(root, metadata.Id.ToString(), fileItem.StoredFileName);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return Task.FromResult<Stream?>(null);
|
||||
|
||||
Stream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, useAsync: true);
|
||||
return Task.FromResult<Stream?>(stream);
|
||||
}
|
||||
|
||||
public async Task StreamPackageAsZipAsync(SharePackageMetadata metadata, Stream outputStream)
|
||||
{
|
||||
string root = GetRootDirectory();
|
||||
string packageDir = Path.Combine(root, metadata.Id.ToString());
|
||||
|
||||
using var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
|
||||
foreach (var file in metadata.Files)
|
||||
{
|
||||
string filePath = Path.Combine(packageDir, file.StoredFileName);
|
||||
if (!File.Exists(filePath))
|
||||
continue;
|
||||
|
||||
var entry = zipArchive.CreateEntry(file.OriginalFileName, CompressionLevel.Optimal);
|
||||
await using var entryStream = entry.Open();
|
||||
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 128 * 1024, useAsync: true);
|
||||
await fileStream.CopyToAsync(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task IncrementDownloadCountAsync(SharePackageMetadata metadata)
|
||||
{
|
||||
string metaPath = Path.Combine(GetRootDirectory(), metadata.Id.ToString(), "metadata.json");
|
||||
if (File.Exists(metaPath))
|
||||
{
|
||||
metadata.DownloadCount++;
|
||||
try
|
||||
{
|
||||
await using var stream = File.Create(metaPath);
|
||||
await JsonSerializer.SerializeAsync(stream, metadata, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update download count for {Id}", metadata.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CleanupExpiredPackagesAsync()
|
||||
{
|
||||
if (!_options.EnableAutoCleanup)
|
||||
{
|
||||
_logger.LogInformation("Auto cleanup is disabled in configuration.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cleaned = 0;
|
||||
string root = GetRootDirectory();
|
||||
|
||||
// 1. Clean expired packages
|
||||
foreach (var dir in Directory.GetDirectories(root))
|
||||
{
|
||||
string dirName = Path.GetFileName(dir);
|
||||
if (dirName.Equals("_temp", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
string metaPath = Path.Combine(dir, "metadata.json");
|
||||
if (File.Exists(metaPath))
|
||||
{
|
||||
var meta = await LoadMetadataFileAsync(metaPath);
|
||||
if (meta != null && meta.IsExpired)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
cleaned++;
|
||||
_logger.LogInformation("Cleaned expired package {Id} (Expired at: {ExpiresAt})", meta.Id, meta.ExpiresAtUtc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete expired directory {Dir}", dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clean old orphaned _temp sessions (older than 4 hours)
|
||||
string tempRoot = GetTempDirectory();
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
foreach (var tempDir in Directory.GetDirectories(tempRoot))
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new DirectoryInfo(tempDir);
|
||||
if (DateTime.UtcNow - info.CreationTimeUtc > TimeSpan.FromHours(4))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
_logger.LogInformation("Cleaned orphaned temp session {TempDir}", tempDir);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to clean orphaned temp directory {TempDir}", tempDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
public static string EncodeShortGuid(Guid guid)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
guid.TryWriteBytes(bytes);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace('/', '_')
|
||||
.Replace('+', '-')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
public static bool TryDecodeShortGuid(string shortGuid, out Guid guid)
|
||||
{
|
||||
guid = Guid.Empty;
|
||||
if (string.IsNullOrWhiteSpace(shortGuid) || shortGuid.Length < 20 || shortGuid.Length > 24)
|
||||
return false;
|
||||
|
||||
string base64 = shortGuid.Replace('_', '/').Replace('-', '+');
|
||||
switch (base64.Length % 4)
|
||||
{
|
||||
case 2: base64 += "=="; break;
|
||||
case 3: base64 += "="; break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] bytes = Convert.FromBase64String(base64);
|
||||
if (bytes.Length == 16)
|
||||
{
|
||||
guid = new Guid(bytes);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// invalid
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"WeShare": {
|
||||
"UploadRootFolder": "Uploads",
|
||||
"MaxUploadSizeBytes": 5368709120,
|
||||
"ChunkSizeBytes": 10485760,
|
||||
"EnableAutoCleanup": true,
|
||||
"CleanupIntervalMinutes": 15
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* WeShare Cupertino Liquid Theme & Visual Effects */
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { opacity: 0.45; transform: scale(1); }
|
||||
50% { opacity: 0.8; transform: scale(1.06); }
|
||||
}
|
||||
|
||||
@keyframes streamMove {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
animation: pulseGlow 5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-stream {
|
||||
animation: streamMove 2s infinite linear;
|
||||
}
|
||||
|
||||
/* Glassmorphism Card (Cupertino Liquid) */
|
||||
.glass-panel {
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(36px);
|
||||
-webkit-backdrop-filter: blur(36px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), inset 0 1px 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.glass-subpanel {
|
||||
background: rgba(10, 15, 29, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Dropzone States */
|
||||
.dropzone-border {
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.dropzone-border:hover, .drag-active {
|
||||
border-color: #38bdf8 !important;
|
||||
background-color: rgba(56, 189, 248, 0.08) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Glow Buttons */
|
||||
.btn-primary-glow {
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #6366f1 50%, #a855f7 100%);
|
||||
box-shadow: 0 10px 25px -5px rgba(99, 102, 241, 0.4);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.btn-primary-glow:hover {
|
||||
box-shadow: 0 15px 30px -5px rgba(6, 182, 212, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// WeShare Chunked Streaming Uploader (Up to 5GB support)
|
||||
window.WeShareUploader = (function () {
|
||||
let activeSessionId = null;
|
||||
let isCancelled = false;
|
||||
let currentXhr = null;
|
||||
let dotNetHelper = null;
|
||||
|
||||
function registerDotNet(helper) {
|
||||
dotNetHelper = helper;
|
||||
}
|
||||
|
||||
function initDropzone(dropzoneElementId, fileInputElementId) {
|
||||
const dropzone = document.getElementById(dropzoneElementId);
|
||||
const fileInput = document.getElementById(fileInputElementId);
|
||||
|
||||
if (!dropzone) return;
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropzone.addEventListener(eventName, preventDefaults, false);
|
||||
document.body.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropzone.addEventListener(eventName, () => dropzone.classList.add('drag-active'), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropzone.addEventListener(eventName, () => dropzone.classList.remove('drag-active'), false);
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
handleSelectedFiles(files);
|
||||
});
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleSelectedFiles(e.target.files);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let selectedFilesStore = [];
|
||||
|
||||
function handleSelectedFiles(files) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const fileItems = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const fileId = generateUUID();
|
||||
selectedFilesStore.push({ id: fileId, file: file });
|
||||
fileItems.push({
|
||||
fileId: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type || 'application/octet-stream'
|
||||
});
|
||||
}
|
||||
|
||||
if (dotNetHelper) {
|
||||
dotNetHelper.invokeMethodAsync('OnFilesSelected', fileItems);
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedFile(fileId) {
|
||||
selectedFilesStore = selectedFilesStore.filter(f => f.id !== fileId);
|
||||
}
|
||||
|
||||
function clearSelectedFiles() {
|
||||
selectedFilesStore = [];
|
||||
}
|
||||
|
||||
async function startUpload(expirationDays, password) {
|
||||
if (selectedFilesStore.length === 0) {
|
||||
alert("Prosím vyberte aspoň jeden súbor.");
|
||||
return;
|
||||
}
|
||||
|
||||
isCancelled = false;
|
||||
|
||||
// 1. Initialize upload session
|
||||
const initPayload = {
|
||||
files: selectedFilesStore.map(f => ({
|
||||
fileId: f.id,
|
||||
name: f.file.name,
|
||||
size: f.file.size,
|
||||
type: f.file.type || 'application/octet-stream'
|
||||
})),
|
||||
expirationDays: expirationDays,
|
||||
password: password
|
||||
};
|
||||
|
||||
let initRes;
|
||||
try {
|
||||
const response = await fetch('/api/upload/init', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(initPayload)
|
||||
});
|
||||
if (!response.ok) throw new Error("Chyba inicializácie nahrávania.");
|
||||
initRes = await response.json();
|
||||
} catch (err) {
|
||||
if (dotNetHelper) dotNetHelper.invokeMethodAsync('OnUploadError', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
activeSessionId = initRes.sessionId;
|
||||
const chunkSize = initRes.chunkSize || (10 * 1024 * 1024); // 10MB chunk
|
||||
|
||||
const totalBytesAllFiles = selectedFilesStore.reduce((acc, f) => acc + f.file.size, 0);
|
||||
let uploadedBytesAllFiles = 0;
|
||||
const startTime = Date.now();
|
||||
let lastSpeedSampleTime = startTime;
|
||||
let lastSampleUploadedBytes = 0;
|
||||
let currentSpeedMb = 0;
|
||||
|
||||
// 2. Upload chunks for each file
|
||||
for (let fIdx = 0; fIdx < selectedFilesStore.length; fIdx++) {
|
||||
if (isCancelled) break;
|
||||
|
||||
const item = selectedFilesStore[fIdx];
|
||||
const file = item.file;
|
||||
const fileId = item.id;
|
||||
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
if (isCancelled) break;
|
||||
|
||||
const start = chunkIndex * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const chunkBlob = file.slice(start, end);
|
||||
const chunkBytes = end - start;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('sessionId', activeSessionId);
|
||||
formData.append('fileId', fileId);
|
||||
formData.append('chunkIndex', chunkIndex.toString());
|
||||
formData.append('totalChunks', totalChunks.toString());
|
||||
formData.append('chunk', chunkBlob, file.name);
|
||||
|
||||
try {
|
||||
await uploadChunk(formData);
|
||||
} catch (err) {
|
||||
if (isCancelled) return;
|
||||
if (dotNetHelper) dotNetHelper.invokeMethodAsync('OnUploadError', "Chyba prenosu: " + err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
uploadedBytesAllFiles += chunkBytes;
|
||||
|
||||
// Calculate speed & ETA
|
||||
const now = Date.now();
|
||||
const elapsedSinceSample = (now - lastSpeedSampleTime) / 1000;
|
||||
if (elapsedSinceSample >= 0.5 || chunkIndex === totalChunks - 1) {
|
||||
const bytesSinceSample = uploadedBytesAllFiles - lastSampleUploadedBytes;
|
||||
currentSpeedMb = ((bytesSinceSample / (1024 * 1024)) / elapsedSinceSample).toFixed(1);
|
||||
lastSpeedSampleTime = now;
|
||||
lastSampleUploadedBytes = uploadedBytesAllFiles;
|
||||
}
|
||||
|
||||
const percent = Math.min(100, (uploadedBytesAllFiles / totalBytesAllFiles) * 100);
|
||||
const totalElapsedSec = (now - startTime) / 1000;
|
||||
const avgSpeedBytesPerSec = uploadedBytesAllFiles / totalElapsedSec;
|
||||
const remainingBytes = totalBytesAllFiles - uploadedBytesAllFiles;
|
||||
const etaSeconds = avgSpeedBytesPerSec > 0 ? Math.round(remainingBytes / avgSpeedBytesPerSec) : 0;
|
||||
|
||||
if (dotNetHelper && !isCancelled) {
|
||||
dotNetHelper.invokeMethodAsync('OnUploadProgress', {
|
||||
percent: percent,
|
||||
speedMb: parseFloat(currentSpeedMb) || 0,
|
||||
etaSeconds: etaSeconds,
|
||||
uploadedBytes: uploadedBytesAllFiles,
|
||||
totalBytes: totalBytesAllFiles,
|
||||
currentFileName: file.name
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Complete Upload
|
||||
const completePayload = {
|
||||
sessionId: activeSessionId,
|
||||
expirationDays: expirationDays,
|
||||
password: password,
|
||||
files: initPayload.files
|
||||
};
|
||||
|
||||
try {
|
||||
const compRes = await fetch('/api/upload/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(completePayload)
|
||||
});
|
||||
const result = await compRes.json();
|
||||
if (result.success && dotNetHelper) {
|
||||
dotNetHelper.invokeMethodAsync('OnUploadCompleted', result);
|
||||
clearSelectedFiles();
|
||||
} else if (dotNetHelper) {
|
||||
dotNetHelper.invokeMethodAsync('OnUploadError', result.error || 'Dokončenie zlyhalo.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (dotNetHelper) dotNetHelper.invokeMethodAsync('OnUploadError', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadChunk(formData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
currentXhr = xhr;
|
||||
xhr.open('POST', '/api/upload/chunk', true);
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.responseText);
|
||||
} else {
|
||||
reject(new Error(`Server vrátil kód ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
reject(new Error("Zlyhalo sieťové spojenie so serverom."));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
isCancelled = true;
|
||||
if (currentXhr) {
|
||||
try { currentXhr.abort(); } catch (e) { }
|
||||
}
|
||||
|
||||
if (activeSessionId) {
|
||||
fetch(`/api/upload/cancel/${activeSessionId}`, { method: 'POST' }).catch(() => { });
|
||||
activeSessionId = null;
|
||||
}
|
||||
|
||||
if (dotNetHelper) {
|
||||
dotNetHelper.invokeMethodAsync('OnUploadCancelled');
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
return new Promise((res, rej) => {
|
||||
document.execCommand('copy') ? res() : rej();
|
||||
textArea.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
registerDotNet,
|
||||
initDropzone,
|
||||
removeSelectedFile,
|
||||
clearSelectedFiles,
|
||||
startUpload,
|
||||
cancelUpload,
|
||||
copyToClipboard
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user