Compare commits

...

17 Commits

Author SHA1 Message Date
4a0bd62432 chore: update README
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m12s
2026-04-11 19:53:32 +02:00
d409928506 chore: update README
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m32s
2026-04-11 19:47:12 +02:00
f1e2231675 feat(umami): setup
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m32s
2026-02-27 20:07:00 +01:00
e87f19e7ce feat(api): allow _ and - in logo name
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m35s
2026-02-22 14:07:22 +01:00
3d53660319 feat(api): normalize logo name 2026-02-22 14:06:15 +01:00
5733342619 fix(ui): replace old github default image with gitea one
All checks were successful
Build and Push Docker Image / build (push) Successful in 3m48s
2026-02-22 13:44:42 +01:00
d7ba7da0f3 refactor: use nuxtimg to improve performances 2026-02-22 00:51:10 +01:00
85febfb1c8 feat: change logo names to be real brand name 2026-02-21 22:54:37 +01:00
7be6a7c3a2 feat(ui): show icons in the select menu 2026-02-21 22:41:21 +01:00
0a6d9dd2d2 fix(ui): replace last reference to github.com by git.pihkaal.me 2026-02-21 22:30:21 +01:00
ddcc898b7e feat(logos): add more logos and standardize size 2026-02-21 22:29:41 +01:00
1035320763 chore: update license 2026-02-21 22:22:05 +01:00
dc26ec415f feat: handle and display clipboard error in useCopyable 2026-02-21 22:09:50 +01:00
81987ac8f1 feat: list logos from the api instead of hardcoding it 2026-02-21 22:05:26 +01:00
43c91b3c54 refactor: restructure and improve quality 2026-02-21 21:38:44 +01:00
09ef185d89 feat(api): name the file qrcode.[format] to match the front-end naming 2026-02-21 16:25:20 +01:00
c4c165edab feat(nuxt): nuxt3 -> nuxt4 2026-02-21 16:22:51 +01:00
127 changed files with 7275 additions and 9338 deletions

12
.prettierignore Normal file
View File

@@ -0,0 +1,12 @@
.test
.nuxt
.output
.data
dist
node_modules
*.JPG
*.png
*.webp
*.gltf
*.blend
*.blend1

View File

@@ -9,7 +9,7 @@ COPY . /app
WORKDIR /app WORKDIR /app
FROM base AS prod-deps FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile --ignore-scripts
FROM base AS build FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
@@ -17,6 +17,7 @@ RUN pnpm run build
FROM base FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/.output /app/.output COPY --from=build /app/.output /app/.output
EXPOSE 3000 EXPOSE 3000

View File

@@ -1,4 +1,4 @@
Copyright (c) 2024 Pihkaal <hello@pihkaal.me> Copyright (c) 2024-2026 Pihkaal <hello@pihkaal.me>
Permission is hereby granted, free of charge, to any person Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without files (the "Software"), to deal in the Software without

View File

@@ -1,36 +1,47 @@
<h1 align="center"> <div align="center">
<br> <h1><a target="_blank" href="https://simple-qr.com">simple-qr.com</a></h1>
<img src="https://i.imgur.com/an3wOdO.png" alt="QRCode Image" width="200"> <img src="./docs/demo.gif" alt="demo" />
<br> </div>
simple-qr.com
<br>
</h1>
<h4 align="center">Simple, bullshit-free QR code generator with straightforward API.</h4> Simple, bullshit-free QR code generator with straightforward API.
<p align="center"> ## What it does
<a href="https://nuxt.com">
<img src="https://img.shields.io/badge/nuxt-4ade80?style=for-the-badge&logo=vite&logoColor=white">
</a>
<a href="https://typescriptlang.org">
<img src="https://img.shields.io/badge/TypeScript-007acc?style=for-the-badge&logo=typescript&logoColor=white">
</a>
</p>
<p align="center" id="links"> - Generate QR codes via a simple web UI or a straightforward REST API
<a href="#description">Description</a> • - Embed logos (30+ brands: Gitea, Signal, Monero, Session, etc.) in the center of QR codes
<a href="https://simple-qr.com">Visit it</a> • - Export in PNG, JPEG, or WebP formats
<a href="#license">License</a> - No authentication, no rate limiting, no ads
</p>
<br> ## Stack
## Description - [Nuxt 4](https://nuxt.com) + [Nuxt UI](https://ui.nuxt.com)
I created this side project to learn more about Nuxt and Vue, also because I needed a simple way to generate QR codes, but all websites I found were full of ads and were over complicated. ## API
<br> ### `GET /api` - Generate a QR code
## License Returns the QR code as an image file.
This project is <a href="https://opensource.org/licenses/MIT">MIT</a> licensed. | Parameter | Type | Required | Description |
|-----------|---------------------------|----------|-----------------------------------------------------|
| `content` | string | yes | Data to encode |
| `format` | `png` \| `jpeg` \| `webp` | no | Output format (default: `png`) |
| `logo` | string | no | Logo name to embed in the center (case-insensitive) |
**Example:**
```
GET https://simple-qr.com/api?content=https://git.pihkaal.me&format=webp&logo=gitea
```
### `GET /api/logos` - List available logos
Returns a JSON array of available logo names.
**Example:**
```
GET https://simple-qr.com/api/logos
```
```json
["signal", "monero", "session", ...]
```

View File

@@ -20,7 +20,8 @@ useSeoMeta({
</script> </script>
<template> <template>
<div role="main" class="flex min-h-[100vh] items-center justify-center"> <UApp>
<div role="main" class="flex min-h-screen items-center justify-center">
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
<div <div
@@ -30,4 +31,5 @@ useSeoMeta({
<AppBody /> <AppBody />
</div> </div>
</div> </div>
</UApp>
</template> </template>

28
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,28 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme {
--ui-color-primary-50: oklch(98.2% 0.018 155.826);
--ui-color-primary-100: oklch(96.2% 0.044 156.743);
--ui-color-primary-200: oklch(92.5% 0.084 155.995);
--ui-color-primary-300: oklch(87.1% 0.15 154.449);
--ui-color-primary-400: oklch(79.5% 0.22 151.711);
--ui-color-primary-500: oklch(72.5% 0.232 149.579);
--ui-color-primary-600: oklch(62.9% 0.204 149.214);
--ui-color-primary-700: oklch(52.8% 0.162 150.069);
--ui-color-primary-800: oklch(44.9% 0.125 151.328);
--ui-color-primary-900: oklch(39.4% 0.1 152.535);
--ui-color-primary-950: oklch(26.7% 0.068 152.934);
--ui-color-neutral-50: oklch(98.5% 0 0);
--ui-color-neutral-100: oklch(96.7% 0.001 286.375);
--ui-color-neutral-200: oklch(92% 0.004 286.32);
--ui-color-neutral-300: oklch(87.1% 0.006 286.286);
--ui-color-neutral-400: oklch(70.5% 0.015 286.067);
--ui-color-neutral-500: oklch(55.2% 0.016 285.938);
--ui-color-neutral-600: oklch(44.2% 0.017 285.786);
--ui-color-neutral-700: oklch(37% 0.013 285.805);
--ui-color-neutral-800: oklch(27.4% 0.006 286.033);
--ui-color-neutral-900: oklch(21% 0.006 285.885);
--ui-color-neutral-950: oklch(14.1% 0.005 285.823);
}

View File

@@ -1,45 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
const app = useAppStore();
const closeModal = () => {
app.apiModalOpened = false;
};
const baseApiUrl = useBaseApiUrl(); const baseApiUrl = useBaseApiUrl();
const { copy: copyBaseApiUrl, icon: baseApiUrlIcon } = useCopyable(baseApiUrl); const { copy: copyBaseApiUrl, icon: baseApiUrlIcon } = useCopyable(baseApiUrl);
const { data: logos } = await useFetch<string[]>("/api/logos");
</script> </script>
<template> <template>
<UModal v-model="app.apiModalOpened"> <UModal title="API Documentation">
<UCard <template #body>
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-xl font-semibold leading-6 text-gray-900 dark:text-white"
>
API Documentation
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="closeModal"
/>
</div>
</template>
<div class="flex flex-col space-y-8"> <div class="flex flex-col space-y-8">
<p> <p>
You can easily generate QRCodes by using the API, with no rate You can easily generate QRCodes by using the API, with no rate
limitation. limitation.
<br /> <br >
<br /> <br >
If you are not sure how to use the API, you can fill the QRCode form If you are not sure how to use the API, you can fill the QRCode form
and copy the generated API URL. and copy the generated API URL.
</p> </p>
@@ -47,23 +20,24 @@ const { copy: copyBaseApiUrl, icon: baseApiUrlIcon } = useCopyable(baseApiUrl);
<div class="space-y-3"> <div class="space-y-3">
<h2 class="font-bold text-lg">Base API URL</h2> <h2 class="font-bold text-lg">Base API URL</h2>
<UButtonGroup size="sm" orientation="horizontal" class="w-full"> <UButtonGroup size="md" class="w-full">
<UInput <UInput
:model-value="baseApiUrl" :model-value="baseApiUrl"
size="sm" size="md"
class="w-full" class="w-full"
disabled disabled
:ui="{ base: '!ps-12 !cursor-text font-mono' }" :ui="{ base: '!ps-12.5 !cursor-text font-mono' }"
> >
<template #leading> <template #leading>
<span <span
class="text-white dark:text-gray-900 bg-primary py-0.5 -mx-1 px-2 text-xs rounded-sm" class="text-white dark:text-gray-900 bg-primary py-0.5 -mx-1 px-2 text-xs rounded-xs font-mono"
>GET</span ><span class="translate-y-px inline-block">GET</span></span
> >
</template> </template>
</UInput> </UInput>
<UButton <UButton
color="gray" color="neutral"
variant="subtle"
:icon="baseApiUrlIcon" :icon="baseApiUrlIcon"
@click="copyBaseApiUrl" @click="copyBaseApiUrl"
/> />
@@ -75,21 +49,21 @@ const { copy: copyBaseApiUrl, icon: baseApiUrlIcon } = useCopyable(baseApiUrl);
<div <div
class="text-sm flex flex-col space-y-4 font-mono divide-y divide-gray-200 dark:divide-gray-800" class="text-sm flex flex-col space-y-4 font-mono divide-y divide-gray-200 dark:divide-gray-800"
> >
<div class="pt-1 flex justify-between gap-x-5"> <div class="pt-1 pb-4 flex justify-between gap-x-5">
<span class="text-primary font-semibold">format</span> <span class="text-primary font-semibold">format</span>
<span class="text-slate-700 dark:text-slate-200 text-right">{{ <span class="text-slate-700 dark:text-slate-200 text-right">{{
arrayToUnion(IMAGE_FORMATS) arrayToUnion(IMAGE_FORMATS)
}}</span> }}</span>
</div> </div>
<div class="pt-4 flex justify-between gap-x-5"> <div class="flex pb-4 justify-between gap-x-5">
<span class="text-primary font-semibold">logo</span> <span class="text-primary font-semibold">logo</span>
<span class="text-slate-700 dark:text-slate-200 text-right">{{ <span class="text-slate-700 dark:text-slate-200 text-right">{{
arrayToUnion(LOGOS) arrayToUnion(logos ?? [])
}}</span> }}</span>
</div> </div>
<div class="pt-4 flex justify-between gap-x-5"> <div class="flex justify-between gap-x-5">
<span class="text-primary font-semibold">content</span> <span class="text-primary font-semibold">content</span>
<span class="text-slate-700 dark:text-slate-200 text-right" <span class="text-slate-700 dark:text-slate-200 text-right"
>string</span >string</span
@@ -98,6 +72,6 @@ const { copy: copyBaseApiUrl, icon: baseApiUrlIcon } = useCopyable(baseApiUrl);
</div> </div>
</div> </div>
</div> </div>
</UCard> </template>
</UModal> </UModal>
</template> </template>

271
app/components/AppBody.vue Normal file
View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import { z } from "zod";
import { LazyApiModal } from "#components";
const qrCode = ref("/default.webp");
const form = useTemplateRef("form");
const baseApiUrl = useBaseApiUrl();
const overlay = useOverlay();
const apiModal = overlay.create(LazyApiModal);
const isQRCodeEmpty = computed(() => qrCode.value === "/default.webp");
const { data: logos } = await useFetch("/api/logos");
const logoItems = computed(
() =>
logos.value?.map((name) => ({
label: name,
value: name,
avatar: { src: `/logos/${name}.png`, alt: name },
})) ?? [],
);
const selectedLogoItem = computed({
get: () => logoItems.value?.find((i) => i.value === formState.logo),
set: (item) => {
formState.logo = item?.value as string | undefined;
},
});
const formSchema = z
.object({
hasLogo: z.boolean(),
logo: z.string().optional(),
format: z.enum(IMAGE_FORMATS).default("png"),
content: z
.string()
.optional()
.transform((v) => v ?? ""),
})
.superRefine((data, ctx) => {
if (!data.content)
ctx.addIssue({ code: "custom", message: "Required", path: ["content"] });
if (data.hasLogo && !data.logo)
ctx.addIssue({ code: "custom", message: "Required", path: ["logo"] });
});
const formState = reactive<z.input<typeof formSchema>>({
hasLogo: false,
logo: undefined,
format: IMAGE_FORMATS[0],
content: undefined,
});
const parsedFormState = computed(() => formSchema.safeParse(formState));
watch(formState, async () => {
await nextTick();
form.value?.validate({ silent: true });
updateQRCode();
});
const apiUrl = computed<string>((previous) => {
if (!parsedFormState.value.success) return previous ?? "";
const { content, format, hasLogo, logo } = parsedFormState.value.data;
const params = new URLSearchParams({
...(hasLogo && logo && { logo }),
format,
content,
});
return `${baseApiUrl}?${params}`;
});
const { icon: copyUrlIcon, copy: copyUrl } = useCopyable(apiUrl);
const updateQRCode = async () => {
if (!parsedFormState.value.success) return;
const { content, hasLogo, logo, format } = parsedFormState.value.data;
const logoUrl = hasLogo && logo ? `/logos/${logo}.png` : undefined;
const canvas = await renderQRCodeToCanvas(content, logoUrl);
qrCode.value = canvas.toDataURL(format);
};
const downloadQRCode = () => {
const link = document.createElement("a");
link.href = qrCode.value;
link.download = `qrcode.${formState.format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const {
copy: copyQRCode,
icon: copyImageIcon,
label: copyImageLabel,
} = useCopyable(async () => {
if (isQRCodeEmpty.value) return;
if (!parsedFormState.value.success) return;
const { content, hasLogo, logo } = parsedFormState.value.data;
const logoUrl = hasLogo && logo ? `/logos/${logo}.png` : undefined;
const canvas = await renderQRCodeToCanvas(content, logoUrl);
const qrCode = canvas.toDataURL("png");
const blob = await (await fetch(qrCode)).blob();
const item = new ClipboardItem({ "image/png": blob });
await navigator.clipboard.write([item]);
});
</script>
<template>
<div class="flex flex-col sm:flex-row justify-between gap-4">
<img
:src="qrCode"
class="w-full max-w-[375px] max-h-[375px] sm:max-w-[315px] sm:max-h-[315px] md:max-w-[375px] md:max-h-[375px] m-auto aspect-square border border-gray-100 dark:border-gray-800"
>
<div class="flex-1 flex flex-col justify-center">
<UForm
ref="form"
:schema="formSchema"
:state="formState"
:validate-on="['blur']"
class="space-y-4"
>
<UFormField
label="Username or link"
name="content"
:ui="{ error: 'hidden' }"
>
<UInput
v-model="formState.content"
icon="i-heroicons-user"
placeholder="Your username or profile link"
class="w-full"
/>
</UFormField>
<UFormField name="logo" :ui="{ error: 'hidden' }">
<template #label>
<UCheckbox
v-model="formState.hasLogo"
class="mb-1.5"
label="Logo"
/>
</template>
<USelectMenu
v-model="selectedLogoItem"
:items="logoItems"
:disabled="!formState.hasLogo"
placeholder="Select logo"
class="w-full"
>
<template #leading>
<NuxtImg
v-if="selectedLogoItem"
:src="selectedLogoItem.avatar?.src"
:alt="selectedLogoItem.label"
width="16"
height="16"
class="dark:invert"
/>
<UIcon
v-else
name="i-heroicons-photo"
class="size-4 text-dimmed"
/>
</template>
<template v-if="selectedLogoItem">{{
selectedLogoItem.label
}}</template>
<template #item-leading="{ item }">
<NuxtImg
:src="item.avatar?.src"
:alt="item.label"
width="16"
height="16"
class="dark:invert"
/>
</template>
</USelectMenu>
</UFormField>
<UFormField label="Format" name="format">
<USelectMenu
v-model="formState.format"
icon="i-heroicons-document-duplicate"
:items="unreadonly(IMAGE_FORMATS)"
placeholder="Select format"
class="w-full"
>
<template #item-label="{ item }">
{{ upperCase(item) }}
</template>
<span v-if="formState.format">{{
upperCase(formState.format)
}}</span>
</USelectMenu>
</UFormField>
<UFormField label="API">
<template #hint>
<UButton
size="md"
color="neutral"
variant="link"
icon="i-heroicons-question-mark-circle"
@click="apiModal.open()"
/>
</template>
<UButtonGroup size="sm" class="w-full">
<UInput
v-model="apiUrl"
disabled
placeholder="Please fill all fields first"
class="w-full"
/>
<UButton
color="neutral"
variant="subtle"
:disabled="!apiUrl"
:icon="copyUrlIcon"
@click="copyUrl"
/>
</UButtonGroup>
</UFormField>
<div class="flex space-x-4 pt-2">
<UButton
class="flex-1"
block
:icon="copyImageIcon"
size="md"
color="primary"
variant="solid"
:label="copyImageLabel"
:trailing="false"
:disabled="isQRCodeEmpty"
@click="copyQRCode"
/>
<UButton
class="flex-1"
block
icon="i-heroicons-arrow-down-tray"
size="md"
color="primary"
variant="solid"
label="Download"
:trailing="false"
:disabled="isQRCodeEmpty"
@click="downloadQRCode"
/>
</div>
</UForm>
</div>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
const colorMode = useColorMode();
const isDark = computed({
get() {
return colorMode.value === "dark";
},
set() {
colorMode.preference = colorMode.value === "dark" ? "light" : "dark";
},
});
</script>
<template>
<div
class="pb-1.5 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center"
>
<h1 class="text-2xl sm:text-4xl font-bold">Simple QRCode</h1>
<ClientOnly>
<div class="flex gap-x-1 animate-fadeIn">
<UButton
color="neutral"
variant="ghost"
icon="i-bi-git"
aria-label="Git repo"
class="w-8 h-8"
target="_blank"
to="https://git.pihkaal.me/simple-qr"
/>
<UButton
:icon="
isDark ? 'i-heroicons-moon-20-solid' : 'i-heroicons-sun-20-solid'
"
color="neutral"
variant="ghost"
aria-label="Theme"
class="w-8 h-8"
@click="isDark = !isDark"
/>
</div>
</ClientOnly>
</div>
</template>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fadeIn {
animation: fadeIn 200ms ease-in-out forwards;
}
</style>

View File

@@ -0,0 +1,40 @@
export const useCopyable = (
valueOrCallback:
| string
| Ref<string>
| (() => PromiseLike<string>)
| (() => PromiseLike<void>),
) => {
const icon = ref("i-heroicons-clipboard-document");
const label = ref("Copy");
const toast = useToast();
const copy = async () => {
try {
if (typeof valueOrCallback === "function") {
await valueOrCallback();
} else {
const value = unref(valueOrCallback);
await navigator.clipboard.writeText(value);
}
} catch {
toast.add({
title: "Failed to copy to clipboard",
color: "error",
icon: "i-lucide-circle-x",
});
return;
}
icon.value = "i-heroicons-clipboard-document-check";
label.value = "Copied!";
setTimeout(() => {
icon.value = "i-heroicons-clipboard-document";
label.value = "Copy";
}, 3000);
};
return { icon, copy, label };
};

View File

@@ -1,9 +0,0 @@
<template>
<div class="flex flex-col sm:flex-row justify-between gap-4">
<QRCodePreview />
<QRCodeForm />
</div>
<ApiModal />
</template>

View File

@@ -1,210 +0,0 @@
<script setup lang="ts">
const app = useAppStore();
const baseApiUrl = useBaseApiUrl();
const isQRCodeEmpty = computed(() => app.qrCode === "/default.webp");
const firstBlured = ref(false);
const state = reactive({
hasLogo: false,
logo: undefined,
format: IMAGE_FORMATS[0],
content: undefined,
});
const stateErrors = computed(() => ({
content: firstBlured.value && !state.content,
logo: firstBlured.value && state.hasLogo && !state.logo,
}));
const isValidState = computed(
() =>
((state.hasLogo && state.logo) || !state.hasLogo) &&
state.content &&
state.format,
);
const apiUrl = computed((previous) => {
if (!isValidState.value) return previous;
const params = new URLSearchParams({
...(state.hasLogo && { logo: state.logo }),
format: state.format,
content: state.content,
});
return `${baseApiUrl}?${params}`;
});
const isValidApiUrl = computed(() => !!apiUrl.value);
const { icon: copyUrlIcon, copy: copyUrl } = useCopyable(apiUrl);
const openApiModal = () => {
app.apiModalOpened = true;
};
const updateQRCode = async () => {
await nextTick();
if (!isValidState.value) return;
const logoUrl = state.hasLogo ? `/logos/${state.logo}.png` : undefined;
const canvas = await renderQRCodeToCanvas(state.content, logoUrl);
app.qrCode = canvas.toDataURL(`image/${state.format}`);
};
const downloadQRCode = () => {
const link = document.createElement("a");
link.href = app.qrCode;
link.download = `qrcode.${state.format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const {
copy: copyQRCode,
icon: copyImageIcon,
label: copyImageLabel,
} = useCopyable(async () => {
if (isQRCodeEmpty.value) return;
const logoUrl = state.hasLogo ? `/logos/${state.logo}.png` : undefined;
const canvas = await renderQRCodeToCanvas(state.content, logoUrl);
const qrCode = canvas.toDataURL(`image/png`);
const blob = await (await fetch(qrCode)).blob();
const item = new ClipboardItem({ "image/png": blob });
await navigator.clipboard.write([item]);
});
</script>
<template>
<div class="flex-1 flex flex-col justify-center">
<UForm ref="form" :state="state" class="space-y-4">
<UFormGroup
label="Username or link"
name="content"
:error="stateErrors.content"
>
<UInput
v-model="state.content"
icon="i-heroicons-user"
placeholder="Your username or profile link"
@input="updateQRCode"
@blur="firstBlured = true"
/>
</UFormGroup>
<UFormGroup name="logo" :error="stateErrors.logo">
<template #label>
<UCheckbox
v-model="state.hasLogo"
class="mb-1.5"
label="Logo"
@change="updateQRCode"
@blur="firstBlured = true"
/>
</template>
<USelectMenu
v-model="state.logo"
icon="i-heroicons-photo"
:options="LOGOS"
:disabled="!state.hasLogo"
placeholder="Select logo"
searchable
clear-search-on-close
@change="updateQRCode"
@blur="firstBlured = true"
>
<template #label>
<span v-if="state.logo">{{ capitalize(state.logo) }}</span>
</template>
<template #option="props">
<span>{{ capitalize(props.option) }}</span>
</template>
</USelectMenu>
</UFormGroup>
<UFormGroup label="Format" name="format">
<USelectMenu
v-model="state.format"
icon="i-heroicons-document-duplicate"
:options="IMAGE_FORMATS"
placeholder="Select format"
@change="updateQRCode"
@blur="firstBlured = true"
>
<template #label>
<span v-if="state.format">{{ upperCase(state.format) }}</span>
</template>
<template #option="props">
<span>{{ upperCase(props.option) }}</span>
</template>
</USelectMenu>
</UFormGroup>
<UFormGroup label="API">
<template #hint>
<UButton
size="md"
color="gray"
variant="link"
icon="i-heroicons-question-mark-circle"
@click="openApiModal"
/>
</template>
<UButtonGroup size="sm" orientation="horizontal" class="w-full">
<UInput
v-model="apiUrl"
disabled
placeholder="Please fill all fields first"
class="w-full"
/>
<UButton
color="gray"
:disabled="!isValidApiUrl"
:icon="copyUrlIcon"
@click="copyUrl"
/>
</UButtonGroup>
</UFormGroup>
<div class="flex space-x-4 pt-2">
<UButton
class="flex-1"
block
:icon="copyImageIcon"
size="md"
color="primary"
variant="solid"
:label="copyImageLabel"
:trailing="false"
:disabled="isQRCodeEmpty"
@click="copyQRCode"
/>
<UButton
class="flex-1"
block
icon="i-heroicons-arrow-down-tray"
size="md"
color="primary"
variant="solid"
label="Download"
:trailing="false"
:disabled="isQRCodeEmpty"
@click="downloadQRCode"
/>
</div>
</UForm>
</div>
</template>

View File

@@ -1,10 +0,0 @@
<script setup lang="ts">
const app = useAppStore();
</script>
<template>
<img
:src="app.qrCode"
class="w-full max-w-[375px] max-h-[375px] sm:max-w-[315px] sm:max-h-[315px] md:max-w-[375px] md:max-h-[375px] m-auto aspect-square border border-gray-100 dark:border-gray-800"
/>
</template>

View File

@@ -1,38 +0,0 @@
<template>
<div
class="pb-1.5 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center"
>
<h1 class="text-2xl sm:text-4xl font-bold">Simple QRCode</h1>
<ClientOnly>
<div class="flex gap-x-1 animate-fadeIn">
<UButton
color="gray"
variant="ghost"
icon="i-uil-github"
aria-label="Github repo"
class="w-8 h-8"
target="_blank"
to="https://github.com/pihkaal/simple-qr"
/>
<ThemeSwitcher />
</div>
</ClientOnly>
</div>
</template>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fadeIn {
animation: fadeIn 200ms ease-in-out forwards;
}
</style>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
const colorMode = useColorMode();
const isDark = computed({
get() {
return colorMode.value === "dark";
},
set() {
colorMode.preference = colorMode.value === "dark" ? "light" : "dark";
},
});
</script>
<template>
<UButton
:icon="isDark ? 'i-heroicons-moon-20-solid' : 'i-heroicons-sun-20-solid'"
color="gray"
variant="ghost"
aria-label="Theme"
class="w-8 h-8"
@click="isDark = !isDark"
/>
</template>

View File

@@ -1,25 +0,0 @@
export const useCopyable = (
valueOrCallback: string | Ref<string> | (() => PromiseLike<string>),
) => {
const icon = ref("i-heroicons-clipboard-document");
const label = ref("Copy");
const copy = async () => {
if (typeof valueOrCallback === "function") {
await valueOrCallback();
} else {
const value = unref(valueOrCallback);
await navigator.clipboard.writeText(value);
}
icon.value = "i-heroicons-clipboard-document-check";
label.value = "Copied!";
setTimeout(() => {
icon.value = "i-heroicons-clipboard-document";
label.value = "Copy";
}, 3000);
};
return { icon, copy, label };
};

View File

@@ -11,6 +11,7 @@ services:
- "traefik.http.services.simple-qr.loadbalancer.server.port=3000" - "traefik.http.services.simple-qr.loadbalancer.server.port=3000"
- "traefik.http.routers.simple-qr.tls=true" - "traefik.http.routers.simple-qr.tls=true"
- "traefik.http.routers.simple-qr.tls.certResolver=myresolver" - "traefik.http.routers.simple-qr.tls.certResolver=myresolver"
- "traefik.http.routers.simple-qr.middlewares=umami-middleware@file"
restart: always restart: always
networks: networks:

BIN
docs/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

View File

@@ -1,5 +1,3 @@
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs"; import withNuxt from "./.nuxt/eslint.config.mjs";
export default withNuxt(); export default withNuxt();
// Your custom configs here

View File

@@ -1,8 +1,9 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2024-04-03", compatibilityDate: "2024-11-01",
devtools: { enabled: true }, devtools: { enabled: true },
modules: ["@nuxt/eslint", "@nuxt/ui", "@pinia/nuxt"], modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxt/image"],
css: ["~/assets/css/main.css"],
components: [ components: [
{ {
path: "~/components", path: "~/components",

View File

@@ -1,5 +1,5 @@
{ {
"name": "social-qr", "name": "simple-qr",
"description": "Simple, bullshit-free QR code generator", "description": "Simple, bullshit-free QR code generator",
"author": "Pihkaal <hello@pihkaal.me> (https://pihkaal.me)", "author": "Pihkaal <hello@pihkaal.me> (https://pihkaal.me)",
"private": true, "private": true,
@@ -11,25 +11,28 @@
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"lint": "eslint --fix --cache .", "lint": "eslint --fix --cache .",
"format": "pnpx prettier --cache --write ." "format": "prettier --cache --write ."
}, },
"dependencies": { "dependencies": {
"@iconify-json/heroicons": "^1.2.0", "@nuxt/image": "^2.0.0",
"@iconify-json/uil": "^1.2.1", "jszip": "^3.10.1",
"@nuxt/eslint": "^0.5.7",
"@nuxt/ui": "^2.18.6",
"@pinia/nuxt": "^0.5.5",
"canvas": "^2.11.2",
"nuxt": "^3.13.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.33.5", "skia-canvas": "^3.0.8",
"vue": "latest", "zod": "^4.3.6"
"vue-router": "latest",
"zod": "^3.23.8"
}, },
"packageManager": "pnpm@9.11.0", "packageManager": "pnpm@10.30.1",
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.5", "@iconify-json/bi": "^1.2.7",
"typescript": "5.5.4" "@iconify-json/heroicons": "^1.2.3",
"@iconify-json/lucide": "^1.2.92",
"@iconify-json/uil": "^1.2.3",
"@nuxt/eslint": "^1.15.1",
"@nuxt/ui": "^3.3.7",
"@types/qrcode": "^1.5.6",
"eslint": "^9.39.1",
"nuxt": "^4.3.1",
"prettier": "^3.8.1",
"tailwindcss": "^4.2.0",
"typescript": "^5.6.3"
} }
} }

15505
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
onlyBuiltDependencies:
- esbuild
- sharp
- skia-canvas
- unrs-resolver
- vue-demi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
public/logos/BeReal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
public/logos/Bitcoin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/logos/Codeberg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/logos/Codecademy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/logos/Diaspora.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/logos/Discord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/logos/Dropbox.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/logos/Ello.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/logos/Facebook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/logos/Flickr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
public/logos/Git.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
public/logos/GitHub.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/logos/GitLab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/logos/Gitea.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/logos/Instagram.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/logos/Kik.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

BIN
public/logos/Line.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/logos/LinkedIn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
public/logos/Litecoin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/logos/Mastodon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/logos/Medium.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/logos/Messenger.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/logos/Monero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/logos/Napster.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/logos/OnlyFans.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/logos/Patreon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
public/logos/PayPal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
public/logos/PeerTube.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/logos/Pinterest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/logos/Reddit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/logos/Session.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/logos/Signal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/logos/Snapchat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/logos/Spotify.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/logos/Substack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/logos/Telegram.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/logos/Threema.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/logos/Twitch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/logos/Venmo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
public/logos/Viber.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/logos/WeChat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/logos/WhatsApp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/logos/X.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/logos/YouTube.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
public/logos/Zoom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

BIN
public/logos/iMessage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Some files were not shown because too many files have changed in this diff Show More