chore: initial scaffold

This commit is contained in:
Julien Calixte
2026-06-23 11:05:42 +02:00
commit 94a65fc641
21 changed files with 2352 additions and 0 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

24
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": false
}

11
.oxlintrc.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

31
.zed/settings.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

25
public/favicon.svg Normal file
View 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
View 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>

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

24
tsconfig.node.json Normal file
View 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
View 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)),
},
},
})