chore: initial scaffold
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
.oxfmtrc.json
Normal file
5
.oxfmtrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
}
|
||||
11
.oxlintrc.json
Normal file
11
.oxlintrc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {},
|
||||
"env": {
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
31
.zed/settings.json
Normal file
31
.zed/settings.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"languages": {
|
||||
"TypeScript": {
|
||||
"format_on_save": "on",
|
||||
"formatter": {
|
||||
"external": {
|
||||
"command": "./node_modules/.bin/oxfmt",
|
||||
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"JavaScript": {
|
||||
"format_on_save": "on",
|
||||
"formatter": {
|
||||
"external": {
|
||||
"command": "./node_modules/.bin/oxfmt",
|
||||
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vue.js": {
|
||||
"format_on_save": "on",
|
||||
"formatter": {
|
||||
"external": {
|
||||
"command": "./node_modules/.bin/oxfmt",
|
||||
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
28
README.md
Normal file
28
README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# blur
|
||||
|
||||
Upload an image and obscure parts of it — with a **rectangle** or a **brush**,
|
||||
using either a **Gaussian blur** or a **pixelate** effect — then **download** the
|
||||
result or **copy** it straight to your clipboard.
|
||||
|
||||
Everything runs client-side in the browser (canvas). Images never leave the
|
||||
device. Deployed at https://blur.apoena.dev
|
||||
|
||||
## Use it
|
||||
|
||||
1. Upload, drag-and-drop, or paste an image (⌘/Ctrl+V).
|
||||
2. Pick a tool (Rectangle / Brush) and an effect (Blur / Pixelate); tune the strength.
|
||||
3. Drag over the parts you want to obscure. ⌘/Ctrl+Z to undo, Reset to start over.
|
||||
4. Download the PNG or Copy it to the clipboard.
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
pnpm dev # frontend on :5173
|
||||
pnpm lint # oxlint (pnpm lint:fix to autofix)
|
||||
pnpm fmt # oxfmt (pnpm fmt:check to verify only)
|
||||
pnpm build # type-check (vue-tsc) + production build — what Coolify runs
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
Pushes to `main` are picked up by Coolify at https://platform.apoena.dev.
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>blur</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
nginx.conf
Normal file
15
nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "blur",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"fmt": "oxfmt",
|
||||
"fmt:check": "oxfmt --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"daisyui": "^5.5.23",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"oxfmt": "^0.55.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
1468
pnpm-lock.yaml
generated
Normal file
1468
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
public/favicon.svg
Normal file
25
public/favicon.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<!--
|
||||
tags: [edit, photo, photography, tool, blur, creative, artistic, visual, aesthetic, style]
|
||||
category: Design
|
||||
version: "1.47"
|
||||
unicode: "ef8c"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#130f40"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 21a9.01 9.01 0 0 0 2.32 -.302a9 9 0 0 0 1.74 -16.733a9 9 0 1 0 -4.06 17.035" />
|
||||
<path d="M12 3v17" />
|
||||
<path d="M12 12h9" />
|
||||
<path d="M12 9h8" />
|
||||
<path d="M12 6h6" />
|
||||
<path d="M12 18h6" />
|
||||
<path d="M12 15h8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
566
src/App.vue
Normal file
566
src/App.vue
Normal file
@@ -0,0 +1,566 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||
|
||||
type Tool = "rect" | "brush"
|
||||
type Effect = "blur" | "pixelate"
|
||||
|
||||
const workRef = ref<HTMLCanvasElement | null>(null)
|
||||
const overlayRef = ref<HTMLCanvasElement | null>(null)
|
||||
const fileRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hasImage = ref(false)
|
||||
const dragOver = ref(false)
|
||||
const tool = ref<Tool>("rect")
|
||||
const effect = ref<Effect>("blur")
|
||||
const blurStrength = ref(14)
|
||||
const pixelStrength = ref(16)
|
||||
const brushSize = ref(44)
|
||||
const imgW = ref(0)
|
||||
const imgH = ref(0)
|
||||
const canUndo = ref(false)
|
||||
const toast = ref<{ text: string; ok: boolean } | null>(null)
|
||||
const canCopy = typeof window !== "undefined" && "ClipboardItem" in window
|
||||
|
||||
// Offscreen canvases (not reactive — raw pixel buffers).
|
||||
let source: HTMLCanvasElement | null = null // pristine original
|
||||
let fx: HTMLCanvasElement | null = null // full-image blurred/pixelated version
|
||||
let undoStack: ImageData[] = []
|
||||
const MAX_UNDO = 12
|
||||
|
||||
// Pointer-drag state.
|
||||
let drawing = false
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let lastX = 0
|
||||
let lastY = 0
|
||||
let toastTimer = 0
|
||||
|
||||
function ctx(c: HTMLCanvasElement): CanvasRenderingContext2D {
|
||||
return c.getContext("2d")!
|
||||
}
|
||||
|
||||
function showToast(text: string, ok: boolean) {
|
||||
toast.value = { text, ok }
|
||||
window.clearTimeout(toastTimer)
|
||||
toastTimer = window.setTimeout(() => (toast.value = null), 2800)
|
||||
}
|
||||
|
||||
function loadFile(file: File) {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
showToast("That file isn't an image.", false)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
setupCanvases(img)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
showToast("Couldn't load that image.", false)
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
|
||||
function setupCanvases(img: HTMLImageElement) {
|
||||
const w = img.naturalWidth
|
||||
const h = img.naturalHeight
|
||||
imgW.value = w
|
||||
imgH.value = h
|
||||
|
||||
const work = workRef.value!
|
||||
const overlay = overlayRef.value!
|
||||
work.width = w
|
||||
work.height = h
|
||||
overlay.width = w
|
||||
overlay.height = h
|
||||
|
||||
source = document.createElement("canvas")
|
||||
source.width = w
|
||||
source.height = h
|
||||
ctx(source).drawImage(img, 0, 0)
|
||||
|
||||
fx = document.createElement("canvas")
|
||||
fx.width = w
|
||||
fx.height = h
|
||||
|
||||
ctx(work).drawImage(img, 0, 0)
|
||||
clearOverlay()
|
||||
undoStack = []
|
||||
canUndo.value = false
|
||||
hasImage.value = true
|
||||
computeEffect()
|
||||
}
|
||||
|
||||
// Rebuild the full-image effect layer from the pristine source.
|
||||
function computeEffect() {
|
||||
if (!source || !fx) return
|
||||
const w = source.width
|
||||
const h = source.height
|
||||
const ec = ctx(fx)
|
||||
ec.clearRect(0, 0, w, h)
|
||||
|
||||
if (effect.value === "blur") {
|
||||
ec.filter = `blur(${blurStrength.value}px)`
|
||||
ec.drawImage(source, 0, 0)
|
||||
ec.filter = "none"
|
||||
return
|
||||
}
|
||||
|
||||
// Pixelate: shrink, then scale back up with smoothing off.
|
||||
const block = pixelStrength.value
|
||||
const sw = Math.max(1, Math.round(w / block))
|
||||
const sh = Math.max(1, Math.round(h / block))
|
||||
const tmp = document.createElement("canvas")
|
||||
tmp.width = sw
|
||||
tmp.height = sh
|
||||
const tc = ctx(tmp)
|
||||
tc.imageSmoothingEnabled = true
|
||||
tc.drawImage(source, 0, 0, sw, sh)
|
||||
ec.imageSmoothingEnabled = false
|
||||
ec.drawImage(tmp, 0, 0, sw, sh, 0, 0, w, h)
|
||||
ec.imageSmoothingEnabled = true
|
||||
}
|
||||
|
||||
watch([effect, blurStrength, pixelStrength], () => {
|
||||
if (hasImage.value) computeEffect()
|
||||
})
|
||||
|
||||
function pushUndo() {
|
||||
const work = workRef.value
|
||||
if (!work) return
|
||||
undoStack.push(ctx(work).getImageData(0, 0, work.width, work.height))
|
||||
if (undoStack.length > MAX_UNDO) undoStack.shift()
|
||||
canUndo.value = true
|
||||
}
|
||||
|
||||
function undo() {
|
||||
const data = undoStack.pop()
|
||||
if (!data) return
|
||||
ctx(workRef.value!).putImageData(data, 0, 0)
|
||||
canUndo.value = undoStack.length > 0
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (!source) return
|
||||
pushUndo()
|
||||
const work = workRef.value!
|
||||
const c = ctx(work)
|
||||
c.clearRect(0, 0, work.width, work.height)
|
||||
c.drawImage(source, 0, 0)
|
||||
}
|
||||
|
||||
// Paint the effect layer into a circular dab (brush).
|
||||
function stampDot(x: number, y: number) {
|
||||
if (!fx) return
|
||||
const r = brushSize.value
|
||||
const c = ctx(workRef.value!)
|
||||
c.save()
|
||||
c.beginPath()
|
||||
c.arc(x, y, r, 0, Math.PI * 2)
|
||||
c.clip()
|
||||
c.drawImage(fx, x - r, y - r, r * 2, r * 2, x - r, y - r, r * 2, r * 2)
|
||||
c.restore()
|
||||
}
|
||||
|
||||
function stampLine(x0: number, y0: number, x1: number, y1: number) {
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0)
|
||||
const step = Math.max(1, brushSize.value / 3)
|
||||
const n = Math.max(1, Math.ceil(dist / step))
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n
|
||||
stampDot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t)
|
||||
}
|
||||
}
|
||||
|
||||
// Paint the effect layer into a rectangle.
|
||||
function paintRect(x0: number, y0: number, x1: number, y1: number) {
|
||||
if (!fx) return
|
||||
const x = Math.min(x0, x1)
|
||||
const y = Math.min(y0, y1)
|
||||
const w = Math.abs(x1 - x0)
|
||||
const h = Math.abs(y1 - y0)
|
||||
if (w < 1 || h < 1) return
|
||||
const c = ctx(workRef.value!)
|
||||
c.save()
|
||||
c.beginPath()
|
||||
c.rect(x, y, w, h)
|
||||
c.clip()
|
||||
c.drawImage(fx, x, y, w, h, x, y, w, h)
|
||||
c.restore()
|
||||
}
|
||||
|
||||
function toCoords(e: PointerEvent) {
|
||||
const overlay = overlayRef.value!
|
||||
const rect = overlay.getBoundingClientRect()
|
||||
return {
|
||||
x: ((e.clientX - rect.left) / rect.width) * overlay.width,
|
||||
y: ((e.clientY - rect.top) / rect.height) * overlay.height,
|
||||
}
|
||||
}
|
||||
|
||||
function clearOverlay() {
|
||||
const o = overlayRef.value
|
||||
if (!o) return
|
||||
ctx(o).clearRect(0, 0, o.width, o.height)
|
||||
}
|
||||
|
||||
// Transient marquee (rect) or brush ring, drawn at canvas resolution.
|
||||
function drawOverlay(x: number, y: number) {
|
||||
const o = overlayRef.value
|
||||
if (!o) return
|
||||
const c = ctx(o)
|
||||
c.clearRect(0, 0, o.width, o.height)
|
||||
const k = o.width / (o.getBoundingClientRect().width || o.width)
|
||||
|
||||
if (drawing && tool.value === "rect") {
|
||||
const rx = Math.min(startX, x)
|
||||
const ry = Math.min(startY, y)
|
||||
const rw = Math.abs(x - startX)
|
||||
const rh = Math.abs(y - startY)
|
||||
c.setLineDash([6 * k, 4 * k])
|
||||
c.lineWidth = 1.5 * k
|
||||
c.strokeStyle = "rgba(0,0,0,0.85)"
|
||||
c.strokeRect(rx, ry, rw, rh)
|
||||
c.strokeStyle = "rgba(255,255,255,0.95)"
|
||||
c.lineDashOffset = 5 * k
|
||||
c.strokeRect(rx, ry, rw, rh)
|
||||
c.setLineDash([])
|
||||
return
|
||||
}
|
||||
|
||||
if (tool.value === "brush") {
|
||||
for (const [color, width] of [
|
||||
["rgba(0,0,0,0.7)", 2.5 * k],
|
||||
["rgba(255,255,255,0.95)", 1 * k],
|
||||
] as const) {
|
||||
c.beginPath()
|
||||
c.arc(x, y, brushSize.value, 0, Math.PI * 2)
|
||||
c.lineWidth = width
|
||||
c.strokeStyle = color
|
||||
c.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!hasImage.value) return
|
||||
overlayRef.value!.setPointerCapture(e.pointerId)
|
||||
const { x, y } = toCoords(e)
|
||||
drawing = true
|
||||
startX = x
|
||||
startY = y
|
||||
lastX = x
|
||||
lastY = y
|
||||
if (tool.value === "brush") {
|
||||
pushUndo()
|
||||
stampDot(x, y)
|
||||
}
|
||||
drawOverlay(x, y)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!hasImage.value) return
|
||||
const { x, y } = toCoords(e)
|
||||
if (drawing && tool.value === "brush") {
|
||||
stampLine(lastX, lastY, x, y)
|
||||
lastX = x
|
||||
lastY = y
|
||||
}
|
||||
drawOverlay(x, y)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!drawing) return
|
||||
const { x, y } = toCoords(e)
|
||||
drawing = false
|
||||
if (tool.value === "rect" && Math.abs(x - startX) >= 1 && Math.abs(y - startY) >= 1) {
|
||||
pushUndo()
|
||||
paintRect(startX, startY, x, y)
|
||||
}
|
||||
clearOverlay()
|
||||
if (tool.value === "brush") drawOverlay(x, y)
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
if (!drawing) clearOverlay()
|
||||
}
|
||||
|
||||
function pickFile() {
|
||||
fileRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) loadFile(file)
|
||||
input.value = ""
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragOver.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) loadFile(file)
|
||||
}
|
||||
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
loadFile(file)
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toBlob(): Promise<Blob | null> {
|
||||
return new Promise((res) => workRef.value!.toBlob(res, "image/png"))
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!hasImage.value) return
|
||||
const blob = await toBlob()
|
||||
if (!blob) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "blurred.png"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!hasImage.value) return
|
||||
try {
|
||||
const blob = await toBlob()
|
||||
if (!blob) throw new Error("no blob")
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])
|
||||
showToast("Copied to clipboard.", true)
|
||||
} catch {
|
||||
showToast("Clipboard copy isn't available here — use Download.", false)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault()
|
||||
undo()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("paste", onPaste)
|
||||
window.addEventListener("keydown", onKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("paste", onPaste)
|
||||
window.removeEventListener("keydown", onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col bg-base-200 text-base-content">
|
||||
<header class="navbar border-b border-base-300 bg-base-100 px-4">
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<svg
|
||||
class="size-7 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 21a9.01 9.01 0 0 0 2.32 -.302a9 9 0 0 0 1.74 -16.733a9 9 0 1 0 -4.06 17.035"
|
||||
/>
|
||||
<path d="M12 3v17" />
|
||||
<path d="M12 12h9" />
|
||||
<path d="M12 9h8" />
|
||||
<path d="M12 6h6" />
|
||||
<path d="M12 18h6" />
|
||||
<path d="M12 15h8" />
|
||||
</svg>
|
||||
<h1 class="text-lg font-semibold tracking-tight">blur</h1>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="pickFile">
|
||||
{{ hasImage ? "New image" : "Upload image" }}
|
||||
</button>
|
||||
<input ref="fileRef" type="file" accept="image/*" class="hidden" @change="onFileChange" />
|
||||
</header>
|
||||
|
||||
<main class="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-4 p-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||
<div class="card-body flex-row flex-wrap items-end gap-x-6 gap-y-4 p-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium opacity-60">Tool</span>
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
:class="tool === 'rect' ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="tool = 'rect'"
|
||||
>
|
||||
Rectangle
|
||||
</button>
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
:class="tool === 'brush' ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="tool = 'brush'"
|
||||
>
|
||||
Brush
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium opacity-60">Effect</span>
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
:class="effect === 'blur' ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="effect = 'blur'"
|
||||
>
|
||||
Blur
|
||||
</button>
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
:class="effect === 'pixelate' ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="effect = 'pixelate'"
|
||||
>
|
||||
Pixelate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label v-show="effect === 'blur'" class="flex w-40 flex-col gap-1">
|
||||
<span class="text-xs font-medium opacity-60">Blur strength · {{ blurStrength }}</span>
|
||||
<input
|
||||
v-model.number="blurStrength"
|
||||
type="range"
|
||||
min="2"
|
||||
max="60"
|
||||
class="range range-primary range-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label v-show="effect === 'pixelate'" class="flex w-40 flex-col gap-1">
|
||||
<span class="text-xs font-medium opacity-60">Pixel size · {{ pixelStrength }}</span>
|
||||
<input
|
||||
v-model.number="pixelStrength"
|
||||
type="range"
|
||||
min="4"
|
||||
max="60"
|
||||
class="range range-primary range-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label v-show="tool === 'brush'" class="flex w-40 flex-col gap-1">
|
||||
<span class="text-xs font-medium opacity-60">Brush size · {{ brushSize }}</span>
|
||||
<input
|
||||
v-model.number="brushSize"
|
||||
type="range"
|
||||
min="8"
|
||||
max="160"
|
||||
class="range range-primary range-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="ml-auto flex items-end gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="!canUndo"
|
||||
title="Undo (⌘/Ctrl+Z)"
|
||||
@click="undo"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="!hasImage" @click="reset">Reset</button>
|
||||
<button class="btn btn-sm" :disabled="!hasImage || !canCopy" @click="copy">Copy</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="!hasImage" @click="download">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage -->
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<!-- Empty state / dropzone -->
|
||||
<button
|
||||
v-if="!hasImage"
|
||||
class="flex w-full max-w-2xl flex-col items-center gap-3 rounded-box border-2 border-dashed p-16 text-center transition-colors"
|
||||
:class="
|
||||
dragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-base-300 bg-base-100 hover:border-primary/50'
|
||||
"
|
||||
@click="pickFile"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave="dragOver = false"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<svg
|
||||
class="size-12 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 21a9.01 9.01 0 0 0 2.32 -.302a9 9 0 0 0 1.74 -16.733a9 9 0 1 0 -4.06 17.035"
|
||||
/>
|
||||
<path d="M12 3v17" />
|
||||
<path d="M12 12h9" />
|
||||
<path d="M12 9h8" />
|
||||
<path d="M12 6h6" />
|
||||
<path d="M12 18h6" />
|
||||
<path d="M12 15h8" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium">Drop an image here</p>
|
||||
<p class="text-sm opacity-60">or click to upload · or paste from your clipboard</p>
|
||||
</button>
|
||||
|
||||
<!-- Canvas stack (kept mounted so refs stay valid) -->
|
||||
<div v-show="hasImage" class="grid">
|
||||
<canvas
|
||||
ref="workRef"
|
||||
class="col-start-1 row-start-1 max-h-[70vh] max-w-full rounded-box border border-base-300 shadow-lg"
|
||||
/>
|
||||
<canvas
|
||||
ref="overlayRef"
|
||||
class="col-start-1 row-start-1 max-h-[70vh] max-w-full touch-none select-none rounded-box"
|
||||
:class="tool === 'brush' ? 'cursor-none' : 'cursor-crosshair'"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="hasImage" class="text-center text-xs opacity-50">
|
||||
{{ imgW }} × {{ imgH }} px · everything happens in your browser — images never leave your
|
||||
device
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<!-- Toast -->
|
||||
<div v-if="toast" class="toast toast-center toast-bottom z-50">
|
||||
<div class="alert" :class="toast.ok ? 'alert-success' : 'alert-error'">
|
||||
<span>{{ toast.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
37
src/assets/icons/README.md
Normal file
37
src/assets/icons/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Icons
|
||||
|
||||
Reusable in-app icons. Source: https://tabler.io/icons (outline variant by default).
|
||||
|
||||
## Add an icon
|
||||
|
||||
1. Find the icon on https://tabler.io/icons, click it, copy the SVG (or download).
|
||||
2. Save it here as `<slug>.svg` — same slug Tabler uses (`bolt.svg`, `qrcode.svg`).
|
||||
3. Keep `stroke="currentColor"` in the SVG so colour follows Tailwind classes.
|
||||
|
||||
## Use an icon
|
||||
|
||||
Static colour (cheapest, no extra component):
|
||||
|
||||
```vue
|
||||
<img src="@/assets/icons/bolt.svg" alt="" class="size-5" />
|
||||
```
|
||||
|
||||
Dynamic colour (needs `currentColor` to flow through — paste the SVG inline as a component):
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<svg
|
||||
class="size-5 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- paste paths from the Tabler SVG here -->
|
||||
</svg>
|
||||
</template>
|
||||
```
|
||||
|
||||
The favicon at `public/favicon.svg` was generated from this same icon set with `currentColor` replaced by the app's primary hex at scaffold time.
|
||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue"
|
||||
import "./style.css"
|
||||
import App from "./App.vue"
|
||||
|
||||
createApp(App).mount("#app")
|
||||
17
src/style.css
Normal file
17
src/style.css
Normal file
@@ -0,0 +1,17 @@
|
||||
/* The font @import MUST come first — before @import "tailwindcss" — because
|
||||
Tailwind inlines its import into real CSS rules, and the CSS spec requires
|
||||
@import to precede all other rules. If it comes second, the production build
|
||||
warns ("@import must precede all rules…") and browsers silently DROP the
|
||||
font import, so the custom font never loads. */
|
||||
@import url("https://api.fonts.coollabs.io/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@plugin "daisyui/theme" {
|
||||
name: "light";
|
||||
default: true;
|
||||
--color-primary: #130f40;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
17
tsconfig.app.json
Normal file
17
tsconfig.app.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath, URL } from "node:url"
|
||||
import { defineConfig } from "vite"
|
||||
import vue from "@vitejs/plugin-vue"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user