Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shuttle-platform-backend",
"version": "2.0.1",
"version": "2.0.2",
"private": true,
"scripts": {
"start": "node src/index.js",
Expand Down
28 changes: 25 additions & 3 deletions backend/src/orders.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,22 @@ router.post('/:slug/orders/wipe', (req, res) => {
}
});

// GET /api/shops/:slug/orders/po/:filename — Download a PO file (PDF)
// Content-type map for common PO file extensions
const PO_CONTENT_TYPES = {
'.pdf': 'application/pdf',
'.html': 'text/html',
'.htm': 'text/html',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.csv': 'text/csv',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};

// GET /api/shops/:slug/orders/po/:filename — Download/open a PO file
router.get('/:slug/orders/po/:filename', (req, res) => {
const { slug, filename } = req.params;

Expand All @@ -114,8 +129,15 @@ router.get('/:slug/orders/po/:filename', (req, res) => {
return res.status(404).json({ error: 'PO file not found' });
}

res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
const ext = path.extname(safeName).toLowerCase();
const contentType = PO_CONTENT_TYPES[ext] || 'application/octet-stream';

// PDFs, HTML, and images can be shown inline; everything else triggers download
const isInline = ext === '.pdf' || ext === '.html' || ext === '.htm' || ext === '.png' || ext === '.jpg' || ext === '.jpeg';
const disposition = isInline ? 'inline' : 'attachment';

res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `${disposition}; filename="${safeName}"`);
fs.createReadStream(filePath).pipe(res);
});

Expand Down
13 changes: 7 additions & 6 deletions backend/src/shops.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,14 +477,15 @@ router.post('/', (req, res) => {
`details: ${dr.details !== false}`,
`extra_notes: ${dr.extra_notes !== false}`,
`shipping_handler: ${dr.shipping_handler !== false}`,
`hotel_list: ${dr.hotel_list === true}`,
`hotel_list: ${dr.hotel_list === true || dr.hotel_collection === true}`,
`hotel_collection: ${dr.hotel_collection === true}`,
].join('\n');
fs.writeFileSync(
path.join(shopDir, 'DATABASE', 'Presets', 'DataRequired.txt'),
drContent
);

if (dr.hotel_list && hotelList) {
if ((dr.hotel_list || dr.hotel_collection) && hotelList) {
fs.mkdirSync(path.join(shopDir, 'DATABASE', 'Design', 'Details'), { recursive: true });
fs.writeFileSync(
path.join(shopDir, 'DATABASE', 'Design', 'Details', 'Hotels.txt'),
Expand Down Expand Up @@ -523,7 +524,7 @@ router.post('/', (req, res) => {
// Register in database
db.prepare(
'INSERT INTO shops (slug, name, description, status, port, subdomain, shuttle_version) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(slug, name, description || '', 'stopped', port, subdomain, 'STS-2.00');
).run(slug, name, description || '', 'stopped', port, subdomain, 'STS-2.01');

// Start the container using container-readable path
const hostComposeFile = getComposeFilePath(slug);
Expand Down Expand Up @@ -981,7 +982,7 @@ router.get('/:slug/version', (req, res) => {
result.currentVersion = shop.shuttle_version;
}

result.latestAvailable = 'STS-2.00';
result.latestAvailable = 'STS-2.01';

res.json(result);
} catch (err) {
Expand Down Expand Up @@ -1078,7 +1079,7 @@ router.post('/:slug/update-template', (req, res) => {
}).trim();
} catch { /* ignore */ }

db.prepare('UPDATE shops SET shuttle_version = ? WHERE slug = ?').run('STS-2.00', slug);
db.prepare('UPDATE shops SET shuttle_version = ? WHERE slug = ?').run('STS-2.01', slug);

log.push(`Update complete. Now at commit ${newCommit}.`);
res.json({ message: `Shop "${slug}" updated successfully`, commit: newCommit, log: log.join('\n') });
Expand Down Expand Up @@ -1169,7 +1170,7 @@ router.post('/:slug/upgrade', (req, res) => {
}

// Update version in DB
db.prepare('UPDATE shops SET shuttle_version = ?, status = ? WHERE slug = ?').run('STS-2.00', 'running', slug);
db.prepare('UPDATE shops SET shuttle_version = ?, status = ? WHERE slug = ?').run('STS-2.01', 'running', slug);
log.push('Updated shop version to STS-2.00.');

res.json({ message: `Shop "${slug}" upgraded to STS-2.00`, log: log.join('\n') });
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "shuttle-platform-frontend",
"private": true,
"version": "2.0.1",
"version": "2.0.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/CollectionsEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
listShopFiles, readShopFile, writeShopFile, deleteShopFile,
uploadShopFiles, replaceShopFile, getShopImageUrl,
} from '../lib/api';
import FadeImage from './FadeImage';

const DETAIL_FIELDS = {
'Name.txt': { type: 'text', label: 'Name' },
Expand Down Expand Up @@ -517,10 +518,10 @@ export default function CollectionsEditor({ slug }) {
}`}
>
{item.thumbnailFile ? (
<img
<FadeImage
src={getShopImageUrl(slug, item.thumbnailFile)}
alt={item.name}
className="w-full h-24 object-cover rounded-md mb-2 border border-border/30"
className="w-full h-24 rounded-md mb-2 border border-border/30"
/>
) : (
<div className="w-full h-24 rounded-md mb-2 bg-muted/50 flex items-center justify-center border border-border/30">
Expand Down Expand Up @@ -661,10 +662,10 @@ export default function CollectionsEditor({ slug }) {
const imgUrl = getShopImageUrl(slug, photo.path) + (ts ? `&_t=${ts}` : '');
return (
<div key={photo.name} className="relative group rounded-md border border-border/40 overflow-hidden">
<img
<FadeImage
src={imgUrl}
alt={photo.name}
className="w-full h-28 object-cover"
className="w-full h-28"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/components/FadeImage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState, useCallback, useRef } from 'react';

/**
* Image component that shows a shimmer placeholder color block and
* crossfades to the real image once it finishes loading.
*
* Usage — drop-in replacement for <img>. All sizing / rounding /
* border classes go on the outer className as usual.
*/
export default function FadeImage({ src, alt, className = '', style, ...rest }) {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const prevSrc = useRef(src);

// Reset state when src changes (e.g. image replaced)
if (src !== prevSrc.current) {
prevSrc.current = src;
setLoaded(false);
setError(false);
}

const handleLoad = useCallback(() => setLoaded(true), []);
const handleError = useCallback(() => { setLoaded(true); setError(true); }, []);

return (
<div className={`fade-img-wrap ${className}`} style={style}>
{/* Shimmer placeholder — visible until image loads */}
<div
className={`fade-img-placeholder ${loaded ? 'fade-img-placeholder--hidden' : ''}`}
aria-hidden
/>
{!error ? (
<img
src={src}
alt={alt || ''}
onLoad={handleLoad}
onError={handleError}
className={`fade-img ${loaded ? 'fade-img--visible' : ''}`}
draggable={false}
{...rest}
/>
) : (
<div className="fade-img-error">
<span>Failed to load</span>
</div>
)}
</div>
);
}
3 changes: 2 additions & 1 deletion frontend/src/components/KeyValueEditor.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { Save, Eye, EyeOff, Copy, Upload, Check } from 'lucide-react';
import { getShopImageUrl } from '../lib/api';
import FadeImage from './FadeImage';

const HIDDEN_FILES = new Set(['readme.md', 'readme.txt', '.gitkeep', '.ds_store']);

Expand Down Expand Up @@ -229,7 +230,7 @@ export default function KeyValueEditor({
<span className="text-[10px] text-muted-foreground font-mono opacity-60">{entry.name}</span>
</div>
<div className="rounded-lg border border-border/40 bg-muted/30 p-4 flex flex-col items-center gap-3">
<img src={imgUrl} alt={label} className="max-h-40 max-w-full object-contain rounded-md border border-border/30" />
<FadeImage src={imgUrl} alt={label} className="max-h-40 max-w-full rounded-md border border-border/30" style={{ objectFit: 'contain' }} />
<div className="flex items-center gap-2">
<button
onClick={() => imageInputRefs.current[filePath]?.click()}
Expand Down
44 changes: 33 additions & 11 deletions frontend/src/components/OrderTable.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, FileDown } from 'lucide-react';
import { ArrowUpDown, FileDown, FileSpreadsheet } from 'lucide-react';
import { getPoFileUrl } from '../lib/api';

export default function OrderTable({ orders, slug }) {
Expand All @@ -11,6 +11,11 @@ export default function OrderTable({ orders, slug }) {
return Object.keys(orders[0]);
}, [orders]);

// Check if any order has a PO File value
const hasPo = useMemo(() => {
return orders.some(row => row['PO File'] && row['PO File'].trim());
}, [orders]);

const sorted = useMemo(() => {
if (!sortKey) return orders;
return [...orders].sort((a, b) => {
Expand All @@ -35,18 +40,10 @@ export default function OrderTable({ orders, slug }) {
}

const renderCell = (col, value) => {
// Render PO File column as a download link
// Render PO File column as the filename text (the button is in the dedicated column)
if (col === 'PO File' && value && slug) {
return (
<a
href={getPoFileUrl(slug, value)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
<FileDown className="h-3 w-3" />
{value}
</a>
<span className="text-xs font-mono text-muted-foreground">{value}</span>
);
}
return value;
Expand All @@ -69,6 +66,14 @@ export default function OrderTable({ orders, slug }) {
</span>
</th>
))}
{hasPo && (
<th className="px-4 py-3 text-left font-medium text-muted-foreground select-none" title="Supports PDF, HTML, Excel, and image files">
<span className="inline-flex items-center gap-1">
<FileSpreadsheet className="h-3 w-3" />
Open PO
</span>
</th>
)}
</tr>
</thead>
<tbody>
Expand All @@ -79,6 +84,23 @@ export default function OrderTable({ orders, slug }) {
{renderCell(col, row[col])}
</td>
))}
{hasPo && (
<td className="px-4 py-2.5">
{row['PO File'] && row['PO File'].trim() ? (
<a
href={getPoFileUrl(slug, row['PO File'])}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium bg-primary/15 text-primary hover:bg-primary/25 border border-primary/20 hover:border-primary/40 transition-all"
>
<FileDown className="h-3.5 w-3.5" />
Open PO
</a>
) : (
<span className="text-xs text-muted-foreground/50">—</span>
)}
</td>
)}
</tr>
))}
</tbody>
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,66 @@ html.light .lp-card:hover {
border-color: hsl(188 100% 32% / 0.35);
box-shadow: 0 0 0 1px hsl(188 100% 32% / 0.1), 0 4px 24px hsl(188 100% 32% / 0.08);
}

/* ── FadeImage — placeholder block → image crossfade ───── */
.fade-img-wrap {
position: relative;
overflow: hidden;
}

.fade-img-placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, hsl(var(--muted)), hsl(var(--secondary)));
opacity: 1;
transition: opacity 0.45s ease;
z-index: 1;
}

@keyframes fade-img-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}

.fade-img-placeholder::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--border) / 0.3) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: fade-img-shimmer 1.8s ease-in-out infinite;
}

.fade-img-placeholder--hidden {
opacity: 0;
pointer-events: none;
}

.fade-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.45s ease;
}

.fade-img--visible {
opacity: 1;
}

.fade-img-error {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
font-size: 0.7rem;
}
Loading