feat(share): inline media preview on single-file share landing

The public share page only rendered media previews for FOLDER shares —
a single-file share got a bare icon + download button, even for images
and videos the browser can play natively.

Backend: resolve the shared file's mime_type + size at read time and
expose them on ShareDto (meta + password-verify endpoints, one shared
enrichment helper). Display-only enrichment: a failed file lookup
leaves the fields None instead of failing the response — the download
endpoint still surfaces the real error.

Frontend: the 'file' view now reuses the folder grid's lazyVideo
(poster-seek + retry) for video and imageRetry for images, with
Range-aware streaming already provided by /api/s/{token}/file/{id}.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 16:00:30 +08:00
parent 4d067b3fc6
commit 6ee26e46e6
5 changed files with 174 additions and 3 deletions
+10
View File
@@ -9,6 +9,16 @@ import type { ItemType } from '$lib/api/types';
export interface ShareMeta {
item_type: ItemType;
item_name: string;
/** The shared item's id — file shares use it to build the preview src. */
item_id: string;
/**
* File shares only: resolved by the server at read time so the landing
* page can inline a media preview (video player / image) instead of a
* bare download button. Absent for folder shares.
*/
mime_type?: string;
/** File shares only: the shared file's size in bytes. */
size?: number;
}
export interface ShareFolderEntry {
+30 -1
View File
@@ -287,7 +287,29 @@
</form>
{:else if view === 'file'}
<div class="share__center">
<Icon name="file" class="share__big-icon" />
{#if meta && mediaKind(meta.mime_type) === 'video'}
<!-- Inline player: Range-aware endpoint streams the video, so the
timeline seeks without downloading the whole file first.
lazyVideo defers the load, seeks a few frames in for a poster
and retries once on error (same behaviour as the folder grid). -->
<video
class="share__media"
data-testid="public-share-file-video"
use:lazyVideo={shareFileUrl(token, meta.item_id)}
controls
playsinline
></video>
{:else if meta && mediaKind(meta.mime_type) === 'image'}
<img
class="share__media"
data-testid="public-share-file-image"
src={shareFileUrl(token, meta.item_id)}
alt={meta.item_name}
use:imageRetry
/>
{:else}
<Icon name="file" class="share__big-icon" />
{/if}
<h1>{meta?.item_name}</h1>
<a
class="share__btn"
@@ -486,6 +508,13 @@
text-align: center;
}
.share__media {
max-width: 100%;
max-height: min(70vh, 40rem);
border-radius: var(--radius-2xl);
border: 1px solid var(--color-border);
}
:global(.share__big-icon) {
font-size: 3rem;
color: var(--color-text-muted);
+11
View File
@@ -16,6 +16,15 @@ pub struct ShareDto {
pub created_at: u64,
pub created_by: String,
pub access_count: u64,
/// File shares only: the shared file's MIME type, resolved at read time
/// so anonymous viewers can render an inline media preview (video player
/// / image) instead of a bare download button. Absent for folder shares
/// and whenever the file lookup fails (display-only enrichment).
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// File shares only: the shared file's size in bytes (see `mime_type`).
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -49,6 +58,8 @@ impl ShareDto {
created_at: share.created_at(),
created_by: share.created_by().to_string(),
access_count: share.access_count(),
mime_type: None,
size: None,
}
}
}
+122 -1
View File
@@ -249,6 +249,18 @@ impl ShareService {
};
self.fetch_share_resolved(token, unlocked).await
}
/// Public share landing payload: share metadata enriched with the shared
/// file's `mime_type` + `size` so anonymous viewers get an inline media
/// preview (video player / image) instead of a bare download button.
pub async fn get_shared_link_meta_with_unlock(
&self,
token: &str,
unlock_jwt: Option<&str>,
) -> Result<ShareDto, DomainError> {
let dto = self.get_shared_link_with_unlock(token, unlock_jwt).await?;
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
}
}
impl ShareUseCase for ShareService {
@@ -538,7 +550,8 @@ impl ShareUseCase for ShareService {
}
// Password verified (or not required) — return full share metadata
Ok(ShareDto::from_entity(&share, &self.base_url))
let dto = ShareDto::from_entity(&share, &self.base_url);
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
}
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
@@ -558,6 +571,31 @@ impl ShareUseCase for ShareService {
}
}
/// Fill `mime_type`/`size` on file-share DTOs so anonymous viewers get an
/// inline media preview (video player / image) on the public share page
/// instead of a bare download button.
///
/// Display-only enrichment that never fails the response: a failed file
/// lookup (transient DB error, race with a delete) leaves the fields `None`
/// and the download endpoint surfaces the real error — a read failure is
/// never proof the data is absent. Folder shares pass through untouched.
///
/// A free function (not a method) so the `integration_tests` mirror of the
/// service exercises the exact same logic instead of re-implementing it.
async fn enrich_share_dto_with_file_info<FR: FileReadPort>(
mut dto: ShareDto,
file_repository: &FR,
) -> ShareDto {
if dto.item_type != "file" {
return dto;
}
if let Ok(file) = file_repository.get_file(&dto.item_id).await {
dto.mime_type = Some(file.mime_type().to_string());
dto.size = Some(file.size());
}
dto
}
#[cfg(feature = "integration_tests")]
#[allow(dead_code)]
mod tests {
@@ -642,6 +680,18 @@ mod tests {
})?;
self.password_hasher.hash_password(password).await
}
/// Mirror of `ShareService::get_shared_link_meta_with_unlock` —
/// fetch by token (the mirror has no unlock-JWT machinery) plus the
/// shared file-info enrichment.
async fn get_shared_link_meta_with_unlock(
&self,
token: &str,
_unlock_jwt: Option<&str>,
) -> Result<ShareDto, DomainError> {
let dto = self.get_shared_link_by_token(token).await?;
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
}
}
impl<SR, FR, FoR, PH> ShareUseCase for ShareServiceForTest<SR, FR, FoR, PH>
@@ -1261,4 +1311,75 @@ mod tests {
assert!(share_dto.has_password);
assert!(share_dto.url.starts_with("http://127.0.0.1:8086/s/"));
}
/// The share-landing meta endpoint enriches file shares with the shared
/// file's mime type + size so anonymous viewers can render an inline
/// preview (video player / image) instead of a bare download button.
#[tokio::test]
async fn test_get_shared_link_meta_enriches_file_shares() {
let config = Arc::new(AppConfig::default());
let service = ShareServiceForTest::new(
config,
Arc::new(MockShareRepository::new()),
Arc::new(MockFileRepository),
Arc::new(MockFolderRepository),
Arc::new(MockPasswordHasher),
);
let share = service
.create_shared_link(
Uuid::new_v4(),
CreateShareDto {
item_id: "test_file_id".to_string(),
item_name: Some("movie.mp4".to_string()),
item_type: "file".to_string(),
password: None,
expires_at: None,
},
)
.await
.unwrap();
let meta = service
.get_shared_link_meta_with_unlock(&share.token, None)
.await
.unwrap();
assert_eq!(meta.mime_type.as_deref(), Some("text/plain"));
assert_eq!(meta.size, Some(123));
}
/// Folder shares must NOT gain a bogus mime type — the enrichment is a
/// file-share-only passthrough for them.
#[tokio::test]
async fn test_get_shared_link_meta_leaves_folder_shares_unenriched() {
let config = Arc::new(AppConfig::default());
let service = ShareServiceForTest::new(
config,
Arc::new(MockShareRepository::new()),
Arc::new(MockFileRepository),
Arc::new(MockFolderRepository),
Arc::new(MockPasswordHasher),
);
let share = service
.create_shared_link(
Uuid::new_v4(),
CreateShareDto {
item_id: "test_folder_id".to_string(),
item_name: Some("pictures".to_string()),
item_type: "folder".to_string(),
password: None,
expires_at: None,
},
)
.await
.unwrap();
let meta = service
.get_shared_link_meta_with_unlock(&share.token, None)
.await
.unwrap();
assert_eq!(meta.mime_type, None);
assert_eq!(meta.size, None);
}
}
+1 -1
View File
@@ -242,7 +242,7 @@ pub async fn access_shared_item(
// every public share landing).
let (_, item) = tokio::join!(
share_use_case.register_shared_link_access(&token),
share_use_case.get_shared_link_with_unlock(&token, unlock_jwt.as_deref()),
share_use_case.get_shared_link_meta_with_unlock(&token, unlock_jwt.as_deref()),
);
match item {