feat: Stage 2 - Target Image Layout & printtarg Integration #12
Generated
+1
@@ -1579,6 +1579,7 @@ dependencies = [
|
||||
name = "iccery"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -24,4 +24,5 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
base64 = "0.22"
|
||||
|
||||
|
||||
@@ -63,6 +63,16 @@ pub struct TargenConfig {
|
||||
pub cwd: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct PrinttargConfig {
|
||||
pub instrument: String, // One of: "i1", "p3", "CM", "SS", "20", "22", "41", "51"
|
||||
pub page_size: String, // One of: "A4", "A4R", "A3", "A2", "Letter", "LetterR", "Legal", "4x6", "11x17", or "WWWxHHH"
|
||||
pub bit_depth: u8, // 8 or 16
|
||||
pub dpi: u32, // TIFF resolution, e.g. 100, 200, 300
|
||||
pub basename: String, // Must match the .ti1 basename from Stage 1
|
||||
pub cwd: String, // Working directory where the .ti1 file resides
|
||||
}
|
||||
|
||||
pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-v".to_string(),
|
||||
@@ -92,6 +102,27 @@ pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
|
||||
args
|
||||
}
|
||||
|
||||
pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-v".to_string(),
|
||||
"-u".to_string(),
|
||||
"-i".to_string(),
|
||||
config.instrument.clone(),
|
||||
"-p".to_string(),
|
||||
config.page_size.clone(),
|
||||
];
|
||||
|
||||
if config.bit_depth == 16 {
|
||||
args.push("-T".to_string());
|
||||
} else {
|
||||
args.push("-t".to_string());
|
||||
}
|
||||
args.push(config.dpi.to_string());
|
||||
|
||||
args.push(config.basename.clone());
|
||||
args
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_targen(
|
||||
app: AppHandle,
|
||||
@@ -113,6 +144,33 @@ pub async fn run_targen(
|
||||
state.spawn(app, id, binary, args, cwd).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_printtarg(
|
||||
app: AppHandle,
|
||||
state: State<'_, ProcessManager>,
|
||||
config: PrinttargConfig,
|
||||
) -> Result<(), String> {
|
||||
let binary = resolve_binary(app.clone(), "printtarg".to_string()).await?;
|
||||
let args = build_printtarg_args(&config);
|
||||
let id = format!("printtarg_{}", config.basename);
|
||||
|
||||
let cwd = if config.cwd.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(config.cwd.clone())
|
||||
};
|
||||
|
||||
state.spawn(app, id, binary, args, cwd).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_file_base64(path: String) -> Result<String, String> {
|
||||
use std::fs;
|
||||
let bytes = fs::read(&path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
use base64::Engine;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -144,4 +202,46 @@ mod tests {
|
||||
let args = build_targen_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-d", "4", "-f", "1500", "-B", "8", "cmyk_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_printtarg_args_i1_a4_8bit() {
|
||||
let config = PrinttargConfig {
|
||||
instrument: "i1".to_string(),
|
||||
page_size: "A4".to_string(),
|
||||
bit_depth: 8,
|
||||
dpi: 100,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-t", "100", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_printtarg_args_cm_letter_16bit() {
|
||||
let config = PrinttargConfig {
|
||||
instrument: "CM".to_string(),
|
||||
page_size: "Letter".to_string(),
|
||||
bit_depth: 16,
|
||||
dpi: 300,
|
||||
basename: "cmyk_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-T", "300", "cmyk_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_printtarg_args_custom_page_size() {
|
||||
let config = PrinttargConfig {
|
||||
instrument: "SS".to_string(),
|
||||
page_size: "200x400".to_string(),
|
||||
bit_depth: 8,
|
||||
dpi: 150,
|
||||
basename: "custom_target".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ pub fn run() {
|
||||
commands::resolve_binary,
|
||||
commands::list_instruments,
|
||||
commands::run_targen,
|
||||
commands::run_printtarg,
|
||||
commands::read_file_base64,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+88
-2
@@ -84,8 +84,94 @@
|
||||
<!-- Stage 2: printtarg -->
|
||||
<section id="stage-2" class="stage hidden">
|
||||
<h2>Print Layout</h2>
|
||||
<p>Prepare the target for printing based on your instrument.</p>
|
||||
<button class="primary" id="btnLayout">Create Layout (.ti2, .tif)</button>
|
||||
<p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p>
|
||||
|
||||
<!-- Colour management warning banner -->
|
||||
<div class="warning-banner" id="cmWarningBanner">
|
||||
<span class="warning-icon">⚠️</span>
|
||||
<div class="warning-text">
|
||||
<strong>CRITICAL: Disable ALL printer driver colour management when printing these targets.</strong>
|
||||
<p>Set your printer driver to "No Colour Adjustment" (Epson), "Off (No Colour Adjustment)" (Canon), or the equivalent setting for your printer. Failure to do this will produce an incorrect profile.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<div class="form-group">
|
||||
<label>Measurement Instrument</label>
|
||||
<select id="instrumentSelect">
|
||||
<option value="i1" selected>X-Rite i1Pro / i1Pro2</option>
|
||||
<option value="p3">X-Rite i1Pro3+</option>
|
||||
<option value="CM">X-Rite ColorMunki</option>
|
||||
<option value="SS">X-Rite SpectroScan</option>
|
||||
<option value="20">X-Rite DTP20</option>
|
||||
<option value="22">X-Rite DTP22</option>
|
||||
<option value="41">X-Rite DTP41</option>
|
||||
<option value="51">X-Rite DTP51</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Page Size</label>
|
||||
<select id="pageSizeSelect">
|
||||
<option value="A4" selected>A4 [210 × 297 mm]</option>
|
||||
<option value="A4R">A4 Landscape [297 × 210 mm]</option>
|
||||
<option value="A3">A3 [297 × 420 mm]</option>
|
||||
<option value="A2">A2 [420 × 594 mm]</option>
|
||||
<option value="Letter">US Letter [215.9 × 279.4 mm]</option>
|
||||
<option value="LetterR">US Letter Landscape [279.4 × 215.9 mm]</option>
|
||||
<option value="Legal">US Legal [215.9 × 355.6 mm]</option>
|
||||
<option value="4x6">4×6 [101.6 × 152.4 mm]</option>
|
||||
<option value="11x17">11×17 [279.4 × 431.8 mm]</option>
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<div id="customPageSizeRow" class="input-row hidden" style="margin-top: 8px;">
|
||||
<div>
|
||||
<label for="customPageW" class="sub-label">Width (mm)</label>
|
||||
<input type="number" id="customPageW" min="50" max="2000" placeholder="e.g. 200">
|
||||
</div>
|
||||
<div>
|
||||
<label for="customPageH" class="sub-label">Height (mm)</label>
|
||||
<input type="number" id="customPageH" min="50" max="2000" placeholder="e.g. 400">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>TIFF Output</label>
|
||||
<div class="input-row">
|
||||
<div>
|
||||
<label class="sub-label">Bit Depth</label>
|
||||
<div class="radio-group">
|
||||
<label><input type="radio" name="bitDepth" value="8" checked> 8-bit</label>
|
||||
<label><input type="radio" name="bitDepth" value="16"> 16-bit</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tiffDpi" class="sub-label">Resolution (DPI)</label>
|
||||
<select id="tiffDpi">
|
||||
<option value="100">100 DPI (Draft)</option>
|
||||
<option value="200" selected>200 DPI (Standard)</option>
|
||||
<option value="300">300 DPI (High Quality)</option>
|
||||
<option value="600">600 DPI (Ultra)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
|
||||
|
||||
<div class="log-container hidden" id="printtargLogContainer">
|
||||
<h4>Process Output</h4>
|
||||
<pre id="printtargLog"></pre>
|
||||
</div>
|
||||
|
||||
<!-- TIFF preview gallery (populated dynamically after generation) -->
|
||||
<div class="tiff-gallery hidden" id="tiffGallery">
|
||||
<h3>Generated Target Pages</h3>
|
||||
<div class="gallery-info" id="galleryInfo"></div>
|
||||
<div class="gallery-grid" id="galleryGrid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stage 3: chartread -->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { initTargen } from './targen.js';
|
||||
import { initPrinttarg } from './printtarg.js';
|
||||
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
@@ -27,4 +28,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Initialize Stage 1
|
||||
initTargen();
|
||||
|
||||
// Initialize Stage 2
|
||||
initPrinttarg();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
// Module-level state: set by Stage 1 when it completes
|
||||
let stage1Basename = "";
|
||||
let stage1Cwd = "";
|
||||
|
||||
/**
|
||||
* Called by targen.js (or app.js) after Stage 1 completes.
|
||||
* Passes the basename and working directory forward.
|
||||
*/
|
||||
export function setStage1Result(basename, cwd) {
|
||||
stage1Basename = basename;
|
||||
stage1Cwd = cwd;
|
||||
}
|
||||
|
||||
export function initPrinttarg() {
|
||||
const instrumentSelect = document.getElementById("instrumentSelect");
|
||||
const pageSizeSelect = document.getElementById("pageSizeSelect");
|
||||
const customPageSizeRow = document.getElementById("customPageSizeRow");
|
||||
const customPageW = document.getElementById("customPageW");
|
||||
const customPageH = document.getElementById("customPageH");
|
||||
const bitDepthRadios = document.querySelectorAll('input[name="bitDepth"]');
|
||||
const tiffDpi = document.getElementById("tiffDpi");
|
||||
const btnCreateLayout = document.getElementById("btnCreateLayout");
|
||||
const logContainer = document.getElementById("printtargLogContainer");
|
||||
const logPre = document.getElementById("printtargLog");
|
||||
const tiffGallery = document.getElementById("tiffGallery");
|
||||
const galleryInfo = document.getElementById("galleryInfo");
|
||||
const galleryGrid = document.getElementById("galleryGrid");
|
||||
|
||||
// Show/hide custom page size inputs
|
||||
pageSizeSelect.addEventListener("change", (e) => {
|
||||
if (e.target.value === "custom") {
|
||||
customPageSizeRow.classList.remove("hidden");
|
||||
} else {
|
||||
customPageSizeRow.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Create Layout button
|
||||
btnCreateLayout.addEventListener("click", async () => {
|
||||
// Validate that Stage 1 has been completed
|
||||
if (!stage1Basename) {
|
||||
logPre.textContent = "[ERROR] No .ti1 file available. Complete Stage 1 first.\n";
|
||||
logContainer.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
logPre.textContent = "";
|
||||
logContainer.classList.remove("hidden");
|
||||
tiffGallery.classList.add("hidden");
|
||||
galleryGrid.innerHTML = "";
|
||||
btnCreateLayout.disabled = true;
|
||||
|
||||
// Determine page size
|
||||
let pageSize = pageSizeSelect.value;
|
||||
if (pageSize === "custom") {
|
||||
const w = parseInt(customPageW.value, 10);
|
||||
const h = parseInt(customPageH.value, 10);
|
||||
if (!w || !h || w < 50 || h < 50) {
|
||||
logPre.textContent = "[ERROR] Custom page size must have width and height ≥ 50mm.\n";
|
||||
btnCreateLayout.disabled = false;
|
||||
return;
|
||||
}
|
||||
pageSize = `${w}x${h}`;
|
||||
}
|
||||
|
||||
// Determine bit depth
|
||||
let bitDepth = 8;
|
||||
bitDepthRadios.forEach(radio => {
|
||||
if (radio.checked) bitDepth = parseInt(radio.value, 10);
|
||||
});
|
||||
|
||||
const config = {
|
||||
instrument: instrumentSelect.value,
|
||||
page_size: pageSize,
|
||||
bit_depth: bitDepth,
|
||||
dpi: parseInt(tiffDpi.value, 10),
|
||||
basename: stage1Basename,
|
||||
cwd: stage1Cwd,
|
||||
};
|
||||
|
||||
const processId = `printtarg_${stage1Basename}`;
|
||||
|
||||
// Accumulate all stdout lines to extract JSON manifest at the end
|
||||
let stdoutAccumulator = "";
|
||||
|
||||
try {
|
||||
const unlistenStdout = await listen("process:stdout", (event) => {
|
||||
if (event.payload.id === processId && event.payload.line) {
|
||||
stdoutAccumulator += event.payload.line + "\n";
|
||||
logPre.textContent += event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||
if (event.payload.id === processId && event.payload.line) {
|
||||
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenExit = await listen("process:exit", (event) => {
|
||||
if (event.payload.id === processId) {
|
||||
unlistenStdout();
|
||||
unlistenStderr();
|
||||
unlistenExit();
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
logPre.textContent += "\n[SUCCESS] printtarg completed successfully.\n";
|
||||
btnCreateLayout.disabled = false;
|
||||
|
||||
// Parse the JSON manifest from stdout
|
||||
const manifest = extractManifest(stdoutAccumulator);
|
||||
if (manifest && manifest.pages && manifest.pages.length > 0) {
|
||||
renderTiffGallery(manifest, config.cwd);
|
||||
}
|
||||
|
||||
advanceToStage3();
|
||||
} else {
|
||||
logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`;
|
||||
btnCreateLayout.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logPre.textContent = "Starting printtarg...\n";
|
||||
await invoke("run_printtarg", { config });
|
||||
|
||||
} catch (err) {
|
||||
logPre.textContent += `\n[INVOKE ERROR] ${err}\n`;
|
||||
btnCreateLayout.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract the JSON manifest object from the accumulated stdout.
|
||||
* The manifest is emitted by printtarg -u as a JSON block starting with { and ending with }.
|
||||
* It always appears at the end of stdout, after all "Creating file..." lines.
|
||||
*/
|
||||
function extractManifest(stdout) {
|
||||
try {
|
||||
// Find the last JSON object in the output
|
||||
const jsonStart = stdout.lastIndexOf('{\n "event": "manifest"');
|
||||
if (jsonStart === -1) return null;
|
||||
const jsonEnd = stdout.indexOf('\n}', jsonStart);
|
||||
if (jsonEnd === -1) return null;
|
||||
const jsonStr = stdout.substring(jsonStart, jsonEnd + 2);
|
||||
return JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse printtarg manifest:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render TIFF preview gallery using base64-encoded images from the backend.
|
||||
* @param {Object} manifest - The parsed JSON manifest from printtarg -u
|
||||
* @param {string} cwd - The working directory where TIFFs were generated
|
||||
*/
|
||||
async function renderTiffGallery(manifest, cwd) {
|
||||
tiffGallery.classList.remove("hidden");
|
||||
|
||||
const pageCount = manifest.pages.length;
|
||||
const totalPatches = manifest.pages.reduce((sum, p) => sum + p.patches, 0);
|
||||
const dims = manifest.pages[0];
|
||||
galleryInfo.textContent = `${pageCount} page(s) · ${totalPatches} patches · ${dims.width_mm} × ${dims.height_mm} mm per page`;
|
||||
|
||||
for (const page of manifest.pages) {
|
||||
const sep = cwd.includes('\\') ? '\\' : '/';
|
||||
const filePath = cwd ? `${cwd}${sep}${page.filename}` : page.filename;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "gallery-card";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "gallery-label";
|
||||
label.textContent = `${page.filename} (${page.patches} patches)`;
|
||||
card.appendChild(label);
|
||||
|
||||
try {
|
||||
const base64Data = await invoke("read_file_base64", { path: filePath });
|
||||
const img = document.createElement("img");
|
||||
img.src = `data:image/tiff;base64,${base64Data}`;
|
||||
img.alt = page.filename;
|
||||
// TIFF may not render natively in all browsers — provide a fallback
|
||||
img.onerror = () => {
|
||||
img.remove();
|
||||
const fallback = document.createElement("div");
|
||||
fallback.className = "gallery-fallback";
|
||||
fallback.innerHTML = `<span class="file-icon">📄</span><span>${page.filename}</span><span class="file-size">${page.patches} patches</span>`;
|
||||
card.insertBefore(fallback, label.nextSibling);
|
||||
};
|
||||
card.appendChild(img);
|
||||
} catch (err) {
|
||||
const fallback = document.createElement("div");
|
||||
fallback.className = "gallery-fallback";
|
||||
fallback.innerHTML = `<span class="file-icon">📄</span><span>${page.filename}</span><span class="file-size">Preview unavailable</span>`;
|
||||
card.appendChild(fallback);
|
||||
}
|
||||
|
||||
galleryGrid.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function advanceToStage3() {
|
||||
const steps = document.querySelectorAll('.step');
|
||||
const stages = document.querySelectorAll('.stage');
|
||||
|
||||
steps.forEach(s => s.classList.remove('active'));
|
||||
if (steps[2]) steps[2].classList.add('active');
|
||||
|
||||
stages.forEach(s => {
|
||||
s.classList.remove('active');
|
||||
s.classList.add('hidden');
|
||||
});
|
||||
if (stages[2]) {
|
||||
stages[2].classList.remove('hidden');
|
||||
stages[2].classList.add('active');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
const { save } = window.__TAURI__.dialog;
|
||||
import { setStage1Result } from './printtarg.js';
|
||||
|
||||
export function initTargen() {
|
||||
const colourSpaceRadios = document.querySelectorAll('input[name="colourSpace"]');
|
||||
@@ -131,6 +132,7 @@ export function initTargen() {
|
||||
// In a real app we'd dispatch an event to advance the stepper here.
|
||||
// For now, we'll manually unlock stage 2 in the state.
|
||||
btnGenerate.disabled = false;
|
||||
setStage1Result(basename, currentWorkingDir);
|
||||
advanceToStage2();
|
||||
} else {
|
||||
logPre.textContent += `\n[ERROR] Targen exited with code ${event.payload.code}.\n`;
|
||||
|
||||
+108
-1
@@ -225,7 +225,8 @@ button:disabled {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#targenLog {
|
||||
#targenLog,
|
||||
#printtargLog {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
@@ -234,3 +235,109 @@ button:disabled {
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 2: Warning Banner
|
||||
======================================== */
|
||||
.warning-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: rgba(255, 152, 0, 0.12);
|
||||
border: 1px solid rgba(255, 152, 0, 0.4);
|
||||
border-left: 4px solid #ff9800;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warning-text strong {
|
||||
display: block;
|
||||
color: #ffb74d;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.warning-text p {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 2: TIFF Gallery
|
||||
======================================== */
|
||||
.tiff-gallery {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tiff-gallery h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gallery-info {
|
||||
font-size: 0.9em;
|
||||
color: #888;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gallery-card {
|
||||
background: var(--panel-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.gallery-card:hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.gallery-card img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.gallery-label {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85em;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gallery-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px 16px;
|
||||
background: var(--bg-color);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gallery-fallback .file-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.gallery-fallback .file-size {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user