Skip to content

Commit c662efc

Browse files
committed
feat: visual bulk canvas with drag-drop, live preview, and ZIP export
- Rewrite BulkCreateView as visual canvas: left toolbar + center grid + right editor sidebar - Add use-bulk-canvas composable for state management, concurrency-limited rendering, debounced updates - Add bulk-canvas-toolbar with template selector, multi-image drop zone, library picker - Add bulk-canvas-grid with auto-fill CSS grid and bulk-canvas-card with scaled preview - Add bulk-canvas-item-editor sidebar with ImagePicker, ParamEditor reuse - Fix snapdom export: remove scale transform + overflow before capture - Add Bulk icon to sidebar navigation - Remove italic from agency-split layout title
1 parent b74d51e commit c662efc

8 files changed

Lines changed: 962 additions & 193 deletions

File tree

app/src/components/AppLayout.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ function isActive(path: string) {
5050
<rect x="2" y="9" width="10" height="3" rx="1.5" fill="currentColor" opacity=".5"/>
5151
<rect x="2" y="14" width="7" height="3" rx="1.5" fill="currentColor" opacity=".3"/>
5252
</svg>
53+
<!-- Bulk icon (grid of images) -->
54+
<svg v-if="item.icon === 'bulk'" width="20" height="20" viewBox="0 0 20 20" fill="none">
55+
<rect x="2" y="2" width="7" height="5" rx="1" fill="currentColor" opacity=".7"/>
56+
<rect x="11" y="2" width="7" height="5" rx="1" fill="currentColor" opacity=".5"/>
57+
<rect x="2" y="9" width="7" height="5" rx="1" fill="currentColor" opacity=".5"/>
58+
<rect x="11" y="9" width="7" height="5" rx="1" fill="currentColor" opacity=".7"/>
59+
<rect x="2" y="16" width="16" height="2" rx="1" fill="currentColor" opacity=".3"/>
60+
</svg>
5361
<span class="mp-layout__nav-label">{{ item.label }}</span>
5462
</RouterLink>
5563
</li>
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<script setup lang="ts">
2+
import { ref, onMounted, watch, nextTick } from 'vue'
3+
import type { BulkCanvasItem } from '../composables/use-bulk-canvas'
4+
5+
const props = defineProps<{
6+
item: BulkCanvasItem
7+
}>()
8+
9+
const emit = defineEmits<{
10+
'toggle-select': [id: string]
11+
'edit': [id: string]
12+
'remove': [id: string]
13+
}>()
14+
15+
const renderRef = ref<HTMLElement | null>(null)
16+
const scale = ref(0.25)
17+
const innerW = ref(0)
18+
const innerH = ref(0)
19+
20+
// Measure the #thumbnail inside rendered HTML to set proper container size
21+
function measureThumbnail() {
22+
if (!renderRef.value) return
23+
const thumb = renderRef.value.querySelector('#thumbnail') as HTMLElement
24+
if (!thumb) return
25+
innerW.value = thumb.offsetWidth || 1080
26+
innerH.value = thumb.offsetHeight || 1080
27+
}
28+
29+
watch(() => props.item.previewHtml, async () => {
30+
await nextTick()
31+
measureThumbnail()
32+
})
33+
34+
onMounted(() => {
35+
nextTick(() => measureThumbnail())
36+
})
37+
</script>
38+
39+
<template>
40+
<div
41+
class="card"
42+
:class="{ 'card--selected': item.selected }"
43+
:data-bulk-id="item.id"
44+
>
45+
<!-- Checkbox -->
46+
<label class="card__check" @click.stop>
47+
<input
48+
type="checkbox"
49+
:checked="item.selected"
50+
@change="emit('toggle-select', item.id)"
51+
/>
52+
</label>
53+
54+
<!-- Remove button -->
55+
<button class="card__remove" @click.stop="emit('remove', item.id)" title="Remove">&times;</button>
56+
57+
<!-- Preview area -->
58+
<div class="card__preview" @click="emit('edit', item.id)">
59+
<div v-if="item.loading" class="card__spinner">
60+
<div class="card__spinner-ring" />
61+
</div>
62+
<div
63+
v-else-if="item.previewHtml"
64+
ref="renderRef"
65+
class="card__render"
66+
:style="{
67+
width: innerW ? innerW * scale + 'px' : '100%',
68+
height: innerW ? innerH * scale + 'px' : 'auto',
69+
}"
70+
>
71+
<div
72+
class="card__render-inner"
73+
data-bulk-scale
74+
:style="{ transform: `scale(${scale})` }"
75+
v-html="item.previewHtml"
76+
/>
77+
</div>
78+
<div v-else class="card__placeholder">
79+
<img :src="item.featureImage" class="card__thumb" />
80+
</div>
81+
</div>
82+
83+
<!-- Label -->
84+
<div class="card__label">{{ item.title || 'Untitled' }}</div>
85+
</div>
86+
</template>
87+
88+
<style scoped>
89+
.card {
90+
position: relative;
91+
border: 2px solid var(--mp-rule, #e5e5e5);
92+
border-radius: var(--mp-radius, 8px);
93+
overflow: hidden;
94+
cursor: pointer;
95+
transition: border-color 0.15s, box-shadow 0.15s;
96+
background: var(--mp-bg, #fff);
97+
}
98+
99+
.card:hover {
100+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
101+
}
102+
103+
.card--selected {
104+
border-color: var(--mp-terra, #c75b39);
105+
}
106+
107+
.card__check {
108+
position: absolute;
109+
top: 8px;
110+
left: 8px;
111+
z-index: 2;
112+
cursor: pointer;
113+
}
114+
115+
.card__check input {
116+
width: 18px;
117+
height: 18px;
118+
cursor: pointer;
119+
accent-color: var(--mp-terra, #c75b39);
120+
}
121+
122+
.card__remove {
123+
position: absolute;
124+
top: 4px;
125+
right: 4px;
126+
z-index: 2;
127+
width: 24px;
128+
height: 24px;
129+
border: none;
130+
border-radius: 50%;
131+
background: rgba(0, 0, 0, 0.6);
132+
color: #fff;
133+
font-size: 16px;
134+
line-height: 1;
135+
cursor: pointer;
136+
display: flex;
137+
align-items: center;
138+
justify-content: center;
139+
opacity: 0;
140+
transition: opacity 0.15s;
141+
}
142+
143+
.card:hover .card__remove {
144+
opacity: 1;
145+
}
146+
147+
.card__preview {
148+
overflow: hidden;
149+
display: flex;
150+
align-items: flex-start;
151+
justify-content: center;
152+
background: var(--mp-bg2, #f5f5f5);
153+
}
154+
155+
.card__render {
156+
overflow: hidden;
157+
position: relative;
158+
}
159+
160+
.card__render-inner {
161+
transform-origin: top left;
162+
pointer-events: none;
163+
}
164+
165+
.card__placeholder {
166+
width: 100%;
167+
aspect-ratio: 1;
168+
}
169+
170+
.card__thumb {
171+
width: 100%;
172+
height: 100%;
173+
object-fit: cover;
174+
}
175+
176+
.card__spinner {
177+
display: flex;
178+
align-items: center;
179+
justify-content: center;
180+
width: 100%;
181+
min-height: 200px;
182+
}
183+
184+
.card__spinner-ring {
185+
width: 32px;
186+
height: 32px;
187+
border: 3px solid var(--mp-rule, #ddd);
188+
border-top-color: var(--mp-terra, #c75b39);
189+
border-radius: 50%;
190+
animation: spin 0.8s linear infinite;
191+
}
192+
193+
@keyframes spin {
194+
to { transform: rotate(360deg); }
195+
}
196+
197+
.card__label {
198+
padding: 6px 10px;
199+
font-size: 12px;
200+
font-weight: 500;
201+
color: var(--mp-ink, #333);
202+
white-space: nowrap;
203+
overflow: hidden;
204+
text-overflow: ellipsis;
205+
border-top: 1px solid var(--mp-rule, #e5e5e5);
206+
}
207+
</style>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<script setup lang="ts">
2+
import type { BulkCanvasItem } from '../composables/use-bulk-canvas'
3+
import BulkCanvasCard from './bulk-canvas-card.vue'
4+
5+
defineProps<{
6+
items: BulkCanvasItem[]
7+
}>()
8+
9+
const emit = defineEmits<{
10+
'toggle-select': [id: string]
11+
'edit': [id: string]
12+
'remove': [id: string]
13+
}>()
14+
</script>
15+
16+
<template>
17+
<div class="grid-wrap">
18+
<div v-if="items.length === 0" class="grid-empty">
19+
<p>Drop images or use the toolbar to add items</p>
20+
</div>
21+
<div v-else class="grid">
22+
<BulkCanvasCard
23+
v-for="item in items"
24+
:key="item.id"
25+
:item="item"
26+
@toggle-select="emit('toggle-select', $event)"
27+
@edit="emit('edit', $event)"
28+
@remove="emit('remove', $event)"
29+
/>
30+
</div>
31+
</div>
32+
</template>
33+
34+
<style scoped>
35+
.grid-wrap {
36+
flex: 1;
37+
overflow-y: auto;
38+
padding: 1.25rem;
39+
}
40+
41+
.grid {
42+
display: grid;
43+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
44+
gap: 1rem;
45+
}
46+
47+
.grid-empty {
48+
display: flex;
49+
align-items: center;
50+
justify-content: center;
51+
height: 100%;
52+
min-height: 300px;
53+
color: var(--mp-muted, #999);
54+
font-size: 14px;
55+
}
56+
</style>

0 commit comments

Comments
 (0)