Work on note component
All checks were successful
/ docker (push) Successful in 1m57s

This commit is contained in:
Melody Becker 2025-07-18 11:27:20 +02:00
parent 369453794b
commit 594f87f240
14 changed files with 226 additions and 43 deletions

Binary file not shown.

View file

@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"lorem-ipsum": "^2.0.8",
"node-vibrant": "^4.0.3", "node-vibrant": "^4.0.3",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"sass": "^1.89.2", "sass": "^1.89.2",

View file

@ -5,21 +5,23 @@ import Header from '@/components/global/Header.vue'
</script> </script>
<template> <template>
<div class="wrapper">
<Header /> <Header />
<RouterView /> <RouterView />
</div>
</template> </template>
<style scoped> <style scoped>
.wrapper {
display: flex;
flex-direction: column;
}
header { header {
line-height: 1.5; line-height: 1.5;
max-height: 100vh; max-height: 100vh;
} }
.logo {
display: block;
margin: 0 auto 2rem;
}
nav { nav {
width: 100%; width: 100%;
font-size: 12px; font-size: 12px;
@ -28,7 +30,7 @@ nav {
} }
nav a.router-link-exact-active { nav a.router-link-exact-active {
color: var(--color-text); color: var(--text);
} }
nav a.router-link-exact-active:hover { nav a.router-link-exact-active:hover {
@ -38,7 +40,7 @@ nav a.router-link-exact-active:hover {
nav a { nav a {
display: inline-block; display: inline-block;
padding: 0 1rem; padding: 0 1rem;
border-left: 1px solid var(--color-border); border-left: 1px solid var(--text);
} }
nav a:first-of-type { nav a:first-of-type {
@ -52,9 +54,6 @@ nav a:first-of-type {
padding-right: calc(var(--section-gap) / 2); padding-right: calc(var(--section-gap) / 2);
} }
.logo {
margin: 0 2rem 0 0;
}
header .wrapper { header .wrapper {
display: flex; display: flex;

View file

@ -25,4 +25,26 @@ svg.black-white {
stroke: var(--text); stroke: var(--text);
} }
.hide {
display: none;
}
/* Copied from: https://stackoverflow.com/a/4407335 */
.no-select {
-webkit-touch-callout: none; /* iOS Safari */ /* Safari */ /* Konqueror HTML */ /* Old versions of Firefox */ /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently
supported by Chrome, Edge, Opera and Firefox */
}
.separator-horizontal {
width: 100%;
border: 1px solid black;
margin-top: 0.2em;
margin-bottom: 0.2em;
}
.filling-spacer {
flex-grow: 1;
}
/*# sourceMappingURL=base.css.map */ /*# sourceMappingURL=base.css.map */

View file

@ -1 +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"} {"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;;;AAGJ;EACE;;;AAGF;AACA;EACE;EACA;AAAA;;;AAIF;EACE;EACA;EACA;EACA;;;AAGF;EACE","file":"base.css"}

View file

@ -30,3 +30,25 @@ svg.black-white {
fill: var(--text); fill: var(--text);
stroke: var(--text); stroke: var(--text);
} }
.hide {
display: none;
}
/* Copied from: https://stackoverflow.com/a/4407335 */
.no-select {
-webkit-touch-callout: none; /* iOS Safari */ /* Safari */ /* Konqueror HTML */ /* Old versions of Firefox */ /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently
supported by Chrome, Edge, Opera and Firefox */
}
.separator-horizontal {
width: 100%;
border: 1px solid black;
margin-top: 0.2em;
margin-bottom: 0.2em;
}
.filling-spacer {
flex-grow: 1;
}

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
// A menu intended for placement at the very beginning of the page.
// It's main purpose is to aid and improve screenreader support and keyboard navigation
// It should only be visible when explicitly focused by the browser
// (as the first element, this should always be the case for keyboard navigation when loading the page)
</script>
<template>
<div class="screenreader-menu-wrapper"></div>
</template>
<style scoped>
.screenreader-menu-wrapper {
display: none;
}
.screenreader-menu-wrapper:focus {
display: block;
}
</style>

View file

@ -1,40 +1,85 @@
<script setup lang="ts"> <script setup lang="ts">
import PreviewHeader from '@/components/user/PreviewHeader.vue' import PreviewHeader from '@/components/user/PreviewHeader.vue'
import type { Note as NoteData } from '@/stores/note.ts' import type { Note as NoteData } from '@/stores/note.ts'
import { ref } from 'vue' import { computed, ref } from 'vue'
import FormattedText from '@/components/util/FormattedText.vue' import FormattedText from '@/components/util/FormattedText.vue'
const props = defineProps<{ const props = defineProps<{
note: NoteData; note: NoteData
}>() }>()
let collapsed = ref<boolean>(true); const MaxNoCollapseLen = 200
const collapsed = ref<boolean>(true)
const collapsable = props.note.rawContent.length > MaxNoCollapseLen
const collapseButtonText = computed(() => {
return collapsed.value ? 'Expand' : 'Collapse'
})
function shortenContent(): string { function shortenContent(): string {
if (!collapsed.value) return props.note.rawContent; if (!collapsed.value) return props.note.rawContent
if (props.note.rawContent.length > 200) { if (props.note.rawContent.length > MaxNoCollapseLen) {
const words = props.note.rawContent.split(' '); const words = props.note.rawContent.split(' ')
let outString = ''; let outString = ''
for (const word of words) { for (const word of words) {
if (outString.length > 200) { if (outString.length > MaxNoCollapseLen) {
return outString + '...'; return outString + '...'
} }
outString += word + ' '; outString += word + ' '
} }
return outString; return outString
} else { } else {
return props.note.rawContent; return props.note.rawContent
} }
} }
function toggleExpansion($el: HTMLElement) {
collapsed.value = !collapsed.value;
if (!collapsed.value) return;
$el.scrollIntoView();
}
</script> </script>
<template> <template>
<!-- Preview for reply here, decide if this component can also serve as such a preview --> <div role="document" class="note">
<PreviewHeader :user="props.note.owner" /> <!-- Preview for reply here, decide if this component can also serve as such a preview -->
<FormattedText :raw-text="shortenContent" :hashtags="props.note.hashtags" :pinged="props.note.pings" /> <PreviewHeader :user="props.note.owner" />
<FormattedText
:raw-text="shortenContent()"
:hashtags="props.note.hashtags"
:pinged="props.note.pings"
/>
<div class="toggle-wrapper" v-if="collapsable">
<div
type="button"
@click="toggleExpansion($el)"
class="collapse-toggle no-select"
role="button"
aria-description="Toggle to expand/collapse the note content. Default is collapsed"
tabindex="0"
>
{{ collapseButtonText }}
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.note {
background-color: var(--secondary);
}
.collapse-toggle {
color: var(--text);
border: var(--text) 1px solid;
border-radius: 1em;
background-color: var(--accent);
padding: 0.2em 0.6em 0.2em 0.6em;
}
.toggle-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
</style> </style>

View file

@ -1,18 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineProps } from "vue" import { defineProps } from 'vue'
import type { User } from '../../stores/userdata' import type { User } from '../../stores/userdata'
import ProfilePicture from '@/components/user/ProfilePicture.vue' import ProfilePicture from '@/components/user/ProfilePicture.vue'
import FormattedText from '@/components/util/FormattedText.vue' import FormattedText from '@/components/util/FormattedText.vue'
const props = defineProps<{
user: User const props = defineProps<{
}>() user: User
}>()
</script> </script>
<template> <template>
<div class="account-header"> <div class="account-header">
<ProfilePicture :username="props.user.username" :media="props.user.profilePicture" /> <div class="pfp-wrapper">
<ProfilePicture
class="pfp"
:size="48"
:username="props.user.username"
:media="props.user.profilePicture"
/>
</div>
<div class="account-info"> <div class="account-info">
<FormattedText :raw-text="props.user.displayName"/> <FormattedText class="displayname" :raw-text="props.user.displayName" />
<p>{{props.user.username}}</p> <p class="username">{{ props.user.username }}</p>
</div>
<div class="filler"/>
<!-- TODO: Distinguish this from buttons.
It is not a button. It is not clickable. It must not look like the clickable things
-->
<div class="server-wrapper">
<p class="server-name">{{props.user.server.name}}</p>
</div> </div>
</div> </div>
</template> </template>
@ -21,10 +36,35 @@ import FormattedText from '@/components/util/FormattedText.vue'
.account-header { .account-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
height: 4em;
background-color: var(--accent);
padding: 0 0.6em 0.6em 0.6em;
} }
.account-header {
.pfp {
margin-right: 1em;
}
.pfp-wrapper {
margin-top: 1em;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-start; align-items: center;
}
.displayname {
font-weight: bold;
}
.username {
margin-top: -0.75em;
font-size: 0.9em;
}
.filler {
flex-grow: 1;
}
.server-name {
border: var(--text) 1px solid;
border-radius: 0.6em;
padding: 0.2em;
} }
</style> </style>

View file

@ -8,6 +8,7 @@ import { vi } from 'vitest'
const props = defineProps<{ const props = defineProps<{
media?: MediaMetadata media?: MediaMetadata
username: string username: string
size: number
}>() }>()
/* async function calcBackgroundColor() { /* async function calcBackgroundColor() {
@ -45,7 +46,7 @@ async function calcBackgroundStyle() {
Get primary color of the image, invert it and set as background for higher contrast in case of transparency 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 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"/> <img :src="props.media.url" :alt="props.media.alt" :height="props.size" :width="props.size" ref="profilePicture"/>
</div> </div>
<div <div
v-else v-else

View file

@ -12,10 +12,17 @@
// Probably something akin to the treeificator. Marshal text into tree first, // Probably something akin to the treeificator. Marshal text into tree first,
// then adjust tree nodes to types that unmarshal into html // then adjust tree nodes to types that unmarshal into html
// Or single pass transform, keeping a stack of the current context // Or single pass transform, keeping a stack of the current context
// If stack isn't empty at the end, log an error to console, complete with required closures in order
//
// IMPORTANT: This function is security critical!
// The content it produces is rendered out directly, with no further protections.
// However, since the only content to parse would be server-produced Linstrom formatted text,
// there should not be much of an issue here (unless the client is directly modified, at which
// point all bets are off the table) (the burden of securing the input is on the server)
function render(): string { function render(): string {
return props.rawText return "<p>"+props.rawText+"</p>"
} }
</script> </script>
<template> <template>
<div v-html="render" /> <div v-html="render()" />
</template> </template>

View file

@ -0,0 +1,7 @@
export interface RemoteServer {
id: string;
name: string;
url: string;
isSelf: boolean;
isDead: boolean;
}

View file

@ -1,4 +1,5 @@
import type { MediaMetadata } from '@/stores/media.ts' import type { MediaMetadata } from '@/stores/media.ts'
import type { RemoteServer } from '@/stores/remoteServer.ts'
export interface User { export interface User {
username: string username: string
@ -7,4 +8,5 @@ export interface User {
profilePicture?: MediaMetadata profilePicture?: MediaMetadata
bannerPicture?: MediaMetadata bannerPicture?: MediaMetadata
backgroundPicture?: MediaMetadata backgroundPicture?: MediaMetadata
server: RemoteServer
} }

View file

@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MediaMetadata } from '@/stores/media.ts' import type { MediaMetadata } from '@/stores/media.ts'
import type { User } from '@/stores/userdata.ts' import type { User } from '@/stores/userdata.ts'
import PreviewHeader from '@/components/user/PreviewHeader.vue' import type { Note as NoteData } from '@/stores/note.ts'
import Note from '@/components/note/Note.vue'
import { loremIpsum } from 'lorem-ipsum'
import type { RemoteServer } from '@/stores/remoteServer.ts'
const sampleMedia = { const sampleMedia = {
url: "https://valco.fi/cdn/shop/files/nettisivu_valkoinen.webp", url: "https://mk.absturztau.be/proxy/avatar.webp?url=https%3A%2F%2Fmisskey-taube.s3.eu-central-1.wasabisys.com%2Ffiles%2F40dc29f1-bb92-4df1-a4b7-011287456cf3.webp&avatar=1",
alt: "valko logo", alt: "valko logo",
blurred: false, blurred: false,
mediaType: "image/webp", mediaType: "image/webp",
@ -13,15 +16,30 @@
remote: false, remote: false,
id: "some-media-api" id: "some-media-api"
} as MediaMetadata; } as MediaMetadata;
const server = {
isDead: false,
isSelf: false,
url: "example.com",
name: "example",
id: "some-server-id"
} as RemoteServer;
const user = { const user = {
username: "valko", username: "valko",
displayName: "valko", displayName: "valko",
profilePicture: sampleMedia profilePicture: sampleMedia,
server: server,
} as User; } as User;
const note = {
rawContent: loremIpsum({count: 100}),
hashtags: [],
owner: user,
pings: [],
id: "some-note-id"
} as NoteData;
</script> </script>
<template> <template>
<PreviewHeader :user="user" /> <Note :note="note" />
</template> </template>
<style scoped> <style scoped>