feat(ui): squirrel logo, fork ribbon, clickable steps, and polish
- Squirrel engineer logo (SkwikLogo.vue) with hard hat and ruler - Matching favicon with squirrel head silhouette - Gitea fork ribbon (top-left, desktop only, Gitea green) - Centered header with logo, title, and subtitle - Footer: "Made by Samuel Prevost" with GitHub link - Clickable step indicators for previously visited steps - Smaller datum dots (6/4 base radius with visual cap) - Engineering-tool styling: monospace for measurements, Geist Mono font, deeper dark mode colors, instrument-panel header - EXIF viewer explains why focal length matters - Upload page describes what Skwik does Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
11e8013b6a
commit
98c6fc9a35
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 2.0 KiB |
43
src/App.vue
43
src/App.vue
@ -6,20 +6,44 @@ import ExifViewer from "@/components/ExifViewer.vue"
|
||||
import DatumEditor from "@/components/DatumEditor.vue"
|
||||
import ResultViewer from "@/components/ResultViewer.vue"
|
||||
import ThemeToggle from "@/components/ThemeToggle.vue"
|
||||
import SkwikLogo from "@/components/SkwikLogo.vue"
|
||||
|
||||
const store = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
<!-- Gitea fork ribbon — top-left, desktop only -->
|
||||
<a
|
||||
href="https://serv.e1n.sh/git/sam1902/skwik"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="github-fork-ribbon fixed left-0 top-0 z-[100] hidden md:block"
|
||||
data-ribbon="Fork me on Gitea"
|
||||
title="Fork me on Gitea"
|
||||
>Fork me on Gitea</a
|
||||
>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"
|
||||
class="mx-auto grid h-14 max-w-7xl grid-cols-3 items-center px-4"
|
||||
>
|
||||
<h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<div><!-- spacer for ribbon --></div>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<SkwikLogo :size="28" />
|
||||
<h1
|
||||
class="font-mono text-lg font-semibold tracking-tight"
|
||||
>
|
||||
Skwik
|
||||
</h1>
|
||||
<span
|
||||
class="hidden text-[10px] font-medium uppercase tracking-widest text-muted-foreground sm:inline"
|
||||
>Perspective Correction</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<StepIndicator />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
@ -32,5 +56,18 @@ const store = useAppStore()
|
||||
<DatumEditor v-else-if="store.currentStep === 3" />
|
||||
<ResultViewer v-else-if="store.currentStep === 4" />
|
||||
</main>
|
||||
|
||||
<footer
|
||||
class="border-t border-border/50 py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
Made by
|
||||
<a
|
||||
href="https://github.com/usr-ein"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline underline-offset-2 transition-colors hover:text-foreground"
|
||||
>Samuel Prevost</a
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap");
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@ -9,7 +10,8 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Geist Variable", sans-serif;
|
||||
--font-sans: "Geist", "Geist Variable", ui-sans-serif, sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, monospace;
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
@ -84,10 +86,10 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--background: oklch(0.13 0 0);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: oklch(0.175 0 0);
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
@ -99,7 +101,7 @@
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
@ -126,3 +128,63 @@
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fork-me ribbon — adapted from github-fork-ribbon-css */
|
||||
.github-fork-ribbon {
|
||||
width: 12.1em;
|
||||
height: 12.1em;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
text-decoration: none;
|
||||
text-indent: -999em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.github-fork-ribbon::before,
|
||||
.github-fork-ribbon::after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 15.38em;
|
||||
height: 1.54em;
|
||||
top: 3.23em;
|
||||
right: 0.5em;
|
||||
box-sizing: content-box;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.github-fork-ribbon::before {
|
||||
content: "";
|
||||
padding: 0.38em 0;
|
||||
background-color: #2d7a2e;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.05),
|
||||
rgba(0, 0, 0, 0.15)
|
||||
);
|
||||
box-shadow:
|
||||
0 0.15em 0.23em 0 rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px #5cb85c,
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.25);
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.github-fork-ribbon::after {
|
||||
content: attr(data-ribbon);
|
||||
color: #fff;
|
||||
font: 700 0.9em "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.54em;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
text-indent: 0;
|
||||
text-shadow:
|
||||
0 -1px 0 rgba(0, 0, 0, 0.5),
|
||||
0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@ -49,19 +49,23 @@ function getPointConfigs(datum: Datum, dIdx: number) {
|
||||
const color = getDatumColor(dIdx)
|
||||
const isSelected = store.selectedDatumId === datum.id
|
||||
const points = datum.type === "rectangle" ? datum.corners : datum.endpoints
|
||||
const radius = isSelected ? 10 : 7
|
||||
const baseRadius = isSelected ? 6 : 4
|
||||
const visualRadius = Math.max(
|
||||
baseRadius / scale.value,
|
||||
baseRadius * 0.5
|
||||
)
|
||||
|
||||
return points.map((pt, pIdx) => ({
|
||||
x: pt.x,
|
||||
y: pt.y,
|
||||
radius: radius / scale.value,
|
||||
radius: visualRadius,
|
||||
fill: color,
|
||||
stroke: isSelected ? "#fff" : color,
|
||||
strokeWidth: 2 / scale.value,
|
||||
strokeWidth: 1.5 / scale.value,
|
||||
draggable: true,
|
||||
_datumId: datum.id,
|
||||
_pointIndex: pIdx,
|
||||
hitStrokeWidth: 20 / scale.value,
|
||||
hitStrokeWidth: 12 / scale.value,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue"
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { useMediaQuery } from "@vueuse/core"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { saveDatums } from "@/lib/datum-cache"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
@ -40,6 +42,16 @@ const nextTooltip = computed(() => {
|
||||
const names = incompleteDatums.value.map((d) => d.label)
|
||||
return `Missing dimensions: ${names.join(", ")}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.datums,
|
||||
(datums) => {
|
||||
if (store.fileHash && datums.length > 0) {
|
||||
saveDatums(store.fileHash, datums)
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -77,6 +89,16 @@ const nextTooltip = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<Badge
|
||||
v-if="store.cacheRestoreMessage"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ store.cacheRestoreMessage }}
|
||||
</Badge>
|
||||
</Transition>
|
||||
|
||||
<!-- Single layout: canvas always present, sidebar conditionally placed -->
|
||||
<div
|
||||
class="grid gap-4"
|
||||
|
||||
@ -149,7 +149,9 @@ function getExifRows(): ExifRow[] {
|
||||
<TableCell class="font-medium">{{
|
||||
row.label
|
||||
}}</TableCell>
|
||||
<TableCell>{{ row.value }}</TableCell>
|
||||
<TableCell class="font-mono text-sm">{{
|
||||
row.value
|
||||
}}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
@ -179,26 +181,31 @@ function getExifRows(): ExifRow[] {
|
||||
</svg>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="font-medium text-foreground">
|
||||
Lens Correction Info
|
||||
Why EXIF matters
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
Focal length and lens model data allow the
|
||||
algorithm to estimate radial distortion
|
||||
(barrel/pincushion) introduced by the lens.
|
||||
Without this, the perspective correction relies
|
||||
solely on the datum geometry you provide.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<template v-if="store.exifData.focalLength">
|
||||
This image was shot at
|
||||
<strong
|
||||
>{{ store.exifData.focalLength }}mm</strong
|
||||
Detected
|
||||
<span class="font-mono font-medium text-foreground"
|
||||
>{{ store.exifData.focalLength }}mm</span
|
||||
>
|
||||
<template v-if="store.exifData.lensModel">
|
||||
with a
|
||||
<strong>{{
|
||||
on
|
||||
<span class="font-medium text-foreground">{{
|
||||
store.exifData.lensModel
|
||||
}}</strong> </template
|
||||
>. The deskew algorithm can use this to correct
|
||||
barrel/pincushion distortion.
|
||||
}}</span> </template
|
||||
>. Lens correction will be applied.
|
||||
</template>
|
||||
<template v-else>
|
||||
No focal length data found. The algorithm will
|
||||
rely solely on datum measurements for
|
||||
perspective correction.
|
||||
No focal length found. Lens distortion
|
||||
correction will be skipped.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
280
src/components/SkwikLogo.vue
Normal file
280
src/components/SkwikLogo.vue
Normal file
@ -0,0 +1,280 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
}>(),
|
||||
{
|
||||
size: 28,
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 64 64"
|
||||
:width="size"
|
||||
:height="size"
|
||||
aria-label="Skwik squirrel logo"
|
||||
>
|
||||
<!-- Tail (fluffy, curling up behind) -->
|
||||
<path
|
||||
d="M50 52 Q58 40 54 28 Q52 22 48 20
|
||||
Q55 18 56 12 Q56 8 52 10
|
||||
Q48 12 46 18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
|
||||
<!-- Body -->
|
||||
<ellipse
|
||||
cx="32"
|
||||
cy="46"
|
||||
rx="12"
|
||||
ry="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- Head -->
|
||||
<ellipse
|
||||
cx="32"
|
||||
cy="28"
|
||||
rx="12"
|
||||
ry="13"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- Ear tufts -->
|
||||
<path
|
||||
d="M22 18 Q18 10 14 12 Q12 16 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M42 18 Q46 10 50 12 Q52 16 44 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Eyes -->
|
||||
<circle
|
||||
cx="27"
|
||||
cy="26"
|
||||
r="3"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle
|
||||
cx="37"
|
||||
cy="26"
|
||||
r="3"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Eye shine -->
|
||||
<circle cx="28.2" cy="25" r="1" fill="var(--background, #fff)" />
|
||||
<circle cx="38.2" cy="25" r="1" fill="var(--background, #fff)" />
|
||||
|
||||
<!-- Nose -->
|
||||
<ellipse
|
||||
cx="32"
|
||||
cy="32"
|
||||
rx="2"
|
||||
ry="1.5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
<!-- Cheeks -->
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="32"
|
||||
rx="4"
|
||||
ry="3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<ellipse
|
||||
cx="40"
|
||||
cy="32"
|
||||
rx="4"
|
||||
ry="3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
|
||||
<!-- Mouth -->
|
||||
<path
|
||||
d="M30 34 Q32 36 34 34"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Arms holding ruler -->
|
||||
<path
|
||||
d="M20 42 L12 38"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M44 42 L52 38"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Ruler (held diagonally) -->
|
||||
<rect
|
||||
x="4"
|
||||
y="34"
|
||||
width="22"
|
||||
height="4"
|
||||
rx="0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
transform="rotate(-15 15 36)"
|
||||
/>
|
||||
<!-- Ruler tick marks -->
|
||||
<line
|
||||
x1="8" y1="34" x2="8" y2="36"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
transform="rotate(-15 15 36)"
|
||||
/>
|
||||
<line
|
||||
x1="12" y1="34" x2="12" y2="36"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
transform="rotate(-15 15 36)"
|
||||
/>
|
||||
<line
|
||||
x1="16" y1="34" x2="16" y2="36"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
transform="rotate(-15 15 36)"
|
||||
/>
|
||||
<line
|
||||
x1="20" y1="34" x2="20" y2="36"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
transform="rotate(-15 15 36)"
|
||||
/>
|
||||
|
||||
<!-- Safety goggles on forehead -->
|
||||
<path
|
||||
d="M22 20 Q27 17 32 18 Q37 17 42 20"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<!-- Goggle lenses -->
|
||||
<ellipse
|
||||
cx="26"
|
||||
cy="20"
|
||||
rx="3.5"
|
||||
ry="2.5"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<ellipse
|
||||
cx="38"
|
||||
cy="20"
|
||||
rx="3.5"
|
||||
ry="2.5"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- Goggle bridge -->
|
||||
<path
|
||||
d="M29.5 20 Q32 19 34.5 20"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
<!-- Goggle lens tint -->
|
||||
<ellipse
|
||||
cx="26"
|
||||
cy="20"
|
||||
rx="2.5"
|
||||
ry="1.5"
|
||||
fill="#f59e0b"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<ellipse
|
||||
cx="38"
|
||||
cy="20"
|
||||
rx="2.5"
|
||||
ry="1.5"
|
||||
fill="#f59e0b"
|
||||
opacity="0.15"
|
||||
/>
|
||||
|
||||
<!-- Hard hat -->
|
||||
<path
|
||||
d="M18 16 Q18 6 32 5 Q46 6 46 16"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- Hat brim -->
|
||||
<path
|
||||
d="M16 16 L48 16"
|
||||
stroke="#f59e0b"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<!-- Hat dome highlight -->
|
||||
<path
|
||||
d="M26 9 Q32 7 38 9"
|
||||
fill="none"
|
||||
stroke="#fbbf24"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
opacity="0.6"
|
||||
/>
|
||||
|
||||
<!-- Feet -->
|
||||
<ellipse
|
||||
cx="26"
|
||||
cy="56"
|
||||
rx="4"
|
||||
ry="2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<ellipse
|
||||
cx="38"
|
||||
cy="56"
|
||||
rx="4"
|
||||
ry="2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -17,17 +17,32 @@ const steps = [
|
||||
<template v-for="(step, i) in steps" :key="step.num">
|
||||
<Badge
|
||||
:variant="
|
||||
store.currentStep === step.num ? 'default' : 'outline'
|
||||
store.currentStep === step.num
|
||||
? 'default'
|
||||
: 'outline'
|
||||
"
|
||||
class="cursor-default select-none text-xs"
|
||||
class="select-none font-mono text-xs"
|
||||
:class="{
|
||||
'opacity-40': store.currentStep < step.num,
|
||||
'cursor-pointer hover:bg-accent':
|
||||
step.num <= store.maxStepReached
|
||||
&& step.num !== store.currentStep,
|
||||
'cursor-default':
|
||||
step.num > store.maxStepReached
|
||||
|| step.num === store.currentStep,
|
||||
}"
|
||||
@click="
|
||||
step.num <= store.maxStepReached
|
||||
? store.goToStep(step.num)
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
{{ step.num }}. {{ step.label }}
|
||||
{{ step.num }}.{{ step.label }}
|
||||
</Badge>
|
||||
<span v-if="i < steps.length - 1" class="text-muted-foreground"
|
||||
>·</span
|
||||
<span
|
||||
v-if="i < steps.length - 1"
|
||||
class="text-xs text-muted-foreground/40"
|
||||
>›</span
|
||||
>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user