mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add emoji theme picker
This commit is contained in:
parent
ac4acd31cb
commit
6b6d1d44d2
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -23,3 +23,5 @@ logs
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
config
|
config
|
||||||
|
|
||||||
|
public/emojis
|
||||||
|
|
@ -12,6 +12,7 @@ FROM base AS builder
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
COPY --from=install /temp/dev/node_modules /app/node_modules
|
COPY --from=install /temp/dev/node_modules /app/node_modules
|
||||||
|
RUN cd /app && bun run emojis:generate
|
||||||
RUN cd /app && bun run build --preset node-server
|
RUN cd /app && bun run build --preset node-server
|
||||||
|
|
||||||
FROM oven/bun:1.1.34-alpine AS final
|
FROM oven/bun:1.1.34-alpine AS final
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,10 @@ services:
|
||||||
|
|
||||||
Then, the frontend will be available at `http://localhost:3000` inside the container. To link it to a Versia Server, set the `NUXT_PUBLIC_API_HOST` environment variable to the server's URL.
|
Then, the frontend will be available at `http://localhost:3000` inside the container. To link it to a Versia Server, set the `NUXT_PUBLIC_API_HOST` environment variable to the server's URL.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Make sure to run `bun run emojis:generate` to generate the emoji list before building or running the project.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the AGPL 3.0 - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the AGPL 3.0 - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<textarea v-model="content"
|
<textarea v-model="content"
|
||||||
class="resize-none min-h-48 mt-1 prose prose-invert max-w-full ring-1 ring-white/20 font-mono placeholder:text-zinc-500 bg-transparent rounded appearance-none disabled:cursor-not-allowed"
|
class="resize-none min-h-48 mt-1 prose prose-invert max-w-full ring-1 ring-white/20 font-mono placeholder:text-zinc-500 bg-transparent rounded appearance-none disabled:cursor-not-allowed"
|
||||||
aria-label="Start typing here..."></textarea>
|
aria-label="Start typing here..."></textarea>
|
||||||
|
<p v-if="setting.description" class="text-xs mt-2 text-gray-400">{{ setting.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@
|
||||||
</Select.Indicator>
|
</Select.Indicator>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
</Select.Control>
|
</Select.Control>
|
||||||
<p v-if="setting.notImplemented" class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not
|
<p v-if="setting.notImplemented" class="text-xs mt-2 row-start-3 text-red-300 font-semibold">Not
|
||||||
implemented
|
implemented
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else-if="setting.description" class="text-xs mt-2 text-gray-400">{{ setting.description }}</p>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Select.Positioner>
|
<Select.Positioner>
|
||||||
<Select.Content
|
<Select.Content
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<Tabs.Root v-model="tab">
|
<Tabs.Root v-model="tab">
|
||||||
<Tabs.List class="flex flex-row p-4 gap-4 bg-dark-800 relative ring-1 ring-white/5 overflow-x-auto">
|
<Tabs.List class="flex flex-row p-4 gap-4 bg-dark-800 relative ring-1 ring-white/5 overflow-x-auto">
|
||||||
<Tabs.Trigger :value="page" v-for="page of SettingPages" :as-child="true">
|
<Tabs.Trigger :value="page" v-for="page of [SettingPages.Account, SettingPages.Behaviour, SettingPages.Appearance]" :as-child="true">
|
||||||
<ButtonBase class="capitalize hover:bg-white/5">
|
<ButtonBase class="capitalize hover:bg-white/5">
|
||||||
{{ page }}
|
{{ page }}
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { renderToString } from "vue/server-renderer";
|
||||||
import { SettingIds, type Settings } from "~/settings";
|
import { SettingIds, type Settings } from "~/settings";
|
||||||
import MentionComponent from "../components/social-elements/notes/mention.vue";
|
import MentionComponent from "../components/social-elements/notes/mention.vue";
|
||||||
|
|
||||||
|
const emojisRegex =
|
||||||
|
/\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(\u200D(\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*/gu;
|
||||||
|
const incorrectEmojisRegex = /^[#*0-9©®]$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes in an HTML string, parses emojis and returns a reactive object with the parsed content.
|
* Takes in an HTML string, parses emojis and returns a reactive object with the parsed content.
|
||||||
* @param content String of HTML content to parse
|
* @param content String of HTML content to parse
|
||||||
|
|
@ -29,6 +33,7 @@ export const useParsedContent = (
|
||||||
|
|
||||||
const shouldRenderEmoji =
|
const shouldRenderEmoji =
|
||||||
toValue(settings)?.[SettingIds.CustomEmojis].value;
|
toValue(settings)?.[SettingIds.CustomEmojis].value;
|
||||||
|
const emojiFont = toValue(settings)?.[SettingIds.EmojiTheme].value;
|
||||||
|
|
||||||
// Replace emoji shortcodes with images
|
// Replace emoji shortcodes with images
|
||||||
if (shouldRenderEmoji) {
|
if (shouldRenderEmoji) {
|
||||||
|
|
@ -52,6 +57,19 @@ export const useParsedContent = (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (emojiFont !== "native") {
|
||||||
|
contentHtml.innerHTML = contentHtml.innerHTML.replace(
|
||||||
|
emojisRegex,
|
||||||
|
(match) => {
|
||||||
|
if (incorrectEmojisRegex.test(match)) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<img src="/emojis/${emojiFont}/${match}.svg" alt="${match}" class="h-[1em] inline not-prose hover:scale-110 transi''tion-transform duration-75 ease-in-out">`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Replace links containing mentions with interactive mentions
|
// Replace links containing mentions with interactive mentions
|
||||||
const links = contentHtml.querySelectorAll("a");
|
const links = contentHtml.querySelectorAll("a");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,13 @@ export default defineNuxtConfig({
|
||||||
brotli: false,
|
brotli: false,
|
||||||
gzip: false,
|
gzip: false,
|
||||||
},
|
},
|
||||||
|
routeRules: {
|
||||||
|
"/emojis/**": {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
schemaOrg: {
|
schemaOrg: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bun --bun nuxt dev --https --https.cert config/versia-fe.localhost.pem --https.key config/versia-fe.localhost-key.pem --host versia-fe.localhost",
|
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bun --bun nuxt dev --https --https.cert config/versia-fe.localhost.pem --https.key config/versia-fe.localhost-key.pem --host versia-fe.localhost",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"emojis:generate": "bun run utils/emojis.ts",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"lint": "bunx @biomejs/biome check .",
|
"lint": "bunx @biomejs/biome check .",
|
||||||
"check": "bunx tsc -p ."
|
"check": "bunx tsc -p ."
|
||||||
|
|
@ -64,7 +64,12 @@
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/html-to-text": "^9.0.4",
|
||||||
"@vue-email/nuxt": "^0.8.19",
|
"@vue-email/nuxt": "^0.8.19",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vue-tsc": "^2.1.10"
|
"vue-tsc": "^2.1.10",
|
||||||
|
"@iconify-json/fluent-emoji": "^1.2.1",
|
||||||
|
"@iconify-json/fluent-emoji-flat": "^1.2.1",
|
||||||
|
"@iconify-json/noto": "^1.2.1",
|
||||||
|
"@iconify-json/twemoji": "^1.2.1",
|
||||||
|
"@iconify/utils": "^2.1.33"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@biomejs/biome",
|
"@biomejs/biome",
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@
|
||||||
SettingPages.Appearance,
|
SettingPages.Appearance,
|
||||||
)) as SettingIds[])" :key="id" />
|
)) as SettingIds[])" :key="id" />
|
||||||
</template>
|
</template>
|
||||||
<template #advanced>
|
<!-- <template #advanced>
|
||||||
<Renderer :id="id" v-for="id of (Object.keys(getSettingsForPage(
|
<Renderer :id="id" v-for="id of (Object.keys(getSettingsForPage(
|
||||||
SettingPages.Advanced,
|
SettingPages.Advanced,
|
||||||
)) as SettingIds[])" :key="id" />
|
)) as SettingIds[])" :key="id" />
|
||||||
</template>
|
</template> -->
|
||||||
<template #account>
|
<template #account>
|
||||||
<ProfileEditor />
|
<ProfileEditor />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
36
settings.ts
36
settings.ts
|
|
@ -83,7 +83,7 @@ export enum SettingIds {
|
||||||
export const settings: Record<SettingIds, Setting> = {
|
export const settings: Record<SettingIds, Setting> = {
|
||||||
[SettingIds.Mfm]: {
|
[SettingIds.Mfm]: {
|
||||||
title: "Render MFM",
|
title: "Render MFM",
|
||||||
description: "Render Misskey-Flavoured Markdown",
|
description: "Render Misskey-Flavoured Markdown.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
|
|
@ -91,7 +91,7 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
} as BooleanSetting,
|
} as BooleanSetting,
|
||||||
[SettingIds.CustomCSS]: {
|
[SettingIds.CustomCSS]: {
|
||||||
title: "Custom CSS",
|
title: "Custom CSS",
|
||||||
description: "Custom CSS for the UI",
|
description: "Custom CSS for the UI.",
|
||||||
type: SettingType.Code,
|
type: SettingType.Code,
|
||||||
value: "",
|
value: "",
|
||||||
language: "css",
|
language: "css",
|
||||||
|
|
@ -99,7 +99,7 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
} as CodeSetting,
|
} as CodeSetting,
|
||||||
[SettingIds.Theme]: {
|
[SettingIds.Theme]: {
|
||||||
title: "Theme",
|
title: "Theme",
|
||||||
description: "UI theme",
|
description: "UI theme.",
|
||||||
type: SettingType.Enum,
|
type: SettingType.Enum,
|
||||||
value: "dark",
|
value: "dark",
|
||||||
options: [
|
options: [
|
||||||
|
|
@ -121,21 +121,21 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
} as EnumSetting,
|
} as EnumSetting,
|
||||||
[SettingIds.CustomEmojis]: {
|
[SettingIds.CustomEmojis]: {
|
||||||
title: "Render Custom Emojis",
|
title: "Render Custom Emojis",
|
||||||
description: "Render custom emojis",
|
description: "Render custom emojis.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: true,
|
value: true,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
} as BooleanSetting,
|
} as BooleanSetting,
|
||||||
[SettingIds.ShowContentWarning]: {
|
[SettingIds.ShowContentWarning]: {
|
||||||
title: "Show Content Warning",
|
title: "Show Content Warning",
|
||||||
description: "Show content warnings on notes marked sensitive/spoiler",
|
description: "Show content warnings on notes marked sensitive/spoiler.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: true,
|
value: true,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
} as BooleanSetting,
|
} as BooleanSetting,
|
||||||
[SettingIds.PopupAvatarHover]: {
|
[SettingIds.PopupAvatarHover]: {
|
||||||
title: "Popup Profile Hover",
|
title: "Popup Profile Hover",
|
||||||
description: "Show profile popup when hovering over a user's avatar",
|
description: "Show profile popup when hovering over a user's avatar.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: true,
|
value: true,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
|
|
@ -143,14 +143,14 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
[SettingIds.InfiniteScroll]: {
|
[SettingIds.InfiniteScroll]: {
|
||||||
title: "Infinite Scroll",
|
title: "Infinite Scroll",
|
||||||
description:
|
description:
|
||||||
"Automatically load more notes when reaching the bottom of the page",
|
"Automatically load more notes when reaching the bottom of the page.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: true,
|
value: true,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
} as BooleanSetting,
|
} as BooleanSetting,
|
||||||
[SettingIds.ConfirmDelete]: {
|
[SettingIds.ConfirmDelete]: {
|
||||||
title: "Confirm Delete",
|
title: "Confirm Delete",
|
||||||
description: "Confirm before deleting a note",
|
description: "Confirm before deleting a note.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
|
|
@ -158,7 +158,7 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
} as BooleanSetting,
|
} as BooleanSetting,
|
||||||
[SettingIds.ConfirmFollow]: {
|
[SettingIds.ConfirmFollow]: {
|
||||||
title: "Confirm Follow",
|
title: "Confirm Follow",
|
||||||
description: "Confirm before following/unfollowing a user",
|
description: "Confirm before following/unfollowing a user.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
|
|
@ -166,7 +166,7 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
} as BooleanSetting,
|
} as BooleanSetting,
|
||||||
[SettingIds.ConfirmReblog]: {
|
[SettingIds.ConfirmReblog]: {
|
||||||
title: "Confirm Reblog",
|
title: "Confirm Reblog",
|
||||||
description: "Confirm before reblogging a note",
|
description: "Confirm before reblogging a note.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
|
|
@ -174,7 +174,7 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
} as BooleanSetting,
|
} as BooleanSetting,
|
||||||
[SettingIds.ConfirmFavourite]: {
|
[SettingIds.ConfirmFavourite]: {
|
||||||
title: "Confirm Favourite",
|
title: "Confirm Favourite",
|
||||||
description: "Confirm before favouriting a note",
|
description: "Confirm before favouriting a note.",
|
||||||
type: SettingType.Boolean,
|
type: SettingType.Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
page: SettingPages.Behaviour,
|
page: SettingPages.Behaviour,
|
||||||
|
|
@ -182,7 +182,8 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
} as BooleanSetting,
|
} as BooleanSetting,
|
||||||
[SettingIds.EmojiTheme]: {
|
[SettingIds.EmojiTheme]: {
|
||||||
title: "Emoji Theme",
|
title: "Emoji Theme",
|
||||||
description: "Theme used for rendering emojis",
|
description:
|
||||||
|
"Theme used for rendering emojis. Requires a page reload to apply.",
|
||||||
type: SettingType.Enum,
|
type: SettingType.Enum,
|
||||||
value: "native",
|
value: "native",
|
||||||
options: [
|
options: [
|
||||||
|
|
@ -192,15 +193,22 @@ export const settings: Record<SettingIds, Setting> = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "twemoji",
|
value: "twemoji",
|
||||||
label: "Twitter emoji set",
|
label: "Twitter Emojis",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "noto",
|
value: "noto",
|
||||||
label: "Noto Emoji",
|
label: "Noto Emoji",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "fluent",
|
||||||
|
label: "Fluent Emojis",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "fluent-flat",
|
||||||
|
label: "Fluent Emojis (flat version)",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
page: SettingPages.Appearance,
|
page: SettingPages.Appearance,
|
||||||
notImplemented: true,
|
|
||||||
} as EnumSetting,
|
} as EnumSetting,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
64
utils/emojis.ts
Normal file
64
utils/emojis.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import * as fluentEmojiData from "@iconify-json/fluent-emoji";
|
||||||
|
import * as fluentFlatEmojiData from "@iconify-json/fluent-emoji-flat";
|
||||||
|
import * as notoEmojiData from "@iconify-json/noto";
|
||||||
|
import * as twemojiData from "@iconify-json/twemoji";
|
||||||
|
import { getIconData, iconToHTML, iconToSVG, replaceIDs } from "@iconify/utils";
|
||||||
|
|
||||||
|
const emojiSets = {
|
||||||
|
twemoji: twemojiData,
|
||||||
|
noto: notoEmojiData,
|
||||||
|
fluent: fluentEmojiData,
|
||||||
|
"fluent-flat": fluentFlatEmojiData,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const prerenderEmojis = (set: keyof typeof emojiSets) => {
|
||||||
|
const data = emojiSets[set];
|
||||||
|
|
||||||
|
// Outputs an object in the format { "emoji": "<svg ... />" }
|
||||||
|
const emojisToName = Object.entries(data.chars).map(([unicode, name]) => {
|
||||||
|
const emojiUnicode = String.fromCodePoint(
|
||||||
|
...unicode.split("-").map((c) => Number.parseInt(c, 16)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [emojiUnicode, name] as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the SVG for each emoji
|
||||||
|
return Object.fromEntries(
|
||||||
|
emojisToName.map(([emoji, name]) => {
|
||||||
|
const iconData = getIconData(data.icons, name);
|
||||||
|
|
||||||
|
if (!iconData) {
|
||||||
|
throw new Error(`Icon not found: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = iconToSVG(iconData, {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
emoji,
|
||||||
|
iconToHTML(replaceIDs(svg.body), svg.attributes),
|
||||||
|
] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pregenerates images for all sets and places them in public/emojis/<set>/<unicode_name>.svg
|
||||||
|
const pregenerateImages = async (set: keyof typeof emojiSets) => {
|
||||||
|
const emojis = prerenderEmojis(set);
|
||||||
|
|
||||||
|
const setDir = `public/emojis/${set}`;
|
||||||
|
|
||||||
|
await mkdir(setDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const [emoji, svg] of Object.entries(emojis)) {
|
||||||
|
await writeFile(`${setDir}/${emoji}.svg`, svg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const set of Object.keys(emojiSets) as (keyof typeof emojiSets)[]) {
|
||||||
|
pregenerateImages(set);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue