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": {
"jdenticon": "^3.3.0",
"lorem-ipsum": "^2.0.8",
"node-vibrant": "^4.0.3",
"pinia": "^3.0.3",
"sass": "^1.89.2",

View file

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

View file

@ -25,4 +25,26 @@ svg.black-white {
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 */

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);
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">
import PreviewHeader from '@/components/user/PreviewHeader.vue'
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'
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 {
if (!collapsed.value) return props.note.rawContent;
if (props.note.rawContent.length > 200) {
const words = props.note.rawContent.split(' ');
let outString = '';
if (!collapsed.value) return props.note.rawContent
if (props.note.rawContent.length > MaxNoCollapseLen) {
const words = props.note.rawContent.split(' ')
let outString = ''
for (const word of words) {
if (outString.length > 200) {
return outString + '...';
if (outString.length > MaxNoCollapseLen) {
return outString + '...'
}
outString += word + ' ';
outString += word + ' '
}
return outString;
return outString
} else {
return props.note.rawContent;
return props.note.rawContent
}
}
function toggleExpansion($el: HTMLElement) {
collapsed.value = !collapsed.value;
if (!collapsed.value) return;
$el.scrollIntoView();
}
</script>
<template>
<div role="document" class="note">
<!-- Preview for reply here, decide if this component can also serve as such a preview -->
<PreviewHeader :user="props.note.owner" />
<FormattedText :raw-text="shortenContent" :hashtags="props.note.hashtags" :pinged="props.note.pings" />
<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>
<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>

View file

@ -1,18 +1,33 @@
<script setup lang="ts">
import { defineProps } from "vue"
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<{
const props = defineProps<{
user: User
}>()
}>()
</script>
<template>
<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">
<FormattedText :raw-text="props.user.displayName"/>
<p>{{props.user.username}}</p>
<FormattedText class="displayname" :raw-text="props.user.displayName" />
<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>
</template>
@ -21,10 +36,35 @@ import FormattedText from '@/components/util/FormattedText.vue'
.account-header {
display: flex;
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;
flex-direction: column;
align-items: flex-start;
flex-direction: row;
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>

View file

@ -8,6 +8,7 @@ import { vi } from 'vitest'
const props = defineProps<{
media?: MediaMetadata
username: string
size: number
}>()
/* 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
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
v-else

View file

@ -12,10 +12,17 @@
// Probably something akin to the treeificator. Marshal text into tree first,
// then adjust tree nodes to types that unmarshal into html
// 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 {
return props.rawText
return "<p>"+props.rawText+"</p>"
}
</script>
<template>
<div v-html="render" />
<div v-html="render()" />
</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 { RemoteServer } from '@/stores/remoteServer.ts'
export interface User {
username: string
@ -7,4 +8,5 @@ export interface User {
profilePicture?: MediaMetadata
bannerPicture?: MediaMetadata
backgroundPicture?: MediaMetadata
server: RemoteServer
}

View file

@ -1,10 +1,13 @@
<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'
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 = {
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",
blurred: false,
mediaType: "image/webp",
@ -13,15 +16,30 @@
remote: false,
id: "some-media-api"
} as MediaMetadata;
const server = {
isDead: false,
isSelf: false,
url: "example.com",
name: "example",
id: "some-server-id"
} as RemoteServer;
const user = {
username: "valko",
displayName: "valko",
profilePicture: sampleMedia
profilePicture: sampleMedia,
server: server,
} as User;
const note = {
rawContent: loremIpsum({count: 100}),
hashtags: [],
owner: user,
pings: [],
id: "some-note-id"
} as NoteData;
</script>
<template>
<PreviewHeader :user="user" />
<Note :note="note" />
</template>
<style scoped>