Merge branch 'main' of git.mstar.dev:mstar/linstrom
All checks were successful
/ docker (push) Successful in 1m54s

This commit is contained in:
Melody Becker 2025-07-11 10:13:06 +02:00
commit a3b3b73dab
19 changed files with 760 additions and 39 deletions

Binary file not shown.

View file

@ -14,7 +14,11 @@
"format": "prettier --write src/"
},
"dependencies": {
"jdenticon": "^3.3.0",
"node-vibrant": "^4.0.3",
"pinia": "^3.0.3",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
},
@ -31,6 +35,7 @@
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.29.0",
"eslint-plugin-vue": "~10.2.0",
"fibers": "^5.0.3",
"jiti": "^2.4.2",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",

View file

@ -1,22 +1,11 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import Header from '@/components/global/Header.vue'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<Header />
<RouterView />
</template>

View file

@ -1,18 +1,28 @@
@media (prefers-color-scheme: light) {
:root {
--text: #0d0f0b;
--background: #f6f7f3;
--primary: #4a5733;
--secondary: #b5c898;
--accent: #7c9b4b;
}
:root {
--text: #0d0f0b;
--background: #f6f7f3;
--primary: #4a5733;
--secondary: #b5c898;
--accent: #7c9b4b;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #f2f4f0;
--background: #0b0c08;
--primary: #bfcca8;
--secondary: #546737;
--accent: #96b464;
}
:root {
--text: #f2f4f0;
--background: #0b0c08;
--primary: #bfcca8;
--secondary: #546737;
--accent: #96b464;
}
}
:root {
--border: var(--text);
}
svg.black-white {
fill: var(--text);
stroke: var(--text);
}
/*# sourceMappingURL=base.css.map */

View file

@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["base.scss"],"names":[],"mappings":"AAGA;EACC;IACC;IACA;IACA;IACA;IACA;;;AAIF;EACC;IACC;IACA;IACA;IACA;IACA;;;AAKF;EACE;;;AAGF;EACI;EACA","file":"base.css"}

View file

@ -0,0 +1,32 @@
@use "sass:color";
// Base colors here
@media (prefers-color-scheme: light) {
:root {
--text: #0d0f0b;
--background: #f6f7f3;
--primary: #4a5733;
--secondary: #b5c898;
--accent: #7c9b4b;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #f2f4f0;
--background: #0b0c08;
--primary: #bfcca8;
--secondary: #546737;
--accent: #96b464;
}
}
// And calculate the rest here
:root {
--border: var(--text);
}
svg.black-white {
fill: var(--text);
stroke: var(--text);
}

View file

@ -1,4 +1,12 @@
@import './base.css';
@import 'base.scss';
body {
background-color: var(--background);
}
p, h1, h2, h3, h4, h5, h6 {
color: var(--text);
}
#app {
max-width: 1280px;
@ -10,7 +18,7 @@
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
color: var(--accent);
transition: 0.4s;
padding: 3px;
}

View file

@ -21,7 +21,8 @@
.details {
flex: 1;
margin-left: 1rem;
margin-left: 2rem;
color: var(--text);
}
i {
@ -31,28 +32,28 @@ i {
width: 32px;
height: 32px;
color: var(--color-text);
color: var(--text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
color: var(--text);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
padding: 0.4rem 0 1rem;
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border: 1px solid var(--text);
background: var(--background);
border-radius: 8px;
width: 50px;
height: 50px;
@ -60,7 +61,7 @@ h3 {
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
border-left: 1px solid var(--text);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
@ -69,7 +70,7 @@ h3 {
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
border-left: 1px solid var(--text);
position: absolute;
left: 0;
top: calc(50% + 25px);

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<template>
<header>
<div class="links">
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
<RouterLink to="/testing/note">Test note</RouterLink>
</nav>
</div>
</header>
</template>
<style scoped lang="scss">
.links {
border-radius: 1em;
border: 0.2em solid var(--border);
}
nav a.router-link-exact-active {
color: var(--text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--border);
}
nav a:first-of-type {
border: 0;
}
</style>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { defineProps } from "vue"
import type { User } from '../../stores/userdata'
import ProfilePicture from '@/components/user/ProfilePicture.vue'
import FormattedText from '@/components/util/FormattedText.vue'
const props = defineProps<{
user: User
}>()
</script>
<template>
<div class="account-header">
<ProfilePicture :username="props.user.username" :media="props.user.profilePicture" />
<div class="account-info">
<FormattedText :raw-text="props.user.displayName"/>
<p>{{props.user.username}}</p>
</div>
</div>
</template>
<style scoped>
.account-header {
display: flex;
flex-direction: row;
}
.account-header {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>

View file

@ -0,0 +1,57 @@
<script setup lang="ts">
import { toSvg} from 'jdenticon'
import type { MediaMetadata } from '@/stores/media.ts'
import { Vibrant } from 'node-vibrant/node'
import { useTemplateRef } from 'vue'
import { vi } from 'vitest'
const props = defineProps<{
media?: MediaMetadata
username: string
}>()
/* async function calcBackgroundColor() {
const imgWrapper = useTemplateRef<HTMLImageElement>("profilePicture");
if (!imgWrapper) return;
const img = imgWrapper.value;
if (!img) return null;
const vibrant = Vibrant.from(img).build();
const palette = await vibrant.getPalette();
if (!palette.Vibrant) return;
const r = (255 - palette.Vibrant.r).toString(16),
g = (255 - palette.Vibrant.g).toString(16),
b = (255 - palette.Vibrant.b).toString(16);
// pad each with zeros and return
return '#' + padZero(r) + padZero(g) + padZero(b);
}
function padZero(str: string, len?: number): string {
len = len || 2;
const zeros = new Array(len).join('0');
return (zeros + str).slice(-len);
}
async function calcBackgroundStyle() {
const color = await calcBackgroundColor();
if (!color) return "";
return "background-color: "+color;
}
*/
</script>
<template>
<div v-if="props.media" aria-label="profile picture">
<!-- TODO: Inverted primary color of image
Get primary color of the image, invert it and set as background for higher contrast in case of transparency
calcBackgroundStyle *should* do it, but async and vue no like
-->
<img :src="props.media.url" :alt="props.media.alt" height="52" width="52" ref="profilePicture"/>
</div>
<div
v-else
aria-label="profile picture"
aria-description="No profile picture set, this is a generated identicon"
>
<div v-html="toSvg(props.username, 128)" aria-hidden="true"/>
</div>
</template>

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
import { defineProps } from "vue"
const props = defineProps<{
rawText: string // Raw text (already pre-transformed by server into Linstrom format)
}>()
@ -7,4 +6,6 @@ import { defineProps } from "vue"
// Probably something akin to the treeificator. Marshal text into tree first,
// then adjust tree nodes to types that unmarshal into html
</script>
<p>{{props.rawText}}</p>
<template>
<p>{{props.rawText}}</p>
</template>

View file

@ -17,6 +17,11 @@ const router = createRouter({
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
},
{
path: '/testing/note',
name: 'note-testing',
component: () => import('../views/NoteTestView.vue'),
},
],
})

View file

@ -0,0 +1,10 @@
export interface MediaMetadata {
id: string;
ownedBy: string;
remote: boolean;
url: string;
mediaType: string;
name: string;
alt: string;
blurred: boolean;
}

View file

@ -0,0 +1,10 @@
import type { MediaMetadata } from '@/stores/media.ts'
export interface User {
username: string
displayName: string
description: string
profilePicture?: MediaMetadata
bannerPicture?: MediaMetadata
backgroundPicture?: MediaMetadata
}

View file

@ -0,0 +1,492 @@
class Crusher {
hash(s: any): number {
return String(s).split("").reduce(function(a, b) {
a = ((a << 5) - a) + b.charCodeAt(0);
return a & a
}, 0);
}
isDOMElement(o:any): boolean {
return (
typeof HTMLElement === "object" ? o instanceof HTMLElement :
o && typeof o === "object" && true && o.nodeType === 1 && typeof o.nodeName==="string"
);
}
}
enum BlockType {
ONE = 1,
TWO = 2,
THREE = 3,
}
class Block {
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
type: BlockType;
primary: string;
accent: string;
cellSize: number;
margin: number;
scale: number;
pos: Position;
hash: number;
constructor(
c: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
type: BlockType,
primary: string,
accent: string,
hash: number,
cellSize: number,
margin: number,
scale: number,
pos: Position,
) {
this.canvas = c;
this.context = ctx;
this.type = type;
this.primary = primary;
this.accent = accent;
this.hash = hash;
this.cellSize = cellSize;
this.margin = margin;
this.scale = scale;
this.pos = pos;
}
offset() {
this.context.save();
this.context.translate(0.6 * this.scale, -0.6 * this.scale);
}
resetOffset() {
this.context.restore();
}
makePath(hash: number, offset: number) {
const mod = Math.abs(hash + offset) % 4;
switch(mod) {
case 0:
// top
this.context.beginPath();
this.context.moveTo(this.pos.x, this.pos.y);
this.context.lineTo(this.pos.x + this.cellSize, this.pos.y);
this.context.lineTo(this.pos.x + this.cellSize, this.pos.y + this.cellSize);
this.context.closePath();
break;
case 1:
// right
this.context.beginPath();
this.context.moveTo(this.pos.x + this.cellSize, this.pos.y);
this.context.lineTo(this.pos.x + this.cellSize, this.pos.y + this.cellSize);
this.context.lineTo(this.pos.x, this.pos.y + this.cellSize);
this.context.closePath();
break;
case 2:
// bottom
this.context.beginPath();
this.context.moveTo(this.pos.x, this.pos.y);
this.context.lineTo(this.pos.x, this.pos.y + this.cellSize);
this.context.lineTo(this.pos.x + this.cellSize, this.pos.y + this.cellSize);
this.context.closePath();
break;
case 3:
// left
this.context.beginPath();
this.context.moveTo(this.pos.x, this.pos.y);
this.context.lineTo(this.pos.x + this.cellSize, this.pos.y);
this.context.lineTo(this.pos.x, this.pos.y + this.cellSize);
this.context.closePath();
break;
default:
// top
this.context.beginPath();
this.context.moveTo(this.pos.x, this.pos.y);
this.context.lineTo(this.pos.x + this.cellSize, this.pos.y);
this.context.lineTo(this.pos.x, this.pos.y + this.cellSize);
this.context.closePath();
}
}
draw() {
this.offset();
if (this.type === BlockType.ONE) {
this.makePath(this.hash, this.hash % 3);
this.context.fillStyle = this.primary;
this.context.fill();
this.makePath(this.hash, this.hash % 5);
this.context.fillStyle = this.accent;
this.context.fill();
} else if (this.type === BlockType.TWO) {
this.makePath(this.hash, this.hash % 4);
this.context.fillStyle = this.accent;
this.context.fill();
this.makePath(this.hash, this.hash % 3);
this.context.fillStyle = this.primary;
this.context.fill();
} else {
this.makePath(this.hash, this.hash % 7);
this.context.fillStyle = this.accent;
this.context.fill();
this.makePath(this.hash, this.hash % 8);
this.context.fillStyle = this.primary;
this.context.fill();
}
this.resetOffset();
}
}
class Position {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
class Shape {
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
hash: number;
primary: string;
accent: string;
pos: Position;
scale: number;
cellSize: number;
strokeColor: string;
constructor(
c: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
hash: number,
primary: string,
accent: string,
pos: Position,
scale: number,
cellSize: number,
strokeColor: string
) {
this.canvas = c;
this.context = ctx;
this.hash = hash;
this.primary = primary;
this.accent = accent;
this.pos = pos;
this.scale = scale;
this.cellSize = cellSize;
this.strokeColor = strokeColor;
}
getColor() {
return [this.primary, this.accent][Math.abs(this.hash % 2)];
}
makePath() {
const mod = Math.abs(this.hash + 1) % 4;
switch(mod) {
case 0:
// square
this.context.beginPath();
this.context.moveTo(this.pos.x, this.pos.y);
this.context.lineTo(this.pos.x + (this.cellSize / 2), this.pos.y);
this.context.lineTo(this.pos.x + (this.cellSize / 2), this.pos.y - (this.cellSize / 2));
this.context.lineTo(this.pos.x, this.pos.y - (this.cellSize / 2));
this.context.closePath();
break;
case 1:
//circle
this.context.beginPath();
this.context.arc(
this.pos.x + (this.cellSize / Math.PI) - 5,
this.pos.y - (this.cellSize / Math.PI) + 5,
this.cellSize / 3,
0,
Math.PI * 2,
true
);
break;
case 2:
// triangle
this.context.beginPath();
this.context.moveTo(this.pos.x, this.pos.y);
this.context.lineTo(this.pos.x + (this.cellSize * 0.65), this.pos.y);
this.context.lineTo(this.pos.x, this.pos.y - (this.cellSize * 0.65));
this.context.closePath();
break;
case 3:
// oval
this.context.beginPath();
this.context.moveTo(this.pos.x - (this.cellSize * 0.2), this.pos.y + (this.cellSize * 0.2));
this.context.quadraticCurveTo(this.pos.x + (this.cellSize * 0.4), this.pos.y, this.pos.x + (this.cellSize * 0.5), this.pos.y - (this.cellSize * 0.5));
this.context.moveTo(this.pos.x + (this.cellSize * 0.5), this.pos.y - (this.cellSize * 0.5));
this.context.quadraticCurveTo(this.pos.x , this.pos.y - (this.cellSize * 0.4), this.pos.x - (this.cellSize * 0.2), this.pos.y + (this.cellSize * 0.2));
break;
default:
//square
this.context.beginPath();
this.context.moveTo(this.pos.x, this.pos.y);
this.context.lineTo(this.pos.x + (this.cellSize / 2), this.pos.y);
this.context.lineTo(this.pos.x + (this.cellSize / 2), this.pos.y - (this.cellSize / 2));
this.context.lineTo(this.pos.x, this.pos.y - (this.cellSize / 2));
this.context.closePath();
}
}
draw(hasStroke: boolean, strokeWeight: number) {
const color = this.getColor();
this.context.globalCompositeOperation = "source-over";
this.makePath();
this.context.fillStyle = color;
this.context.strokeStyle = this.strokeColor;
this.context.lineWidth = this.scale * ((4/5 * strokeWeight) / this.canvas.width);
this.context.lineJoin = "round";
this.context.lineCap = "round";
this.context.fill();
if (hasStroke) {
this.context.stroke();
}
}
}
/**
* @name IdentiHeart
* @author Schlipak
* @copyright Apache license 2015 Guillaume de Matos
*/
export class Identiheart {
canvas: HTMLCanvasElement
context: CanvasRenderingContext2D
margin: number
crusher: Crusher
palette = [
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3',
'#03A9F4', '#00BCD4', '#009688', '#4CAF50', '#8BC34A', '#CDDC39',
'#FFEB3B', '#FFC107', '#FF9800', '#FF5722', '#795548', '#607D8B'
];
primary: string = "#F44336";
accent: string = "#E91E63";
scale: number;
cellSize: number;
hash: number = 0;
blocks: Block[] = [];
shape?: Shape;
hasStroke: boolean = true;
strokeWeight = 500;
strokeColor = "#000000";
compositeOperation: GlobalCompositeOperation = 'multiply';
constructor(c: HTMLCanvasElement, margin?: number, scale?: number, ctx?: CanvasRenderingContext2D) {
this.canvas = c;
this.context = ctx ?? c.getContext("2d")!;
this.margin = margin || 5;
this.scale = scale || 20;
this.crusher = new Crusher();
this.cellSize = (this.canvas.width / 2) - (this.margin * this.scale);
}
setUsername(username: string) {
this.hash = this.crusher.hash(username);
return this;
}
setPalette(palette:string[]) {
if (palette.length < 2) {
throw new Error('Palette must be more than two elements long');
}
this.palette = palette;
return this;
}
setHasStroke(b: boolean) {
this.hasStroke = b;
return this;
}
setStrokeWeight(weight: number) {
this.strokeWeight = weight;
return this;
}
setStrokeColor(color: string) {
this.strokeColor = color;
return this;
}
setCompositeOperation(operation: GlobalCompositeOperation) {
this.compositeOperation = operation;
return this;
}
setCanvas(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.context = canvas.getContext('2d')!;
return this;
}
init() {
// Purge the block array
this.blocks = new Array<Block>();
// Purge the shape
this.shape = undefined;
// Generate colors
const crusher = new Crusher();
const subHash = crusher.hash(this.hash);
this.accent = this.palette[Math.abs(subHash % this.palette.length)];
// Clear the canvas
this.context.globalCompositeOperation = "source-over";
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.globalCompositeOperation = this.compositeOperation;
return this;
}
draw() {
this.init();
// Rotate the canvas -45deg
this.context.save();
this.context.translate(this.canvas.width/2, this.canvas.height/2);
this.context.rotate(- Math.PI / 4);
this.context.translate(-this.canvas.width/2, -this.canvas.height/2);
this.generateBlocks();
this.drawBlocks();
if (this.hasStroke) {
this.drawOutline();
}
this.shape = new Shape(this.canvas, this.context, this.hash, this.primary, this.accent, {
x: (this.margin * this.scale) + 1.5 * this.cellSize,
y: (this.margin * this.scale) + 0.5 * this.cellSize
}, this.scale, this.cellSize, this.strokeColor);
this.shape.draw(this.hasStroke, this.strokeWeight);
// Restore the original matrix
this.context.restore();
return this;
}
offset() {
this.context.save();
this.context.translate(0.6 * this.scale, - 0.6 * this.scale);
}
resetOffset() {
this.context.restore();
}
drawOutline() {
this.offset();
this.context.globalCompositeOperation = "source-over";
// Outer lines
this.context.beginPath();
this.context.moveTo(this.margin * this.scale, this.margin * this.scale);
this.context.lineTo(this.margin * this.scale, this.canvas.height - (this.margin * this.scale));
this.context.lineTo(this.canvas.width - (this.margin * this.scale), this.canvas.height - (this.margin * this.scale));
this.context.lineTo(this.canvas.width - (this.margin * this.scale), this.canvas.height / 2);
this.context.lineTo(this.canvas.width / 2, this.canvas.height / 2);
this.context.lineTo(this.canvas.width / 2, this.margin * this.scale);
this.context.closePath();
this.context.strokeStyle = this.strokeColor;
this.context.lineWidth = this.scale * (this.strokeWeight / this.canvas.width);
this.context.lineJoin = "round";
this.context.lineCap = "round";
this.context.stroke();
// Inner lines
this.context.beginPath();
this.context.moveTo(this.canvas.width / 2, this.canvas.height / 2);
this.context.lineTo(this.margin * this.scale, this.canvas.height / 2);
this.context.moveTo(this.canvas.width / 2, this.canvas.height / 2);
this.context.lineTo(this.canvas.width / 2, this.canvas.height - (this.margin * this.scale));
this.context.stroke();
this.resetOffset();
this.context.globalCompositeOperation = this.compositeOperation;
}
generateBlocks() {
/*
c: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
type: BlockType,
primary: string,
accent: string,
hash: number,
cellSize: number,
margin: number,
scale: number,
pos: Position,
* */
let b1 = new Block(
this.canvas,
this.context,
BlockType.ONE,
this.primary,
this.accent,
this.hash,
this.cellSize,
this.margin,
this.scale,
new Position(this.margin * this.scale,this.margin * this.scale));
this.blocks.push(b1);
let b2 = new Block(
this.canvas,
this.context,
BlockType.TWO,
this.primary, this.accent,
this.hash,
this.cellSize,
this.margin,
this.scale,
new Position(this.margin * this.scale,this.canvas.height / 2)
);
this.blocks.push(b2);
let b3 = new Block(
this.canvas,
this.context,
BlockType.THREE,
this.primary,
this.accent,
this.hash,
this.cellSize,
this.margin,
this.scale,
new Position(this.canvas.width / 2, this.canvas.height / 2)
);
this.blocks.push(b3);
}
drawBlocks() {
if (this.blocks.length == 0) {
return false;
}
for (var i = 0; i < this.blocks.length; i++) {
this.blocks[i].draw();
}
}
}

View file

@ -10,6 +10,7 @@
min-height: 100vh;
display: flex;
align-items: center;
color: var(--text);
}
}
</style>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { MediaMetadata } from '@/stores/media.ts'
import type { User } from '@/stores/userdata.ts'
import PreviewHeader from '@/components/user/PreviewHeader.vue'
const sampleMedia = {
url: "https://valco.fi/cdn/shop/files/nettisivu_valkoinen.webp",
alt: "valko logo",
blurred: false,
mediaType: "image/webp",
name: "valko-logo.webp",
ownedBy: "some-user-id",
remote: false,
id: "some-media-api"
} as MediaMetadata;
const user = {
username: "valko",
displayName: "valko",
profilePicture: sampleMedia
} as User;
</script>
<template>
<PreviewHeader :user="user" />
</template>
<style scoped>
</style>