elismasilva's picture
Upload folder using huggingface_hub
9830094 verified
<script lang="ts">
import {
BlockLabel,
Empty,
ShareButton,
IconButton,
IconButtonWrapper,
FullscreenButton,
} from "@gradio/atoms";
import type { SelectData } from "@gradio/utils";
import { Image } from "@gradio/image/shared";
import { Video } from "@gradio/video/shared";
import { dequal } from "dequal";
import { createEventDispatcher, onMount } from "svelte";
import { tick } from "svelte";
import type { GalleryImage, GalleryVideo } from "../types";
import { Download, Image as ImageIcon, Clear, Play, Info } from "@gradio/icons";
import { FileData } from "@gradio/client";
import { format_gallery_for_sharing, extractMetadata, extensiveTechnicalMetadata } from "./utils";
import type { I18nFormatter } from "@gradio/utils";
type GalleryData = GalleryImage | GalleryVideo;
/**
* @component Gallery
* @description A Svelte component for displaying a gallery of images or videos with optional preview mode, fullscreen support, and metadata popup for images.
*/
// Component props
/** @prop {boolean} show_label - Whether to display the gallery label. Defaults to true. */
export let show_label = true;
/** @prop {string} label - The label text for the gallery. */
export let label: string;
/** @prop {GalleryData[] | null} value - Array of gallery items (images or videos). */
export let value: GalleryData[] | null = null;
/** @prop {number | number[] | undefined} columns - Number of grid columns or array of column counts per breakpoint. Defaults to [2]. */
export let columns: number | number[] | undefined = [2];
/** @prop {number | number[] | undefined} rows - Number of grid rows or array of row counts per breakpoint. */
export let rows: number | number[] | undefined = undefined;
/** @prop {number | "auto"} height - Gallery height in pixels or "auto". Defaults to "auto". */
export let height: number | "auto" = "auto";
/** @prop {boolean} preview - Whether to start in preview mode if a value is provided. */
export let preview: boolean;
/** @prop {boolean} allow_preview - Whether preview mode is enabled. Defaults to true. */
export let allow_preview = true;
/** @prop {"contain" | "cover" | "fill" | "none" | "scale-down"} object_fit - CSS object-fit for media. Defaults to "cover". */
export let object_fit: "contain" | "cover" | "fill" | "none" | "scale-down" = "cover";
/** @prop {boolean} show_share_button - Whether to show the share button. Defaults to false. */
export let show_share_button = false;
/** @prop {boolean} show_download_button - Whether to show the download button. Defaults to false. */
export let show_download_button = false;
/** @prop {I18nFormatter} i18n - Internationalization formatter for labels. */
export let i18n: I18nFormatter;
/** @prop {number | null} selected_index - Index of the selected media item. Defaults to null. */
export let selected_index: number | null = null;
/** @prop {boolean} interactive - Whether the gallery is interactive (not used). Defaults to false. */
export const interactive = false;
/** @prop {typeof fetch} _fetch - Fetch function for downloading files. */
export let _fetch: typeof fetch;
/** @prop {"normal" | "minimal"} mode - Display mode for the gallery. Defaults to "normal". */
export let mode: "normal" | "minimal" = "normal";
/** @prop {boolean} show_fullscreen_button - Whether to show the fullscreen button. Defaults to true. */
export let show_fullscreen_button = true;
/** @prop {boolean} display_icon_button_wrapper_top_corner - Whether to position icon buttons in the top corner. Defaults to false. */
export let display_icon_button_wrapper_top_corner = false;
/** @prop {boolean} fullscreen - Whether the gallery is in fullscreen mode. Defaults to false. */
export let fullscreen = false;
/** @prop {boolean} only_custom_metadata - Whether to show only custom metadata in the popup. Defaults to true. */
export let only_custom_metadata: boolean = true;
/** @prop {number | string} popup_metadata_width - Width of the metadata popup. Defaults to "50%". */
export let popup_metadata_width: number | string = "50%";
const dispatch = createEventDispatcher<{
change: undefined;
select: SelectData;
preview_open: undefined;
preview_close: undefined;
fullscreen: boolean;
error: string;
load_metadata: Record<string, any>;
}>();
// Gallery state
let is_full_screen = false;
let image_container: HTMLElement;
let was_reset = true;
let resolved_value: GalleryData[] | null = null;
let effective_columns: number | number[] | undefined = columns;
let prev_value: GalleryData[] | null = value;
let old_selected_index: number | null = selected_index;
let el: HTMLButtonElement[] = [];
let container_element: HTMLDivElement;
let thumbnails_overflow = false;
let preview_element: HTMLButtonElement | null = null;
// Metadata state
let metadata: Record<string, any> | null = null;
let showMetadataPopup: boolean = false;
let is_extracting_metadata = false;
$: filteredMetadata = only_custom_metadata && metadata
? Object.fromEntries(
Object.entries(metadata || {}).filter(([key]) => !extensiveTechnicalMetadata.has(key))
)
: metadata;
/**
* Toggles the metadata popup visibility and extracts metadata if needed.
*/
async function toggleMetadataPopup(): Promise<void> {
if (showMetadataPopup) {
showMetadataPopup = false;
return;
}
if (!selected_media) return;
const media_file = "image" in selected_media ? selected_media.image : null;
if (!media_file) return;
is_extracting_metadata = true;
metadata = await extractMetadata(media_file, only_custom_metadata);
is_extracting_metadata = false;
showMetadataPopup = true;
}
/**
* Dispatches the load_metadata event with filtered metadata and closes the popup.
*/
function dispatchLoadMetadata(): void {
if (filteredMetadata !== null) {
dispatch("load_metadata", filteredMetadata);
closePopup();
}
}
/**
* Closes the metadata popup.
*/
function closePopup(): void {
showMetadataPopup = false;
}
$: was_reset = value == null || value.length === 0;
$: resolved_value = value
? (value.map((data) =>
"video" in data
? { video: data.video as FileData, caption: data.caption }
: { image: data.image as FileData, caption: data.caption }
) as GalleryData[])
: null;
$: {
if (resolved_value && columns) {
const item_count = resolved_value.length;
if (Array.isArray(columns)) {
effective_columns = columns.map((col) => Math.min(col, item_count));
} else {
effective_columns = Math.min(columns, item_count);
}
} else {
effective_columns = columns;
}
}
$: if (!dequal(prev_value, value)) {
selected_index = null;
if (preview && value && value.length > 0) {
selected_index = 0;
}
dispatch("change");
prev_value = value;
}
$: selected_media =
selected_index != null && resolved_value != null
? resolved_value[selected_index]
: null;
$: has_extractable_metadata =
selected_media &&
"image" in selected_media &&
(selected_media.image.url?.toLowerCase().endsWith(".png") ||
selected_media.image.url?.toLowerCase().endsWith(".jpg") ||
selected_media.image.url?.toLowerCase().endsWith(".jpeg"));
$: previous = ((selected_index ?? 0) + (resolved_value?.length ?? 0) - 1) % (resolved_value?.length ?? 0);
$: next = ((selected_index ?? 0) + 1) % (resolved_value?.length ?? 0);
/**
* Handles click events on the preview image to navigate to the previous or next item.
* @param event - The mouse click event.
*/
function handle_preview_click(event: MouseEvent): void {
const element = event.target as HTMLElement;
const x = event.offsetX;
const centerX = element.offsetWidth / 2;
selected_index = x < centerX ? previous : next;
}
/**
* Handles keyboard navigation in preview mode.
* @param e - The keyboard event.
*/
function on_keydown(e: KeyboardEvent): void {
switch (e.code) {
case "Escape":
e.preventDefault();
selected_index = null;
dispatch("preview_close");
break;
case "ArrowLeft":
e.preventDefault();
selected_index = previous;
break;
case "ArrowRight":
e.preventDefault();
selected_index = next;
break;
}
}
$: {
if (selected_index !== old_selected_index) {
showMetadataPopup = false;
metadata = null;
old_selected_index = selected_index;
if (selected_index !== null) {
if (resolved_value != null) {
selected_index = Math.max(0, Math.min(selected_index, resolved_value.length - 1));
}
dispatch("select", {
index: selected_index,
value: resolved_value?.[selected_index],
});
}
}
}
$: if (allow_preview) {
scroll_to_img(selected_index);
}
$: if (selected_index !== null && preview_element) {
tick().then(() => {
preview_element?.focus();
});
}
/**
* Scrolls to the selected thumbnail in the thumbnails container.
* @param index - The index of the thumbnail to scroll to.
*/
async function scroll_to_img(index: number | null): Promise<void> {
if (typeof index !== "number" || !container_element) return;
await tick();
if (!el[index]) return;
el[index].focus();
const { left: container_left, width: container_width } = container_element.getBoundingClientRect();
const { left, width } = el[index].getBoundingClientRect();
const pos = left - container_left + width / 2 - container_width / 2 + container_element.scrollLeft;
container_element.scrollTo({ left: pos < 0 ? 0 : pos, behavior: "smooth" });
}
/**
* Downloads a file from the provided URL.
* @param file_url - The URL of the file to download.
* @param name - The name to use for the downloaded file.
*/
async function download(file_url: string, name: string): Promise<void> {
try {
const response = await _fetch(file_url);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = name;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
if (error instanceof TypeError) {
window.open(file_url, "_blank", "noreferrer");
return;
}
throw error;
}
}
/**
* Checks if the thumbnails container overflows horizontally.
*/
function check_thumbnails_overflow(): void {
if (container_element) {
thumbnails_overflow = container_element.scrollWidth > container_element.clientWidth;
}
}
/**
* Initializes the component, setting up event listeners for fullscreen and resize events.
*/
onMount(() => {
check_thumbnails_overflow();
document.addEventListener("fullscreenchange", () => {
is_full_screen = !!document.fullscreenElement;
fullscreen = is_full_screen;
});
window.addEventListener("resize", check_thumbnails_overflow);
return () => {
window.removeEventListener("resize", check_thumbnails_overflow);
document.removeEventListener("fullscreenchange", () => {
is_full_screen = !!document.fullscreenElement;
fullscreen = is_full_screen;
});
};
});
$: resolved_value, check_thumbnails_overflow();
$: if (container_element) check_thumbnails_overflow();
</script>
<svelte:window />
{#if show_label}
<BlockLabel {show_label} Icon={ImageIcon} label={label || "Gallery"} />
{/if}
{#if value == null || resolved_value == null || resolved_value.length === 0}
<Empty unpadded_box={true} size="large"><ImageIcon /></Empty>
{:else}
<div class="gallery-container" bind:this={image_container}>
<!-- Preview Mode -->
{#if selected_media && allow_preview}
<button
class="preview"
bind:this={preview_element}
class:minimal={mode === "minimal"}
aria-label="Image Preview"
tabindex="-1"
on:keydown={on_keydown}
>
<IconButtonWrapper display_top_corner={display_icon_button_wrapper_top_corner}>
{#if show_download_button}
<IconButton
Icon={Download}
label={i18n("common.download")}
on:click={() => {
const media =
"image" in selected_media
? selected_media.image
: selected_media.video;
if (media?.url) download(media.url, media.orig_name ?? "media");
}}
/>
{/if}
{#if show_fullscreen_button}
<FullscreenButton
{fullscreen}
on:fullscreen={() => {
fullscreen = !fullscreen;
if (fullscreen) {
preview_element?.requestFullscreen();
} else if (document.fullscreenElement) {
document.exitFullscreen();
}
}}
/>
{/if}
{#if has_extractable_metadata}
<IconButton
Icon={Info}
label="View Metadata"
pending={is_extracting_metadata}
on:click={(event) => {
event.stopPropagation();
toggleMetadataPopup();
}}
/>
{/if}
{#if show_share_button}
<div class="icon-button">
<ShareButton
{i18n}
on:share
on:error
{value}
formatter={format_gallery_for_sharing}
/>
</div>
{/if}
{#if !is_full_screen}
<IconButton
Icon={Clear}
label="Close"
on:click={() => {
selected_index = null;
dispatch("preview_close");
}}
/>
{/if}
</IconButtonWrapper>
<button
class="media-container"
on:click={"image" in selected_media ? handle_preview_click : null}
>
{#if "image" in selected_media}
<Image
src={selected_media.image.url}
alt={selected_media.caption || ""}
loading="lazy"
/>
{:else}
<Video
src={selected_media.video.url}
alt={selected_media.caption || ""}
loading="lazy"
controls={true}
loop={false}
is_stream={false}
/>
{/if}
</button>
{#if selected_media?.caption}
<caption class="caption">{selected_media.caption}</caption>
{/if}
<div
bind:this={container_element}
class="thumbnails scroll-hide"
style="justify-content: {thumbnails_overflow ? 'flex-start' : 'center'};"
>
{#each resolved_value as media, i}
<button
bind:this={el[i]}
on:click={() => (selected_index = i)}
class="thumbnail-item thumbnail-small"
class:selected={selected_index === i && mode !== "minimal"}
aria-label={"Thumbnail " + (i + 1) + " of " + resolved_value.length}
>
{#if "image" in media}
<Image
src={media.image.url}
title={media.caption || null}
alt=""
loading="lazy"
/>
{:else}
<Play />
<Video
src={media.video.url}
title={media.caption || null}
is_stream={false}
alt=""
loading="lazy"
loop={false}
/>
{/if}
</button>
{/each}
</div>
{#if showMetadataPopup && filteredMetadata !== null}
<div
class="metadata-popup"
on:click|stopPropagation
role="presentation"
style:width={typeof popup_metadata_width === "number" ? `${popup_metadata_width}px` : popup_metadata_width}
>
<div class="popup-content">
<button class="close-button" on:click={closePopup}>X</button>
<h3 class="popup-title">Image Metadata</h3>
{#if Object.keys(filteredMetadata).length > 0}
<div class="metadata-table-container">
<table class="metadata-table">
<tbody>
{#each Object.entries(filteredMetadata) as [key, val]}
{#if val}
<tr>
<td class="metadata-label">{key}</td>
<td class="metadata-value">{val}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
<button
class="load-metadata-button"
on:click={dispatchLoadMetadata}
>Load Metadata</button>
{:else}
<p class="no-metadata-message">No custom metadata found.</p>
{/if}
</div>
</div>
{/if}
</button>
{/if}
<!-- Main Grid / Single Item View -->
<div
class="grid-wrap"
class:minimal={mode === "minimal"}
class:fixed-height={!height || height == "auto"}
class:hidden={is_full_screen || (selected_media && allow_preview)}
style:height={height !== "auto" ? `${height}px` : null}
>
<!-- Multi-item grid -->
{#if resolved_value && resolved_value.length > 1}
<div
class="grid-container"
style:--grid-cols={Array.isArray(effective_columns) ? effective_columns.join(" ") : effective_columns}
style:--grid-rows={Array.isArray(rows) ? rows.join(" ") : rows}
style:--object-fit={object_fit}
class:pt-6={show_label}
>
{#each resolved_value as entry, i}
{@const file_name = "image" in entry ? entry.image.orig_name : entry.video.orig_name}
<div class="gallery-item-with-name">
<div class="gallery-item">
<button
class="thumbnail-item thumbnail-lg"
class:selected={selected_index === i}
on:click={() => {
if (selected_index === null && allow_preview)
dispatch("preview_open");
selected_index = i;
}}
aria-label={"Thumbnail " + (i + 1) + " of " + resolved_value.length}
>
{#if "image" in entry}
<Image
alt={entry.caption || ""}
src={entry.image.url}
loading="lazy"
/>
{:else}
<Play />
<Video
src={entry.video.url}
title={entry.caption || null}
is_stream={false}
alt=""
loading="lazy"
loop={false}
/>
{/if}
{#if entry.caption}
<div class="caption-label">{entry.caption}</div>
{/if}
</button>
</div>
{#if file_name}
<div class="thumbnail-filename" title={file_name}>
{file_name}
</div>
{/if}
</div>
{/each}
</div>
<!-- Single-item view -->
{:else if resolved_value && resolved_value.length === 1}
{@const entry = resolved_value[0]}
{@const file_name = "image" in entry ? entry.image.orig_name : entry.video.orig_name}
<div class="single-item-wrapper" style:--object-fit={object_fit}>
<div class="gallery-item-with-name">
<div class="gallery-item">
<button
class="thumbnail-item thumbnail-lg"
on:click={() => {
if (allow_preview) {
dispatch("preview_open");
selected_index = 0;
}
}}
aria-label="View single item in preview mode"
>
{#if "image" in entry}
<Image
alt={entry.caption || ""}
src={entry.image.url}
loading="lazy"
/>
{:else}
<Play />
<Video
src={entry.video.url}
title={entry.caption || null}
is_stream={false}
alt=""
loading="lazy"
loop={false}
/>
{/if}
{#if entry.caption}
<div class="caption-label">{entry.caption}</div>
{/if}
</button>
</div>
{#if file_name}
<div class="thumbnail-filename" title={file_name}>
{file_name}
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
/**
* Styles for the gallery container, which holds the entire component.
*/
.gallery-container {
position: relative;
width: 100%;
height: 100%;
}
/**
* Styles for the preview mode, displaying a selected media item.
*/
.preview {
display: flex;
position: absolute;
flex-direction: column;
z-index: var(--layer-2);
border-radius: calc(var(--block-radius) - var(--block-border-width));
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
width: var(--size-full);
height: var(--size-full);
}
.preview:focus-visible {
outline: none;
}
.preview.minimal {
width: fit-content;
height: fit-content;
}
.preview::before {
content: "";
position: absolute;
z-index: var(--layer-below);
background: var(--background-fill-primary);
opacity: 0.9;
width: var(--size-full);
height: var(--size-full);
}
/**
* Styles for the grid wrapper with fixed height constraints.
*/
.fixed-height {
min-height: var(--size-80);
max-height: 80vh;
}
@media (--screen-xl) {
.fixed-height {
min-height: 450px;
}
}
/**
* Styles for the media container in preview mode.
*/
.media-container {
height: calc(100% - var(--size-14));
width: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.media-container :global(img),
.media-container :global(video) {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/**
* Styles for thumbnails in the preview mode carousel.
*/
.thumbnails :global(img) {
object-fit: cover;
width: var(--size-full);
height: var(--size-full);
}
.thumbnails :global(svg) {
position: absolute;
top: var(--size-2);
left: var(--size-2);
width: 50%;
height: 50%;
opacity: 50%;
}
/**
* Styles for captions in preview mode.
*/
.caption {
padding: var(--size-2) var(--size-3);
overflow: hidden;
color: var(--block-label-text-color);
font-weight: var(--weight-semibold);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
align-self: center;
}
/**
* Styles for the thumbnails carousel in preview mode.
*/
.thumbnails {
display: flex;
position: absolute;
bottom: 0;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-lg);
width: var(--size-full);
height: var(--size-14);
overflow-x: scroll;
}
/**
* Styles for individual thumbnail items.
*/
.thumbnail-item {
--ring-color: transparent;
position: relative;
box-shadow: inset 0 0 0 1px var(--ring-color), var(--shadow-drop);
border: 1px solid var(--border-color-primary);
border-radius: var(--button-small-radius);
background: var(--background-fill-secondary);
aspect-ratio: var(--ratio-square);
width: var(--size-full);
height: var(--size-full);
overflow: clip;
}
.thumbnail-item:hover {
--ring-color: var(--color-accent);
border-color: var(--color-accent);
filter: brightness(1.1);
}
.thumbnail-item.selected {
--ring-color: var(--color-accent);
border-color: var(--color-accent);
}
.thumbnail-item :global(svg) {
position: absolute;
top: 50%;
left: 50%;
width: 50%;
height: 50%;
opacity: 50%;
transform: translate(-50%, -50%);
}
.thumbnail-item :global(video) {
width: var(--size-full);
height: var(--size-full);
overflow: hidden;
object-fit: cover;
}
/**
* Styles for small thumbnails in the preview carousel.
*/
.thumbnail-small {
flex: none;
transform: scale(0.9);
transition: 0.075s;
width: var(--size-9);
height: var(--size-9);
}
.thumbnail-small.selected {
--ring-color: var(--color-accent);
transform: scale(1);
border-color: var(--color-accent);
}
/**
* Styles for the grid wrapper containing the gallery items.
*/
.grid-wrap {
position: relative;
padding: var(--size-2);
overflow-y: auto;
}
.grid-wrap.fixed-height {
min-height: var(--size-80);
max-height: 80vh;
}
/**
* Styles for the grid container for multiple items.
*/
.grid-container {
display: grid;
position: relative;
grid-template-rows: repeat(var(--grid-rows), minmax(100px, 1fr));
grid-template-columns: repeat(var(--grid-cols), minmax(100px, 1fr));
grid-auto-rows: minmax(100px, 1fr);
gap: var(--spacing-lg);
}
/**
* Styles for single-item view wrapper.
*/
.single-item-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: var(--spacing-xxl);
box-sizing: border-box;
}
.single-item-wrapper .gallery-item-with-name {
width: 100%;
height: 100%;
max-width: min(300px, 80vw);
max-height: min(320px, calc(80vh - var(--size-4)));
display: flex;
flex-direction: column;
align-items: center;
}
.single-item-wrapper .gallery-item {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
.single-item-wrapper .thumbnail-item.thumbnail-lg {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.single-item-wrapper .thumbnail-filename {
height: var(--size-4);
line-height: var(--size-4);
}
.single-item-wrapper .thumbnail-lg > :global(img),
.single-item-wrapper .thumbnail-lg > :global(video) {
object-fit: var(--object-fit) !important;
}
/**
* Styles for large thumbnails in the grid or single-item view.
*/
.thumbnail-lg > :global(img),
.thumbnail-lg > :global(video) {
width: var(--size-full);
height: var(--size-full);
overflow: hidden;
object-fit: var(--object-fit);
}
.thumbnail-lg:hover .caption-label {
opacity: 0.5;
}
/**
* Styles for captions in the grid or single-item view.
*/
.caption-label {
position: absolute;
right: var(--block-label-margin);
bottom: var(--block-label-margin);
z-index: var(--layer-1);
border-top: 1px solid var(--border-color-primary);
border-left: 1px solid var(--border-color-primary);
border-radius: var(--block-label-radius);
background: var(--background-fill-secondary);
padding: var(--block-label-padding);
max-width: 80%;
overflow: hidden;
font-size: var(--block-label-text-size);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.grid-wrap.minimal {
padding: 0;
}
/**
* Styles for gallery items with associated filenames.
*/
.gallery-item-with-name {
display: flex;
flex-direction: column;
gap: var(--size-1);
width: 100%;
height: 100%;
}
.thumbnail-filename {
font-size: var(--text-xs);
color: var(--body-text-color);
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 var(--size-1);
}
.gallery-item {
position: relative;
width: 100%;
height: 100%;
}
/**
* Styles for the metadata popup displayed in preview mode.
*/
.metadata-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--background-fill-primary, white);
border: 1px solid var(--border-color-primary);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
z-index: 1000;
border-radius: 8px;
max-width: min(90%, 600px);
max-height: min(50vh, calc(100% - 2rem));
min-height: 200px;
display: flex;
flex-direction: column;
pointer-events: auto;
}
.popup-content {
padding: 1rem;
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
overflow-y: auto;
position: relative;
}
.close-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
z-index: 20;
color: var(--body-text-color);
padding: 0.25rem;
line-height: 1;
width: 24px;
height: 24px;
text-align: center;
}
.popup-title {
font-weight: bold;
margin: 0 0 1rem 0;
flex-shrink: 0;
padding-right: 2.5rem;
}
.metadata-table-container {
flex-grow: 1;
overflow-y: auto;
max-height: calc(100% - 5rem);
min-height: 0;
margin-bottom: 1rem;
}
.metadata-table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
.metadata-label {
background: var(--background-fill-secondary, #f5f5f5);
padding: 0.5rem;
font-weight: bold;
text-align: left;
vertical-align: top;
width: 45%;
}
.metadata-value {
text-align: left;
padding: 0.5rem;
white-space: pre-wrap;
word-break: break-all;
vertical-align: top;
}
.load-metadata-button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background-color: var(--button-primary-border-color);
color: var(--button-primary-text-color);
border: none;
border-radius: 4px;
cursor: pointer;
align-self: center;
flex-shrink: 0;
}
.load-metadata-button:hover {
background-color: var(--button-primary-border-color);
}
.no-metadata-message {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--body-text-color-subdued);
}
</style>