feat: working mvp

This commit is contained in:
2025-12-20 18:54:17 +01:00
parent 978df91b6e
commit 4f045da855
5 changed files with 365 additions and 61 deletions
+8 -7
View File
@@ -8,13 +8,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" />
<!-- card related -->
<!--
<meta name="twitter:card" content="summary_large_image">
<meta property="og:title" content="">
<meta property="og:description" content="">
<meta property="og:image" content="https://.../preview_640x320.jpg">
<meta property="org:url" content="https://...">
-->
<meta name="twitter:card" content="summary_large_image" />
<meta property="og:title" content="Coverify" />
<meta
property="og:description"
content="Create album covers from mundane photos"
/>
<meta property="org:url" content="https://klemek.github.io/coverify/" />
</head>
<body>
<div id="app"></div>
+356 -12
View File
@@ -1,8 +1,187 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, onMounted, useTemplateRef, computed } from "vue";
import LucideIcon from "./components/LucideIcon.vue";
enum Filter {
None = "None",
Gray = "Gray",
Sepia = "Sepia",
Invert = "Invert",
}
enum PAPosition {
None = "None",
BL = "Bottom Left",
BC = "Bottom Center",
BR = "Bottom Right",
TL = "Top Left",
TC = "Top Center",
TR = "Top Right",
}
const PA_RATIO = 404 / 646;
const visible = ref<boolean>(false);
const srcData = ref<string | null>(null);
const srcWidth = ref<number>(0);
const srcHeight = ref<number>(0);
const srcLoaded = ref<boolean>(false);
const centerX = ref<number>(0.5);
const centerY = ref<number>(0.5);
const zoom = ref<number>(1);
const filter = ref<Filter>(Filter.None);
const paPos = ref<PAPosition>(PAPosition.BR);
const paScale = ref<number>(0.2);
const paMargin = ref<number>(0.05);
const targetSize = computed<number>(() => {
if (!srcWidth.value || !srcHeight.value) {
return 100;
}
return Math.min(srcWidth.value, srcHeight.value);
});
const input = useTemplateRef<HTMLInputElement>("input");
const image = useTemplateRef<HTMLImageElement>("image");
const parentalAdvisory = useTemplateRef<HTMLImageElement>("parentalAdvisory");
const canvas = useTemplateRef<HTMLCanvasElement>("canvas");
function openImage() {
if (!input.value?.files?.length) {
return;
}
const reader = new FileReader();
reader.onload = () => {
image.value.src = reader.result as string;
};
reader.readAsDataURL(input.value.files[0]);
}
function imageOnLoad() {
srcWidth.value = image.value?.width ?? 0;
srcHeight.value = image.value?.height ?? 0;
if (!srcWidth.value || !srcHeight.value) {
return;
}
srcLoaded.value = true;
setTimeout(draw);
}
function randomElement(items) {
return items[Math.floor(Math.random() * items.length)];
}
function randomize() {
centerX.value = Math.random();
centerY.value = Math.random();
zoom.value = 1 + Math.random() * 3;
filter.value = randomElement(Object.values(Filter));
paPos.value = randomElement(Object.values(PAPosition));
paScale.value = 0.1 + Math.random() * 0.2;
paMargin.value = Math.random() * 0.1;
draw();
}
function reset() {
centerX.value = 0.5;
centerY.value = 0.5;
zoom.value = 1;
filter.value = Filter.None;
paPos.value = PAPosition.BR;
paScale.value = 0.2;
paMargin.value = 0.05;
draw();
}
function newImage() {
srcLoaded.value = false;
reset();
}
function download() {
const link = document.createElement("a");
link.download = "coverify.png";
link.href = canvas.value.toDataURL();
link.click();
}
function applyFilter(f: Filter, r: number, g: number, b: number) {
switch (f) {
case Filter.Gray:
const gray = 0.21 * r + 0.72 * g + 0.07 * b;
return [gray, gray, gray];
case Filter.Invert:
return [255 - r, 255 - g, 255 - b];
case Filter.Sepia:
return [
Math.min(255, Math.round(r * 0.393 + g * 0.769 + b * 0.189)),
Math.min(255, Math.round(r * 0.349 + g * 0.686 + b * 0.168)),
Math.min(255, Math.round(r * 0.272 + g * 0.534 + b * 0.131)),
];
}
return [r, g, b];
}
function draw() {
const ctx = canvas.value?.getContext("2d");
if (!ctx) {
return;
}
canvas.value.width = targetSize.value;
canvas.value.height = targetSize.value;
ctx.clearRect(0, 0, targetSize.value, targetSize.value);
const imgWidth = srcWidth.value * zoom.value;
const imgHeight = srcHeight.value * zoom.value;
const dx = (targetSize.value - imgWidth) * centerX.value;
const dy = (targetSize.value - imgHeight) * centerY.value;
ctx.drawImage(image.value, dx, dy, imgWidth, imgHeight);
const imageData = ctx.getImageData(
0,
0,
targetSize.value,
targetSize.value,
);
const pixelData = imageData.data;
for (let i = 0; i < pixelData.length; i += 4) {
const r1 = pixelData[i];
const g1 = pixelData[i + 1];
const b1 = pixelData[i + 2];
const [r2, g2, b2] = applyFilter(filter.value, r1, g1, b1);
pixelData[i] = r2;
pixelData[i + 1] = g2;
pixelData[i + 2] = b2;
pixelData[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
if (paPos.value !== PAPosition.None) {
const paWidth = targetSize.value * paScale.value;
const paHeight = paWidth * PA_RATIO;
let padx = paMargin.value * targetSize.value;
let pady = paMargin.value * targetSize.value;
if (
paPos.value === PAPosition.BC ||
paPos.value === PAPosition.BL ||
paPos.value === PAPosition.BR
) {
pady = targetSize.value - paHeight - pady;
}
if (paPos.value === PAPosition.BR || paPos.value === PAPosition.TR) {
padx = targetSize.value - paWidth - padx;
} else if (
paPos.value === PAPosition.BC ||
paPos.value === PAPosition.TC
) {
padx = (targetSize.value - paWidth) * 0.5;
}
ctx.drawImage(parentalAdvisory.value, padx, pady, paWidth, paHeight);
}
}
onMounted(() => {
setTimeout(() => {
@@ -17,6 +196,159 @@ onMounted(() => {
<LucideIcon name="disc-3" />
Coverify
</h1>
<p><i>Create album covers from mundane photos</i></p>
<img
ref="image"
style="display: none"
:src="srcData"
@load="imageOnLoad"
/>
<img
ref="parentalAdvisory"
style="display: none"
src="./parental_advisory.png"
/>
<input
v-if="!srcLoaded"
ref="input"
type="file"
accept="image/*"
@change="openImage"
/>
<canvas
v-show="srcLoaded"
ref="canvas"
:width="targetSize"
:height="targetSize"
></canvas>
<br />
<template v-if="srcLoaded">
<div class="buttons">
<button @click="randomize">
<LucideIcon name="dices" /> Randomize
</button>
<button @click="reset">
<LucideIcon name="rotate-ccw" /> Reset
</button>
<button @click="newImage">
<LucideIcon name="trash-2" /> New picture
</button>
<button @click="download">
<LucideIcon name="arrow-down-to-line" /> Download
</button>
</div>
<br />
<table class="config">
<colgroup>
<col style="width: 25%" />
<col />
<col style="width: 10%" />
</colgroup>
<tr>
<td><label for="center-x">Center X:</label></td>
<td>
<input
id="center-x"
v-model="centerX"
type="range"
min="0"
max="1"
step="0.01"
@input="draw"
/>
</td>
<td>{{ (centerX * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="center-y">Center Y:</label></td>
<td>
<input
id="center-y"
v-model="centerY"
type="range"
min="0"
max="1"
step="0.01"
@input="draw"
/>
</td>
<td>{{ (centerY * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="zoom">Zoom:</label></td>
<td>
<input
id="zoom"
v-model="zoom"
type="range"
min="1"
max="4"
step="0.01"
@input="draw"
/>
</td>
<td>{{ (zoom * 100).toFixed(0) }}%</td>
</tr>
<tr>
<td><label for="filter">Filter:</label></td>
<td>
<select id="filter" v-model="filter" @change="draw">
<option
v-for="value in Object.values(Filter)"
:key="`filter-${value}`"
>
{{ value }}
</option>
</select>
</td>
<td></td>
</tr>
<tr>
<td><label for="pa-pos">Sticker Position:</label></td>
<td>
<select id="pa-pos" v-model="paPos" @change="draw">
<option
v-for="value in Object.values(PAPosition)"
:key="`pa-pos-${value}`"
>
{{ value }}
</option>
</select>
</td>
<td></td>
</tr>
<tr v-if="paPos !== PAPosition.None">
<td><label for="pa-scale">Sticker Scale:</label></td>
<td>
<input
id="pa-scale"
v-model="paScale"
type="range"
min="0.10"
max="0.30"
step="0.01"
@input="draw"
/>
</td>
<td>{{ (paScale * 100).toFixed(0) }}%</td>
</tr>
<tr v-if="paPos !== PAPosition.None">
<td><label for="pa-margin">Sticker Margin:</label></td>
<td>
<input
id="pa-margin"
v-model="paMargin"
type="range"
min="0"
max="0.1"
step="0.01"
@input="draw"
/>
</td>
<td>{{ (paMargin * 100).toFixed(0) }}%</td>
</tr>
</table>
</template>
<br />
<hr />
<small class="footer">
@@ -24,24 +356,36 @@ onMounted(() => {
<a href="https://github.com/klemek" target="_blank">klemek</a>
-
<LucideIcon name="github" />
<a href="https://github.com/klemek/coverify" target="_blank">Repository</a>
<a href="https://github.com/klemek/coverify" target="_blank"
>Repository</a
>
- 2025
</small>
</main>
</template>
<style scoped>
.button {
display: block;
h1 {
margin-top: 0;
}
.buttons {
display: flex;
width: 100%;
}
.buttons > button {
flex: 1;
}
canvas {
width: min(40vh, 100%);
margin: auto;
}
select,
input {
width: 100%;
text-decoration: none;
padding: 1em;
margin-bottom: 0.75em;
border: 1px solid var(--color-primary);
border-radius: 0.5em;
background-color: var(--background);
cursor: pointer;
font-size: 1.333em;
}
.button:hover {
-36
View File
@@ -1,36 +0,0 @@
<script setup lang="ts">
interface Props {
href?: string;
color?: string;
}
defineProps<Props>();
</script>
<template>
<component
:is="href ? 'a' : 'div'"
:class="`button ${color ? 'b-' + color + ' ' + color : ''}`"
>
<slot></slot>
</component>
</template>
<style scoped>
.button {
display: block;
width: 100%;
text-decoration: none;
padding: 1em;
margin-bottom: 0.75em;
border: 1px solid var(--color-primary);
border-radius: 0.5em;
background-color: var(--background);
cursor: pointer;
font-size: 1.333em;
}
.button:hover {
background-color: var(--background-secondary);
}
</style>
+1 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { computed } from "vue";
import * as icons from "lucide-vue-next";
interface Props {
@@ -25,11 +25,6 @@ function kebab2camel(kebab: string): string {
.join("");
}
onMounted(() => {
console.log(kebab2camel(props.name));
});
// @ts-expect-error: cannot infer type of all exported data
const icon = computed(() => icons[kebab2camel(props.name)]);
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB