Skip to content

Commit 578b680

Browse files
Merge pull request #143 from CityOfPhiladelphia/fix/image-upload
Fix/image upload
2 parents 1f16c71 + 5c3a0a9 commit 578b680

19 files changed

Lines changed: 692 additions & 120 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,7 @@ Deployments use AWS OIDC — no long-lived credentials. Each app/environment pai
8080
- **pnpm** — package manager and workspace management
8181
- **Turbo** — task orchestration (build, lint, type-check)
8282
- **city CLI** — AWS infrastructure deployment (`city deploy`, `city ship`)
83+
84+
### Icons
85+
86+
The Phila UI design system uses bundled SVG icons sourced from [Font Awesome Free](https://fontawesome.com) (CC BY 4.0). All icons are embedded directly in the package — no external icon font or auth token required.

apps/philly-311/frontend/src/components/wizard/ExitDialog.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ function onDiscard() {
7777

7878
<style scoped>
7979
.exit-dialog {
80+
position: fixed;
81+
top: 50%;
82+
left: 50%;
83+
transform: translate(-50%, -50%);
8084
max-width: 28rem;
8185
width: 100%;
8286
padding: var(--spacing-l, 2rem);
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
<!-- ABOUTME: Wizard exit confirmation dialog — offers saving the in-progress
2+
report as a draft or discarding it; cancelling keeps the user in the wizard. -->
3+
<script setup lang="ts">
4+
import { ref, onMounted, useTemplateRef } from 'vue'
5+
6+
import { PhilaButton, CloseButton } from '@phila/phila-ui-button'
7+
import { Tags } from '@phila/phila-ui-tags'
8+
import { Icon } from '@phila/phila-ui-core'
9+
import { IconPencil, IconBackwardStep, IconForwardStep } from '@phila/phila-ui-core/icons'
10+
import { DrawingCanvas } from '@pinboard/ui'
11+
import type { Dimensions } from '@/types/wizard'
12+
13+
const open = defineModel<boolean>('open')
14+
const complete = defineModel<boolean>('complete')
15+
const file = defineModel<string>('file', { default: '' })
16+
const scale = defineModel<Dimensions>('scale')
17+
18+
const title = 'Show us where the issue appears in your photo'
19+
const note = `Draw a circle around where the issue appears, or skip ahead to the next step.`
20+
const inkColor =
21+
getComputedStyle(document.documentElement)
22+
.getPropertyValue('--phillies-red-500-phillies-red')
23+
.trim() || '#CC3000'
24+
25+
const dialog = ref<HTMLDialogElement | null>(null)
26+
const canvasContainerRef = useTemplateRef('canvasContainerRef')
27+
const canvasRef = useTemplateRef('canvasRef')
28+
const canvasHeight = ref(NaN)
29+
const canvasWidth = ref(NaN)
30+
const uploadedImage = ref<HTMLImageElement | undefined>(undefined)
31+
const imageDim = ref<Dimensions>({
32+
height: NaN,
33+
width: NaN,
34+
})
35+
36+
const canvasBackground = {
37+
'background-image': `url(${file.value})`,
38+
'background-repeat': 'no-repeat',
39+
'background-position': 'center center',
40+
'background-size': 'contain',
41+
}
42+
43+
onMounted(() => {
44+
dialog.value?.showModal()
45+
if (!canvasContainerRef.value) {
46+
throw new Error('Drawing canvas container failed to mount')
47+
}
48+
const containerDim: Dimensions = {
49+
height: canvasContainerRef.value.clientHeight,
50+
width: canvasContainerRef.value.clientWidth,
51+
}
52+
getImageDimensions(file.value).then((imageDimensions) => {
53+
imageDim.value = imageDimensions
54+
setCanvasScale(imageDimensions, containerDim)
55+
})
56+
})
57+
58+
function handleClose() {
59+
open.value = false
60+
}
61+
62+
function handleSkip() {
63+
setImageScale()
64+
complete.value = true
65+
open.value = false
66+
}
67+
68+
function handleNext() {
69+
const offCanvas = new OffscreenCanvas(imageDim.value.width, imageDim.value.height)
70+
if (!canvasRef.value?.drawingCanvas) {
71+
throw new Error('Ref for drawing canvas was undefined')
72+
}
73+
74+
const context = offCanvas.getContext('2d')
75+
if (!context) {
76+
throw new Error('Failed to get context from OffscreenCanvas')
77+
}
78+
createImageBitmap(canvasRef.value.drawingCanvas, {
79+
resizeWidth: imageDim.value.width,
80+
resizeHeight: imageDim.value.height,
81+
resizeQuality: 'high',
82+
}).then((markupImage) => {
83+
if (!uploadedImage.value) {
84+
throw new Error('Could not locate uploaded image')
85+
}
86+
context.drawImage(uploadedImage.value, 0, 0)
87+
context.drawImage(markupImage, 0, 0)
88+
URL.revokeObjectURL(file.value)
89+
offCanvas.convertToBlob({ type: 'image/png', quality: 1 }).then((markupBlob) => {
90+
file.value = URL.createObjectURL(markupBlob)
91+
handleSkip()
92+
})
93+
})
94+
}
95+
96+
function handleUndo() {
97+
canvasRef.value?.undoLine()
98+
}
99+
100+
function handleRedo() {
101+
canvasRef.value?.redoLine()
102+
}
103+
104+
function getImageDimensions(dataURL: string): Promise<Dimensions> {
105+
return new Promise((resolve) => {
106+
uploadedImage.value = new Image()
107+
uploadedImage.value.onload = () => {
108+
resolve({
109+
height: uploadedImage.value?.height ?? NaN,
110+
width: uploadedImage.value?.width ?? NaN,
111+
})
112+
}
113+
uploadedImage.value.src = dataURL
114+
})
115+
}
116+
117+
function setCanvasScale(image: Dimensions, container: Dimensions) {
118+
const widthLtHeight = container.width <= container.height
119+
canvasHeight.value =
120+
(widthLtHeight ? Math.floor(image.height * container.width) / image.width : container.height) *
121+
0.99
122+
canvasWidth.value =
123+
(widthLtHeight
124+
? container.width
125+
: Math.floor((image.width * container.height) / image.height)) * 0.99
126+
scale.value = widthLtHeight
127+
? {
128+
height: image.height / image.width,
129+
width: 1,
130+
}
131+
: {
132+
height: 1,
133+
width: image.width / image.height,
134+
}
135+
}
136+
137+
function setImageScale() {
138+
const widthLtHeight = imageDim.value.width <= imageDim.value.height
139+
scale.value = widthLtHeight
140+
? {
141+
height: imageDim.value.height / imageDim.value.width,
142+
width: 1,
143+
}
144+
: {
145+
height: 1,
146+
width: imageDim.value.width / imageDim.value.height,
147+
}
148+
}
149+
</script>
150+
151+
<template>
152+
<dialog
153+
ref="dialog"
154+
class="image-dialog"
155+
aria-labelledby="image-dialog-title"
156+
@close="handleClose"
157+
@cancel="handleClose"
158+
>
159+
<div class="image-dialog-close">
160+
<CloseButton aria-label="Close image upload dialog" @click="handleClose" />
161+
</div>
162+
163+
<div id="image-dialog-title" class="image-dialog-title" v-text="title" />
164+
<div />
165+
<div class="image-dialog-callout">
166+
<Icon :icon="IconPencil" size="extra-small" /> {{ note }}
167+
</div>
168+
<div />
169+
<div ref="canvasContainerRef" class="image-dialog-canvas">
170+
<DrawingCanvas
171+
v-if="open && canvasHeight && canvasWidth"
172+
ref="canvasRef"
173+
:height="canvasHeight"
174+
:width="canvasWidth"
175+
:options="{ strokeStyle: inkColor }"
176+
:style="canvasBackground"
177+
></DrawingCanvas>
178+
</div>
179+
180+
<div />
181+
<div class="image-dialog-actions">
182+
<Tags text="Redo" :icon="IconForwardStep" color="white" @click="handleRedo" />
183+
<Tags text="Undo" :icon="IconBackwardStep" color="white" @click="handleUndo" />
184+
</div>
185+
<div />
186+
<div class="image-dialog-footer">
187+
<PhilaButton variant="secondary" data-test="image-discard" @click="handleClose"
188+
>Cancel</PhilaButton
189+
>
190+
<PhilaButton
191+
:variant="canvasRef?.drawingComplete ? 'primary' : 'secondary'"
192+
data-test="image-save"
193+
@click="canvasRef?.drawingComplete ? handleNext() : handleSkip()"
194+
>{{ canvasRef?.drawingComplete ? 'Next' : 'Skip' }}</PhilaButton
195+
>
196+
</div>
197+
</dialog>
198+
</template>
199+
200+
<style scoped>
201+
.image-dialog {
202+
position: fixed;
203+
top: 50%;
204+
left: 50%;
205+
transform: translate(-50%, -50%);
206+
display: grid;
207+
grid-template-areas:
208+
'close'
209+
'title'
210+
'title-space'
211+
'callout'
212+
'callout-space'
213+
'canvas'
214+
'canvas-space'
215+
'actions'
216+
'actions-space'
217+
'footer';
218+
grid-template-rows:
219+
auto auto var(--spacing-l, 1.5rem) auto var(--spacing-m, 1rem) 1fr var(--spacing-s, 0.75rem)
220+
auto var(--spacing-3xl, 3rem) auto;
221+
width: clamp(10vw, 65rem, 90vw);
222+
height: clamp(10vh, 65rem, 95vh);
223+
padding: var(--spacing-xl, 2rem);
224+
border: none;
225+
border-radius: var(--border-radius-xl, 1.5rem);
226+
background: var(--Schemes-Background, #fff);
227+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
228+
}
229+
230+
dialog:not([open]) {
231+
display: none;
232+
}
233+
234+
.image-dialog::backdrop {
235+
background: rgba(0, 0, 0, 0.5);
236+
}
237+
238+
.image-dialog-close {
239+
margin-left: auto;
240+
}
241+
242+
.image-dialog-close > .phila-button {
243+
color: var(--Schemes-On-Surface-High, #000) !important;
244+
}
245+
246+
.image-dialog-title {
247+
grid-area: title;
248+
color: var(--Schemes-On-Surface-High, #000);
249+
250+
/* Subtitle/Subtitle 1 */
251+
font-family: var(--Subtitle-font-subtitle-family, Montserrat);
252+
font-size: var(--Subtitle-Subtitle-1-font-subtitle-1-size, 1.5rem);
253+
font-style: normal;
254+
font-weight: 600;
255+
line-height: var(--Subtitle-Subtitle-1-font-subtitle-1-lineheight, 2.25rem); /* 150% */
256+
}
257+
258+
.image-dialog-callout {
259+
grid-area: callout;
260+
display: grid;
261+
grid-template-columns: auto auto;
262+
place-items: center;
263+
padding: var(--spacing-m, 1rem);
264+
gap: var(--spacing-xs, 0.5rem);
265+
border-radius: var(--border-radius-m, 0.75rem);
266+
background: var(--Schemes-Info-Container, #dbefff);
267+
color: var(--Schemes-On-Secondary-Container, #000);
268+
/* Label/Default */
269+
font-family: var(--Label-Default-font-label-default-family, Montserrat);
270+
font-size: var(--Label-Default-font-label-default-size, 1rem);
271+
font-style: normal;
272+
font-weight: 600;
273+
line-height: var(--Label-Default-font-label-default-lineheight, 1.5rem); /* 150% */
274+
}
275+
276+
.image-dialog-canvas {
277+
grid-area: canvas;
278+
overflow: auto;
279+
display: grid;
280+
place-content: center;
281+
height: 1fr;
282+
width: 1fr;
283+
}
284+
285+
.image-dialog-actions {
286+
grid-area: actions;
287+
display: flex;
288+
margin-left: auto;
289+
gap: var(--spacing-xs, 0.5rem);
290+
}
291+
292+
.image-dialog-footer {
293+
grid-area: footer;
294+
display: flex;
295+
margin-left: auto;
296+
gap: var(--spacing-xs, 0.5rem);
297+
}
298+
</style>

apps/philly-311/frontend/src/components/wizard/StepIndicator.vue

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,17 @@ function handleMouseLeave(ev: MouseEvent) {
4949
<div v-if="step.n > 1" class="step-pad-left" />
5050

5151
<span v-if="step.n >= currentStep" class="step-number" v-text="step.n" />
52-
<div
52+
<button
5353
v-else
5454
:id="step.title"
55-
:type="step.clickable ? 'button' : ''"
55+
:type="step.clickable ? 'button' : undefined"
5656
class="step-button"
5757
@mouseenter="handleMouseEnter"
5858
@mouseleave="handleMouseLeave"
5959
@click="step.clickable ? emit('navigate', step.path) : null"
6060
>
6161
<Icon :icon="IconCheck" size="extra-small" class="step-number" />
62-
</div>
62+
</button>
6363

6464
<span class="sr-only" :v-text="`Step ${step.n} of ${steps.length}`" />
6565
<span
@@ -82,7 +82,6 @@ function handleMouseLeave(ev: MouseEvent) {
8282
counter-reset: step-counter;
8383
display: grid;
8484
place-content: center;
85-
list-style: none;
8685
padding: 0;
8786
margin: 0;
8887
max-width: 32rem;
@@ -127,16 +126,21 @@ function handleMouseLeave(ev: MouseEvent) {
127126
height: 0px;
128127
border: var(--border-width-s, 0.0625rem) solid var(--Schemes-Border-low, rgb(204, 204, 204));
129128
margin: var(--spacing-m, 1rem) 0;
130-
width: 2.26rem;
129+
width: var(--scale-450, 2.25rem);
130+
}
131+
132+
.step-button {
133+
background: transparent;
134+
border: none;
131135
}
132136
133137
.step-number {
134138
grid-area: number;
135139
display: grid;
136140
place-content: center;
137141
margin: 0 0 var(--spacing-xs, 0.5rem) 0;
138-
width: 2rem;
139-
height: 2rem;
142+
width: var(--scale-400, 2rem);
143+
height: var(--scale-400, 2rem);
140144
aspect-ratio: 1/1;
141145
background: transparent;
142146
border-radius: var(--border-radius-2xl, 2rem);

apps/philly-311/frontend/src/pages/ReportPage.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function goPrev() {
6464
}
6565
function goNext() {
6666
if (navHandlers.value?.next()) return
67-
if (!canAdvance.value) {
67+
if (!canAdvance.value && !isImageStep.value) {
6868
showErrors.value = true
6969
return
7070
}
@@ -134,7 +134,7 @@ function discardAndExit() {
134134
data-test="wizard-next"
135135
:disabled="!nextPath"
136136
@click="goNext"
137-
>{{ isImageStep ? 'Skip' : 'Next' }}
137+
>{{ isImageStep && !canAdvance ? 'Skip' : 'Next' }}
138138
</PhilaButton>
139139
</div>
140140
</footer>

0 commit comments

Comments
 (0)