Compare commits
5 Commits
5733342619
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a0bd62432 | |||
| d409928506 | |||
| f1e2231675 | |||
| e87f19e7ce | |||
| 3d53660319 |
65
README.md
65
README.md
@@ -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", ...]
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
BIN
docs/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 512 KiB |
@@ -1,4 +1,8 @@
|
|||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
|
import type { Canvas } from "skia-canvas";
|
||||||
|
import { getLogoNames } from "../utils/logos";
|
||||||
|
|
||||||
|
const normalize = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, "");
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { format, logo, content } = await getValidatedQuery(
|
const { format, logo, content } = await getValidatedQuery(
|
||||||
@@ -6,8 +10,26 @@ export default defineEventHandler(async (event) => {
|
|||||||
settingsSchema.parse,
|
settingsSchema.parse,
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoUrl = logo ? resolve("public", `logos/${logo}.png`) : undefined;
|
let canvas: Canvas;
|
||||||
const canvas = await renderQRCodeToCanvas(content, logoUrl);
|
|
||||||
|
if (logo) {
|
||||||
|
const names = await getLogoNames();
|
||||||
|
if (!names)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: "Could not retrieve logos",
|
||||||
|
});
|
||||||
|
|
||||||
|
const match = names.find((n) => normalize(n) === normalize(logo));
|
||||||
|
const resolvedLogo = match ?? logo;
|
||||||
|
|
||||||
|
canvas = await renderQRCodeToCanvas(
|
||||||
|
content,
|
||||||
|
resolve("public", `logos/${resolvedLogo}.png`),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
canvas = await renderQRCodeToCanvas(content, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
const image = canvas.toBuffer(format);
|
const image = canvas.toBuffer(format);
|
||||||
event.node.res.setHeader("Content-Type", `image/${format}`);
|
event.node.res.setHeader("Content-Type", `image/${format}`);
|
||||||
|
|||||||
@@ -1,22 +1,9 @@
|
|||||||
import { readdir } from "fs/promises";
|
import { getLogoNames } from "../utils/logos";
|
||||||
import { join } from "path";
|
|
||||||
import { createHash } from "crypto";
|
|
||||||
|
|
||||||
const logosDir = import.meta.dev
|
export default defineEventHandler(async () => {
|
||||||
? join(process.cwd(), "public/logos")
|
const names = await getLogoNames();
|
||||||
: join(process.cwd(), ".output/public/logos");
|
if (!names)
|
||||||
|
throw createError({ statusCode: 500, message: "Could not retrieve logos" });
|
||||||
|
|
||||||
export default defineCachedEventHandler(async () => {
|
return names;
|
||||||
const files = await readdir(logosDir);
|
|
||||||
return files
|
|
||||||
.filter((f) => f.endsWith(".png"))
|
|
||||||
.map((f) => f.replace(/\.png$/, ""))
|
|
||||||
.sort();
|
|
||||||
}, {
|
|
||||||
maxAge: 60 * 60 * 24,
|
|
||||||
getKey: async () => {
|
|
||||||
const files = await readdir(logosDir);
|
|
||||||
const key = files.filter((f) => f.endsWith(".png")).sort().join(",");
|
|
||||||
return createHash("sha256").update(key).digest("hex");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
29
server/utils/logos.ts
Normal file
29
server/utils/logos.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { readdir } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
const logosDir = import.meta.dev
|
||||||
|
? join(process.cwd(), "public/logos")
|
||||||
|
: join(process.cwd(), ".output/public/logos");
|
||||||
|
|
||||||
|
export const getLogoNames = cachedFunction(
|
||||||
|
async () => {
|
||||||
|
const files = await readdir(logosDir);
|
||||||
|
return files
|
||||||
|
.filter((f) => f.endsWith(".png"))
|
||||||
|
.map((f) => f.replace(/\.png$/, ""))
|
||||||
|
.sort();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
name: "logoNames",
|
||||||
|
getKey: async () => {
|
||||||
|
const files = await readdir(logosDir);
|
||||||
|
const key = files
|
||||||
|
.filter((f) => f.endsWith(".png"))
|
||||||
|
.sort()
|
||||||
|
.join(",");
|
||||||
|
return createHash("sha256").update(key).digest("hex");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user