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
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -22,4 +22,6 @@ logs
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
config
|
||||
config
|
||||
|
||||
public/emojis
|
||||
|
|
@ -12,6 +12,7 @@ FROM base AS builder
|
|||
|
||||
COPY . /app
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## Development
|
||||
|
||||
Make sure to run `bun run emojis:generate` to generate the emoji list before building or running the project.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the AGPL 3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<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"
|
||||
aria-label="Start typing here..."></textarea>
|
||||
<p v-if="setting.description" class="text-xs mt-2 text-gray-400">{{ setting.description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@
|
|||
</Select.Indicator>
|
||||
</Select.Trigger>
|
||||
</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
|
||||
</p>
|
||||
<p v-else-if="setting.description" class="text-xs mt-2 text-gray-400">{{ setting.description }}</p>
|
||||
<Teleport to="body">
|
||||
<Select.Positioner>
|
||||
<Select.Content
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<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.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">
|
||||
{{ page }}
|
||||
</ButtonBase>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { renderToString } from "vue/server-renderer";
|
|||
import { SettingIds, type Settings } from "~/settings";
|
||||
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.
|
||||
* @param content String of HTML content to parse
|
||||
|
|
@ -29,6 +33,7 @@ export const useParsedContent = (
|
|||
|
||||
const shouldRenderEmoji =
|
||||
toValue(settings)?.[SettingIds.CustomEmojis].value;
|
||||
const emojiFont = toValue(settings)?.[SettingIds.EmojiTheme].value;
|
||||
|
||||
// Replace emoji shortcodes with images
|
||||
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
|
||||
const links = contentHtml.querySelectorAll("a");
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,13 @@ export default defineNuxtConfig({
|
|||
brotli: false,
|
||||
gzip: false,
|
||||
},
|
||||
routeRules: {
|
||||
"/emojis/**": {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schemaOrg: {
|
||||
enabled: false,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
"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",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"emojis:generate": "bun run utils/emojis.ts",
|
||||
"postinstall": "nuxt prepare",
|
||||
"lint": "bunx @biomejs/biome check .",
|
||||
"check": "bunx tsc -p ."
|
||||
|
|
@ -64,7 +64,12 @@
|
|||
"@types/html-to-text": "^9.0.4",
|
||||
"@vue-email/nuxt": "^0.8.19",
|
||||
"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": [
|
||||
"@biomejs/biome",
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@
|
|||
SettingPages.Appearance,
|
||||
)) as SettingIds[])" :key="id" />
|
||||
</template>
|
||||
<template #advanced>
|
||||
<!-- <template #advanced>
|
||||
<Renderer :id="id" v-for="id of (Object.keys(getSettingsForPage(
|
||||
SettingPages.Advanced,
|
||||
)) as SettingIds[])" :key="id" />
|
||||
</template>
|
||||
</template> -->
|
||||
<template #account>
|
||||
<ProfileEditor />
|
||||
</template>
|
||||
|
|
|
|||
36
settings.ts
36
settings.ts
|
|
@ -83,7 +83,7 @@ export enum SettingIds {
|
|||
export const settings: Record<SettingIds, Setting> = {
|
||||
[SettingIds.Mfm]: {
|
||||
title: "Render MFM",
|
||||
description: "Render Misskey-Flavoured Markdown",
|
||||
description: "Render Misskey-Flavoured Markdown.",
|
||||
type: SettingType.Boolean,
|
||||
value: false,
|
||||
page: SettingPages.Behaviour,
|
||||
|
|
@ -91,7 +91,7 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
} as BooleanSetting,
|
||||
[SettingIds.CustomCSS]: {
|
||||
title: "Custom CSS",
|
||||
description: "Custom CSS for the UI",
|
||||
description: "Custom CSS for the UI.",
|
||||
type: SettingType.Code,
|
||||
value: "",
|
||||
language: "css",
|
||||
|
|
@ -99,7 +99,7 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
} as CodeSetting,
|
||||
[SettingIds.Theme]: {
|
||||
title: "Theme",
|
||||
description: "UI theme",
|
||||
description: "UI theme.",
|
||||
type: SettingType.Enum,
|
||||
value: "dark",
|
||||
options: [
|
||||
|
|
@ -121,21 +121,21 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
} as EnumSetting,
|
||||
[SettingIds.CustomEmojis]: {
|
||||
title: "Render Custom Emojis",
|
||||
description: "Render custom emojis",
|
||||
description: "Render custom emojis.",
|
||||
type: SettingType.Boolean,
|
||||
value: true,
|
||||
page: SettingPages.Behaviour,
|
||||
} as BooleanSetting,
|
||||
[SettingIds.ShowContentWarning]: {
|
||||
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,
|
||||
value: true,
|
||||
page: SettingPages.Behaviour,
|
||||
} as BooleanSetting,
|
||||
[SettingIds.PopupAvatarHover]: {
|
||||
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,
|
||||
value: true,
|
||||
page: SettingPages.Behaviour,
|
||||
|
|
@ -143,14 +143,14 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
[SettingIds.InfiniteScroll]: {
|
||||
title: "Infinite Scroll",
|
||||
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,
|
||||
value: true,
|
||||
page: SettingPages.Behaviour,
|
||||
} as BooleanSetting,
|
||||
[SettingIds.ConfirmDelete]: {
|
||||
title: "Confirm Delete",
|
||||
description: "Confirm before deleting a note",
|
||||
description: "Confirm before deleting a note.",
|
||||
type: SettingType.Boolean,
|
||||
value: false,
|
||||
page: SettingPages.Behaviour,
|
||||
|
|
@ -158,7 +158,7 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
} as BooleanSetting,
|
||||
[SettingIds.ConfirmFollow]: {
|
||||
title: "Confirm Follow",
|
||||
description: "Confirm before following/unfollowing a user",
|
||||
description: "Confirm before following/unfollowing a user.",
|
||||
type: SettingType.Boolean,
|
||||
value: false,
|
||||
page: SettingPages.Behaviour,
|
||||
|
|
@ -166,7 +166,7 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
} as BooleanSetting,
|
||||
[SettingIds.ConfirmReblog]: {
|
||||
title: "Confirm Reblog",
|
||||
description: "Confirm before reblogging a note",
|
||||
description: "Confirm before reblogging a note.",
|
||||
type: SettingType.Boolean,
|
||||
value: false,
|
||||
page: SettingPages.Behaviour,
|
||||
|
|
@ -174,7 +174,7 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
} as BooleanSetting,
|
||||
[SettingIds.ConfirmFavourite]: {
|
||||
title: "Confirm Favourite",
|
||||
description: "Confirm before favouriting a note",
|
||||
description: "Confirm before favouriting a note.",
|
||||
type: SettingType.Boolean,
|
||||
value: false,
|
||||
page: SettingPages.Behaviour,
|
||||
|
|
@ -182,7 +182,8 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
} as BooleanSetting,
|
||||
[SettingIds.EmojiTheme]: {
|
||||
title: "Emoji Theme",
|
||||
description: "Theme used for rendering emojis",
|
||||
description:
|
||||
"Theme used for rendering emojis. Requires a page reload to apply.",
|
||||
type: SettingType.Enum,
|
||||
value: "native",
|
||||
options: [
|
||||
|
|
@ -192,15 +193,22 @@ export const settings: Record<SettingIds, Setting> = {
|
|||
},
|
||||
{
|
||||
value: "twemoji",
|
||||
label: "Twitter emoji set",
|
||||
label: "Twitter Emojis",
|
||||
},
|
||||
{
|
||||
value: "noto",
|
||||
label: "Noto Emoji",
|
||||
},
|
||||
{
|
||||
value: "fluent",
|
||||
label: "Fluent Emojis",
|
||||
},
|
||||
{
|
||||
value: "fluent-flat",
|
||||
label: "Fluent Emojis (flat version)",
|
||||
},
|
||||
],
|
||||
page: SettingPages.Appearance,
|
||||
notImplemented: true,
|
||||
} 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