add wip html parser
This commit is contained in:
parent
364e6a1e2e
commit
882c2471e8
16 changed files with 1082 additions and 1257 deletions
|
@ -13,10 +13,13 @@
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/fira-sans": "^5.0.18",
|
||||||
"@tabler/icons-vue": "^2.44.0",
|
"@tabler/icons-vue": "^2.44.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
"flowbite": "^2.2.1",
|
"flowbite": "^2.2.1",
|
||||||
"flowbite-vue": "^0.1.2",
|
"flowbite-vue": "^0.1.2",
|
||||||
|
"htmlparser2": "^9.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"sass": "^1.69.5",
|
"sass": "^1.69.5",
|
||||||
"swrv": "^1.0.4",
|
"swrv": "^1.0.4",
|
||||||
|
@ -30,6 +33,7 @@
|
||||||
"@tsconfig/node18": "^18.2.2",
|
"@tsconfig/node18": "^18.2.2",
|
||||||
"@types/node": "^18.19.3",
|
"@types/node": "^18.19.3",
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
"@vue/eslint-config-prettier": "^8.0.0",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
"@vue/tsconfig": "^0.5.0",
|
"@vue/tsconfig": "^0.5.0",
|
||||||
|
|
2029
pnpm-lock.yaml
2029
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -1,4 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import "@fontsource/fira-sans/400.css";
|
||||||
|
import "@fontsource/fira-sans/700.css";
|
||||||
|
|
||||||
import { RouterView } from "vue-router";
|
import { RouterView } from "vue-router";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
42
src/components/HTMLContent.tsx
Normal file
42
src/components/HTMLContent.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import * as htmlparser2 from "htmlparser2";
|
||||||
|
|
||||||
|
import type { Mention, Tag } from "@/lib/api/entities/activity";
|
||||||
|
import type { CustomEmoji } from "@/lib/api/entities/custom_emoji";
|
||||||
|
import { Text, type ChildNode, Element } from "domhandler";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
emoji: CustomEmoji[];
|
||||||
|
tags?: Tag[];
|
||||||
|
mentions?: Mention[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
|
||||||
|
export default function HTMLContent(props: Props) {
|
||||||
|
let { content } = props;
|
||||||
|
// emoji are always in their shortcode form
|
||||||
|
props.emoji.forEach((emoji) => {
|
||||||
|
content = content.replace(new RegExp(`:${emoji.shortcode}:`, "g"), customEmojiElement(emoji));
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = htmlparser2.parseDocument(content);
|
||||||
|
const elements = parseElement(dom.children);
|
||||||
|
|
||||||
|
console.log(dom);
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customEmojiElement = (emoji: CustomEmoji) => {
|
||||||
|
const elem = document.createElement("x-emoji");
|
||||||
|
elem.setAttribute("emoji", emoji.shortcode);
|
||||||
|
return elem.outerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseElement(elems: ChildNode[]) {
|
||||||
|
const out = [];
|
||||||
|
for (const elem of elems) {
|
||||||
|
if (elem instanceof Text) out.push(<>{elem.data}</>);
|
||||||
|
}
|
||||||
|
}
|
9
src/components/VulEmoji.vue
Normal file
9
src/components/VulEmoji.vue
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CustomEmoji } from "@/lib/api/entities/custom_emoji";
|
||||||
|
|
||||||
|
defineProps<{ emoji: CustomEmoji }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img class="inline-emoji" :src="emoji.url" :alt="emoji.shortcode" />
|
||||||
|
</template>
|
|
@ -1,71 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
import type Activity from "@/lib/api/entities/activity";
|
import type Activity from "@/lib/api/entities/activity";
|
||||||
import { IconWorld, IconHome, IconLock, IconMail } from "@tabler/icons-vue";
|
import VulStatusInfo from "./VulStatusInfo.vue";
|
||||||
|
import VulStatusContent from "./VulStatusContent.vue";
|
||||||
|
import VulStatusButtons from "./VulStatusButtons.vue";
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
defineProps<{ status: Activity }>();
|
defineProps<{ status: Activity }>();
|
||||||
|
|
||||||
const statusScopeKey = ({ visibility }: Activity) => {
|
|
||||||
switch (visibility) {
|
|
||||||
case "direct":
|
|
||||||
return "status.visibility.directMessage";
|
|
||||||
case "private":
|
|
||||||
return "status.visibility.followersOnly";
|
|
||||||
case "unlisted":
|
|
||||||
return "status.visibility.unlisted";
|
|
||||||
case "public":
|
|
||||||
return "status.visibility.public";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusScopeIcon = ({ visibility }: Activity) => {
|
|
||||||
switch (visibility) {
|
|
||||||
case "direct":
|
|
||||||
return IconMail
|
|
||||||
case "private":
|
|
||||||
return IconLock
|
|
||||||
case "unlisted":
|
|
||||||
return IconHome
|
|
||||||
case "public":
|
|
||||||
return IconWorld
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row">
|
<div class="flex flex-col m-2">
|
||||||
<div class="flex flex-row flex-grow">
|
<VulStatusInfo :status="status" />
|
||||||
<RouterLink :to="`/@${status.account.acct}`">
|
<VulStatusContent :status="status" />
|
||||||
<img
|
<VulStatusButtons :status="status" />
|
||||||
class="rounded-full m-2"
|
|
||||||
:src="status.account.avatar"
|
|
||||||
width="64"
|
|
||||||
:alt="t('status.avatar', { name: status.account.acct })"
|
|
||||||
:title="t('status.avatar', { name: status.account.acct })"
|
|
||||||
/>
|
|
||||||
</RouterLink>
|
|
||||||
<div class="flex flex-col my-1">
|
|
||||||
<h2 className="text-lg font-bold">
|
|
||||||
<RouterLink :to="`/@${status.account.acct}`">
|
|
||||||
{{ status.account.display_name }}
|
|
||||||
</RouterLink>
|
|
||||||
</h2>
|
|
||||||
<h3 className="text-base font-light">
|
|
||||||
<RouterLink :to="`/@${status.account.acct}`"> @{{ status.account.acct }} </RouterLink>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="my-1 flex flex-row">
|
|
||||||
<span>
|
|
||||||
<span :title="t(statusScopeKey(status))" aria-hidden="true">
|
|
||||||
<component :is="statusScopeIcon(status)" />
|
|
||||||
</span>
|
|
||||||
<span class="sr-only">{{ t(statusScopeKey(status)) }}</span>
|
|
||||||
</span>
|
|
||||||
<RouterLink :to="`/@${status.account.acct}/statuses/${status.id}`">
|
|
||||||
{humanizeDuration(time)}
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
34
src/components/status/VulStatusButtons.vue
Normal file
34
src/components/status/VulStatusButtons.vue
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { IconDotsVertical, IconMessage, IconPlus, IconQuote, IconRepeat, IconStar } from "@tabler/icons-vue";
|
||||||
|
import type Activity from "@/lib/api/entities/activity";
|
||||||
|
|
||||||
|
defineProps<{ status: Activity }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<ul class="flex space-x-4">
|
||||||
|
<li>
|
||||||
|
<span v-if="status.replies_count">{{ status.replies_count }}</span>
|
||||||
|
<IconMessage class="inline" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span v-if="status.reblogs_count">{{ status.reblogs_count }}</span>
|
||||||
|
<IconRepeat class="inline" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<IconQuote class="inline" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span v-if="status.favourites_count">{{ status.favourites_count }}</span>
|
||||||
|
<IconStar class="inline" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<IconPlus class="inline" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<IconDotsVertical class="inline" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
36
src/components/status/VulStatusContent.vue
Normal file
36
src/components/status/VulStatusContent.vue
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import type Activity from "@/lib/api/entities/activity";
|
||||||
|
import { FwbButton } from "flowbite-vue";
|
||||||
|
import HTMLContent from "../HTMLContent";
|
||||||
|
|
||||||
|
const { status } = defineProps<{ status: Activity }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isCollapsed = ref(!!status.spoiler_text);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div v-if="status.spoiler_text">
|
||||||
|
<strong v-html="status.spoiler_text"></strong>
|
||||||
|
<FwbButton class="mx-2" color="alternative" size="sm" @click="() => (isCollapsed = !isCollapsed)">
|
||||||
|
{{
|
||||||
|
isCollapsed
|
||||||
|
? t("status.showContent", { count: status.content?.length || 0 })
|
||||||
|
: t("status.hideContent", { count: status.content?.length || 0 })
|
||||||
|
}}
|
||||||
|
</FwbButton>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isCollapsed" v-html="status.content || 'N/A'"></div>
|
||||||
|
<HTMLContent
|
||||||
|
v-if="!isCollapsed && status.content"
|
||||||
|
:content="status.content"
|
||||||
|
:emoji="status.emojis"
|
||||||
|
:tags="status.tags"
|
||||||
|
:mentions="status.mentions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
69
src/components/status/VulStatusInfo.vue
Normal file
69
src/components/status/VulStatusInfo.vue
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import type Activity from "@/lib/api/entities/activity";
|
||||||
|
import { IconWorld, IconHome, IconLock, IconMail } from "@tabler/icons-vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
defineProps<{ status: Activity }>();
|
||||||
|
|
||||||
|
const statusScopeKey = ({ visibility }: Activity) => {
|
||||||
|
switch (visibility) {
|
||||||
|
case "direct":
|
||||||
|
return "status.visibility.directMessage";
|
||||||
|
case "private":
|
||||||
|
return "status.visibility.followersOnly";
|
||||||
|
case "unlisted":
|
||||||
|
return "status.visibility.unlisted";
|
||||||
|
case "public":
|
||||||
|
return "status.visibility.public";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusScopeIcon = ({ visibility }: Activity) => {
|
||||||
|
switch (visibility) {
|
||||||
|
case "direct":
|
||||||
|
return IconMail;
|
||||||
|
case "private":
|
||||||
|
return IconLock;
|
||||||
|
case "unlisted":
|
||||||
|
return IconHome;
|
||||||
|
case "public":
|
||||||
|
return IconWorld;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div class="flex flex-row flex-grow">
|
||||||
|
<RouterLink :to="`/@${status.account.acct}`">
|
||||||
|
<img
|
||||||
|
class="rounded-full m-2"
|
||||||
|
:src="status.account.avatar"
|
||||||
|
width="64"
|
||||||
|
:alt="t('status.avatar', { name: status.account.acct })"
|
||||||
|
:title="t('status.avatar', { name: status.account.acct })"
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
<div class="flex flex-col my-1">
|
||||||
|
<h2 className="text-lg font-bold">
|
||||||
|
<RouterLink :to="`/@${status.account.acct}`">
|
||||||
|
{{ status.account.display_name }}
|
||||||
|
</RouterLink>
|
||||||
|
</h2>
|
||||||
|
<h3 className="text-base font-light">
|
||||||
|
<RouterLink :to="`/@${status.account.acct}`"> @{{ status.account.acct }} </RouterLink>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-1 flex flex-row">
|
||||||
|
<span>
|
||||||
|
<span :title="t(statusScopeKey(status))" aria-hidden="true">
|
||||||
|
<component :is="statusScopeIcon(status)" />
|
||||||
|
</span>
|
||||||
|
<span class="sr-only">{{ t(statusScopeKey(status)) }}</span>
|
||||||
|
</span>
|
||||||
|
<RouterLink :to="`/@${status.account.acct}/statuses/${status.id}`"> {humanizeDuration(time)} </RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Account } from "./account";
|
import type { Account } from "./account";
|
||||||
|
import type { CustomEmoji } from "./custom_emoji";
|
||||||
|
|
||||||
export default interface Activity {
|
export default interface Activity {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -9,6 +10,9 @@ export default interface Activity {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
spoiler_text: string | null;
|
spoiler_text: string | null;
|
||||||
visibility: "public" | "unlisted" | "private" | "direct";
|
visibility: "public" | "unlisted" | "private" | "direct";
|
||||||
|
mentions: Mention[];
|
||||||
|
tags: Tag[];
|
||||||
|
emojis: CustomEmoji[];
|
||||||
|
|
||||||
reblogs_count: number;
|
reblogs_count: number;
|
||||||
favourites_count: number;
|
favourites_count: number;
|
||||||
|
@ -23,3 +27,15 @@ export interface ActivityAkkomaSource {
|
||||||
content: string;
|
content: string;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Mention {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
url: string;
|
||||||
|
acct: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
status:
|
status:
|
||||||
avatar: "{name}'s mugshot"
|
avatar: "{name}'s mugshot"
|
||||||
characterCount: "{count} character | {count} characters"
|
characterCount: "{count} character | {count} characters"
|
||||||
showContent: "Show treasure ({@:status.characterCount})"
|
showContent: "Show treasure (@:status.characterCount)"
|
||||||
hideContent: "Hide treasure ({@:status.characterCount})"
|
hideContent: "Hide treasure (@:status.characterCount)"
|
||||||
visibility:
|
visibility:
|
||||||
directMessage: Direct bottle
|
directMessage: Direct bottle
|
||||||
followersOnly: Only shown to their mateys
|
followersOnly: Only shown to their mateys
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
status:
|
status:
|
||||||
avatar: "{name}'s avatar"
|
avatar: "{name}'s avatar"
|
||||||
characterCount: "{count} character | {count} characters"
|
characterCount: "{count} character | {count} characters"
|
||||||
showContent: "Show content ({@:status.characterCount})"
|
showContent: "Show content (@:status.characterCount)"
|
||||||
hideContent: "Hide content ({@:status.characterCount})"
|
hideContent: "Hide content (@:status.characterCount)"
|
||||||
visibility:
|
visibility:
|
||||||
directMessage: Direct message
|
directMessage: Direct message
|
||||||
followersOnly: Followers only
|
followersOnly: Followers only
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { theme } from "tailwindcss/defaultConfig";
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
|
@ -8,7 +10,11 @@ export default {
|
||||||
"node_modules/flowbite/**/*.{js,jsx,ts,tsx}",
|
"node_modules/flowbite/**/*.{js,jsx,ts,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Fira Sans"', ...theme.fontFamily.sans],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [import("flowbite/plugin")],
|
plugins: [import("flowbite/plugin")],
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"jsxImportSource": "vue",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
|
|
|
@ -1,12 +1,6 @@
|
||||||
{
|
{
|
||||||
"extends": "@tsconfig/node18/tsconfig.json",
|
"extends": "@tsconfig/node18/tsconfig.json",
|
||||||
"include": [
|
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"],
|
||||||
"vite.config.*",
|
|
||||||
"vitest.config.*",
|
|
||||||
"cypress.config.*",
|
|
||||||
"nightwatch.conf.*",
|
|
||||||
"playwright.config.*"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
import { loadEnv, defineConfig } from "vite";
|
import { loadEnv, defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||||
import yaml from "@modyfi/vite-plugin-yaml";
|
import yaml from "@modyfi/vite-plugin-yaml";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
|
@ -9,7 +10,7 @@ export default defineConfig(({ mode }) => {
|
||||||
const { INSTANCE } = loadEnv(mode, process.cwd(), "");
|
const { INSTANCE } = loadEnv(mode, process.cwd(), "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [vue(), yaml()],
|
plugins: [vue(), vueJsx(), yaml()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
|
Loading…
Reference in a new issue