206 lines
4.8 KiB
Svelte
206 lines
4.8 KiB
Svelte
<script lang="ts">
|
|
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
|
|
import type { TerminalLine } from '$lib/components/tui/types';
|
|
import ModelViewer from '$lib/components/ModelViewer.svelte';
|
|
import { user, models as configModels } from '$lib/config';
|
|
import { getPageSpeedMultiplier, getPageAutoscroll } from '$lib';
|
|
import { themeColors } from '$lib/stores/theme';
|
|
import Icon from '@iconify/svelte';
|
|
|
|
const speed = getPageSpeedMultiplier('models');
|
|
const autoscroll = getPageAutoscroll('models');
|
|
|
|
// Build GLB model list from config (`models`) while preserving metadata.
|
|
// Keep `name`, `path`, `thumbnail`, and `description` for richer UI use.
|
|
const glbModels = (configModels && configModels.length > 0)
|
|
? configModels
|
|
.map(m => ({
|
|
name: m.name,
|
|
path: m.modelUrl ?? m.downloadUrl ?? m.thumbnail ?? '',
|
|
thumbnail: m.thumbnail ?? '',
|
|
description: m.description ?? ''
|
|
}))
|
|
.filter(m => m.path)
|
|
: [
|
|
{ name: 'Bake Potato', path: '/models/BakePotato.glb', thumbnail: '/models/BakePotato-thumb.png', description: 'A fun baked-potato 3D model.' },
|
|
{ name: 'Soda Can', path: '/models/Soda.glb', thumbnail: '/models/Soda-thumb.png', description: 'A realistic soda can model.' }
|
|
];
|
|
|
|
let selectedModel = $state(glbModels[0] ?? { name: '', path: '', thumbnail: '', description: '' });
|
|
let showViewer = $state(false);
|
|
|
|
function selectModel(model: typeof glbModels[0]) {
|
|
selectedModel = model;
|
|
showViewer = true;
|
|
}
|
|
|
|
// Build the terminal lines for the models page
|
|
const lines: TerminalLine[] = [
|
|
{ type: 'command', content: 'ls ~/models/' },
|
|
{ type: 'blank', content: '' },
|
|
|
|
{ type: 'header', content: '(&primary,bold)3D Models(&)' },
|
|
{ type: 'output', content: `(&muted)A collection of 3D models created for games, visualization, and fun.(&)` },
|
|
{ type: 'blank', content: '' },
|
|
|
|
{ type: 'divider', content: 'VIEWER' },
|
|
{ type: 'blank', content: '' },
|
|
|
|
// Interactive model buttons
|
|
...glbModels.map(model => ({
|
|
type: 'button' as const,
|
|
content: model.name,
|
|
icon: 'mdi:cube-scan',
|
|
style: 'primary' as const,
|
|
action: () => selectModel(model)
|
|
})),
|
|
|
|
{ type: 'blank', content: '' },
|
|
{ type: 'success', content: `(&success)${glbModels.length} models available(&)` }
|
|
];
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>3D Models | {user.name}</title>
|
|
<meta name="description" content="3D Models portfolio - Characters, Environments, Props and more" />
|
|
</svelte:head>
|
|
|
|
<div class="models-page">
|
|
<div
|
|
class="models-layout"
|
|
class:viewer-open={showViewer}
|
|
style="--theme-bg: {$themeColors.background};"
|
|
>
|
|
<!-- Terminal Section -->
|
|
<div class="terminal-section">
|
|
<TerminalTUI {lines} title="~/models" interactive={true} {speed} {autoscroll} />
|
|
</div>
|
|
|
|
<!-- 3D Viewer Section -->
|
|
{#if showViewer}
|
|
<div class="viewer-section">
|
|
<div class="viewer-tabs">
|
|
{#each glbModels as model}
|
|
<button
|
|
class="viewer-tab"
|
|
class:active={selectedModel.path === model.path}
|
|
onclick={() => selectModel(model)}
|
|
>
|
|
<Icon icon="mdi:cube-outline" width="14" />
|
|
{model.name}
|
|
</button>
|
|
{/each}
|
|
<button class="viewer-tab close" onclick={() => showViewer = false}>
|
|
<Icon icon="mdi:close" width="14" />
|
|
</button>
|
|
</div>
|
|
<ModelViewer
|
|
modelPath={selectedModel.path}
|
|
modelName={selectedModel.name}
|
|
class="model-viewer-container"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.models-page {
|
|
min-height: calc(100vh - 60px);
|
|
padding: 2rem 1rem;
|
|
}
|
|
|
|
.models-layout {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.terminal-section {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.viewer-section {
|
|
width: 500px;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
animation: slideIn 0.3s ease;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(20px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
.viewer-tabs {
|
|
display: flex;
|
|
background: var(--theme-bg);
|
|
border-radius: 8px 8px 0 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.viewer-tab {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.5rem 1rem;
|
|
background: transparent;
|
|
border: none;
|
|
color: inherit;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
opacity: 0.6;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.viewer-tab:hover {
|
|
opacity: 0.9;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
.viewer-tab.active {
|
|
opacity: 1;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.viewer-tab.close {
|
|
margin-left: auto;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.viewer-tab.close:hover {
|
|
opacity: 1;
|
|
color: #f38ba8;
|
|
}
|
|
|
|
:global(.model-viewer-container) {
|
|
flex: 1;
|
|
min-height: 400px;
|
|
border-radius: 0 0 8px 8px;
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.models-layout {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.viewer-section {
|
|
width: 100%;
|
|
order: -1;
|
|
}
|
|
}
|
|
</style>
|
|
|