Merge pull request #10 from versia-pub/refactor/shadcn

Full UI rewrite
This commit is contained in:
Gaspard Wierzbinski 2024-12-16 14:38:22 +01:00 committed by GitHub
commit 2352bec77b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
355 changed files with 11030 additions and 5272 deletions

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
insert_final_newline = true
tab_width = 4
trim_trailing_whitespace = true

View file

@ -7,7 +7,7 @@ name: Docker
on:
push:
branches: ["main"]
branches: ["main", "refactor/shadcn"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
@ -61,6 +61,12 @@ jobs:
uses: docker/metadata-action@v5 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=ref,event=pr
type=sha
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["inlang.vs-code-extension"]
}

View file

@ -1,3 +1,4 @@
# Paraglide doesn't properly work with Bun, so it needs Node
FROM imbios/bun-node:22-alpine AS base
# Install dependencies into temp directory
@ -6,6 +7,7 @@ FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lockb /temp/dev/
COPY project.inlang /temp/dev/project.inlang
RUN cd /temp/dev && bun install --frozen-lockfile
FROM base AS builder
@ -13,11 +15,13 @@ 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
RUN cd /app && bun run build
FROM oven/bun:1.1.34-alpine AS final
# Run final web server
FROM ghcr.io/static-web-server/static-web-server:2-alpine AS final
COPY --from=builder /app/.output/ /app
COPY --from=builder /app/.output/public /app/public
COPY sws.toml /etc/config.toml
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
LABEL org.opencontainers.image.source="https://github.com/versia-pub/frontend"
@ -27,4 +31,5 @@ LABEL org.opencontainers.image.title="Versia-FE"
LABEL org.opencontainers.image.description="Frontend for the Versia Server Project"
WORKDIR /app
CMD ["bun", "run", "server/index.mjs"]
EXPOSE 3000
CMD ["static-web-server", "--config-file", "/etc/config.toml"]

108
README.md
View file

@ -1,12 +1,42 @@
<p align="center">
<a href="https://versia.pub"><img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110"></a>
</p>
<div align="center">
<a href="https://versia.pub">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://cdn.versia.pub/branding/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://cdn.versia.pub/branding/logo-light.svg">
<img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110" />
</picture>
</a>
</div>
<center><h1><code>versia-fe</code></h1></center>
**Versia-FE** is a beautiful, fast and responsive front-end for the Versia Server project.
<h2 align="center">
<strong><code>Versia Frontend</code></strong>
</h2>
## Features
<div align="center">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/typescript/typescript-original.svg" height="42" width="52" alt="TypeScript logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/vuejs/vuejs-original.svg" height="42" width="52" alt="Vue logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nuxtjs/nuxtjs-original.svg" height="42" width="52" alt="Nuxt logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/docker/docker-original.svg" height="42" width="52" alt="Docker logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bun/bun-original.svg" height="42" width="52" alt="Bun logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/css3/css3-original.svg" height="42" width="52" alt="CSS3 logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/html5/html5-original.svg" height="42" width="52" alt="HTML5 logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/linux/linux-original.svg" height="42" width="52" alt="Linux logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/tailwindcss/tailwindcss-original.svg" height="42" width="52" alt="TailwindCSS logo">
</div>
<br/>
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/ipad-dark.webp">
<source media="(prefers-color-scheme: light)" srcset="assets/ipad-light.webp">
<img alt="Versia-FE screenshot on an iPad Pro" src="assets/ipad-dark.webp" height="400" />
</picture>
</div>
# Features
- [x] Timelines: public, home, local
- [x] Login
@ -22,12 +52,11 @@
- [x] Note editing
- [x] Alt text support everywhere
- [x] Media uploads
- [x] WCAG 2.2 AAA compliance
- Testing is automated and may not catch all issues, please report any accessibility issues you find.
- [x] WCAG 2.2 AAA testing
- [x] Settings
- [x] Profile editing
### Browser Support
## Browser Support
The following browsers are **supported** (issues will be prioritized):
- **Chromium**: `110+`
@ -43,27 +72,27 @@ The following browsers will very likely work, but are not officially supported:
Other browsers may work, but are not guaranteed to.
## Performance
# Performance
### JavaScript Bloat
## JavaScript
The **total** JavaScript bundle size is less than `900 kB`, but this is made even smaller by the fact that the bundle is split into multiple files, and only the necessary files are loaded on each page.
The **total** JavaScript bundle size is less than `1000 kB`, but this is made even smaller by the fact that the bundle is split into multiple files, and only the necessary files are loaded on each page.
### Benchmarks
## Benchmarks
Benchmarks are due to be conducted soon™.
Soon™.
## Installation
# Installation
Versia-FE is included in the provided `docker-compose` file during [Versia Server installation](https://github.com/versia-pub/server/blob/main/docs/installation.md).
To have Versia-FE and Versia Server running on the same domain, edit the Versia Server configuration to point to the Versia-FE container's address (`frontend` category inside config).
### Manual Installation
## Manual Installation
Here are the steps to install Versia-FE manually:
#### Docker/Podman
### Docker/Podman
```yaml
services:
@ -73,29 +102,52 @@ services:
restart: unless-stopped
networks:
- versia-net
environment:
NUXT_PUBLIC_API_HOST: https://yourserver.com
# For Tor users, set the following environment variable in addition to the above
# NUXT_PUBLIC_ONION_API_HOST: http://youronionserver.onion
```
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.
## Development
> [!TIP]
>
> By default, Versia-FE will connect to any Versia Server instance running on the same domain.
>
> You can set the `NUXT_PUBLIC_API_HOST` environment variable to point to a different Versia Server instance.
Make sure to run `bun run emojis:generate` to generate the emoji list before building or running the project.
### Manual
## License
1. Clone the repository.
```bash
git clone https://github.com/versia-pub/frontend.git
```
2. Install dependencies.
```bash
bun install
```
3. Build the project.
```bash
bun run build
```
4. Serve the static files in the `.output/public` directory.
> [!WARNING]
>
> `.output/public/200.html` should be configured as a fallback for all 404 errors.
# Development
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.
## Acknowledgments
All Versia assets (icon, logo, banners, etc) are licensed under [CC-BY-NC-SA-4.0](https://creativecommons.org/licenses/by-nc-sa/4.0).
### Projects
# Acknowledgments
## Projects
- [**Bun**](https://bun.sh): Thanks to the Bun team for creating an amazing JavaScript runtime.
- [**Nuxt**](https://nuxt.com): Thanks to the Nuxt team for creating an amazing Vue framework.
### People
## People
- [**April John**](https://github.com/cutestnekoaqua): Creator and maintainer of the Versia Server ActivityPub bridge.

46
app.vue
View file

@ -1,34 +1,46 @@
<template>
<ClientOnly>
<TooltipProvider>
<Component is="style">
{{ customCss.value }}
</Component>
</ClientOnly>
<NuxtPwaAssets />
<ClientOnly>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<NotificationsRenderer />
<ConfirmationModal />
</ClientOnly>
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
<Toaster class="pointer-events-auto" />
</TooltipProvider>
</template>
<script setup lang="ts">
import "~/styles/theme.css";
import "~/styles/index.css";
import { convert } from "html-to-text";
import "iconify-icon";
import NotificationsRenderer from "./components/notifications/notifications-renderer.vue";
import { SettingIds } from "./settings";
import ConfirmationModal from "./components/modals/confirmation.vue";
// Use SSR-safe IDs for Headless UI
provideHeadlessUseId(() => useId());
import ConfirmationModal from "./components/modals/confirm.vue";
import { Toaster } from "./components/ui/sonner";
import { setLanguageTag } from "./paraglide/runtime";
import { type EnumSetting, SettingIds } from "./settings";
// Sin
//import "~/styles/mcdonalds.css";
const lang = useLanguage();
setLanguageTag(lang.value);
const code = useRequestURL().searchParams.get("code");
const appData = useAppData();
const instance = useInstance();
const description = useExtendedDescription(client);
const customCss = useSetting(SettingIds.CustomCSS);
const route = useRoute();
// Theme switcher
const theme = useSetting(SettingIds.Theme) as Ref<EnumSetting>;
const colorMode = useColorMode();
watch(theme.value, () => {
colorMode.preference = theme.value.value;
});
useSeoMeta({
titleTemplate: (titleChunk) => {
@ -59,7 +71,7 @@ useHead({
],
});
if (code && appData.value) {
if (code && appData.value && route.path !== "/oauth/code") {
signInWithCode(code, appData.value);
}
@ -72,17 +84,7 @@ useCacheRefresh(client);
</script>
<style>
@import url("overlayscrollbars/overlayscrollbars.css");
body {
font-family: Inter, sans-serif;
}
.os-scrollbar .os-scrollbar-handle {
background: #9999;
}
.os-scrollbar .os-scrollbar-handle:hover {
background: #6666;
}
</style>

View file

@ -0,0 +1,47 @@
<div class="loader-container">
<div class="loader-spinner-container">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="lucide lucide-loader-icon loader-spinner">
<path d="M12 2v4"></path>
<path d="m16.2 7.8 2.9-2.9"></path>
<path d="M18 12h4"></path>
<path d="m16.2 16.2 2.9 2.9"></path>
<path d="M12 18v4"></path>
<path d="m4.9 19.1 2.9-2.9"></path>
<path d="M2 12h4"></path>
<path d="m4.9 4.9 2.9 2.9"></path>
</svg>
</div>
</div>
<style>
.loader-container {
display: flex;
justify-content: center;
align-items: center;
height: 100dvh;
width: 100dvw;
padding: 1.5rem;
}
.loader-spinner-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loader-spinner {
animation: spin 1s linear infinite;
width: 2rem;
height: 2rem;
}
</style>

BIN
assets/ipad-dark.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
assets/ipad-light.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -1,8 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true,
"ignore": ["node_modules/**/*", "dist/**/*", ".output", ".nuxt"]
"enabled": true
},
"linter": {
"enabled": true,
@ -11,6 +10,9 @@
"suspicious": {
"noConsole": "off"
},
"performance": {
"noBarrelFile": "off"
},
"correctness": {
"noNodejsModules": "off",
"noUndeclaredVariables": "off",
@ -65,13 +67,20 @@
"noDuplicateElseIf": "error",
"noCommonJs": "error"
}
},
"ignore": ["node_modules/**/*", "dist/**/*", ".output", ".nuxt"]
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4,
"ignore": ["node_modules/**/*", "dist/**/*", ".output", ".nuxt"]
"indentWidth": 4
},
"files": {
"ignore": [
"node_modules/**/*",
"dist/**/*",
".output",
".nuxt",
"paraglide"
]
}
}

BIN
bun.lockb

Binary file not shown.

18
components.json Normal file
View file

@ -0,0 +1,18 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tsConfigPath": ".nuxt/tsconfig.json",
"tailwind": {
"config": "tailwind.config.ts",
"css": "styles/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"framework": "nuxt",
"aliases": {
"components": "~/components",
"utils": "@/lib/utils"
}
}

View file

@ -1,39 +0,0 @@
<template>
<div v-bind="$attrs" class="bg-dark-700 overflow-hidden flex items-center justify-center">
<Skeleton :enabled="!imageLoaded" class="!h-full !w-full !rounded-none">
<img class="cursor-pointer ring-1 w-full h-full object-cover" :src="src" :alt="alt" />
</Skeleton>
</div>
</template>
<script lang="ts" setup>
import Skeleton from "../skeleton/Skeleton.vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps<{
src?: string;
alt?: string;
}>();
const imageLoaded = ref(false);
watch(
() => props.src,
(src) => {
if (!src) {
return;
}
// Load in background
const img = new Image();
img.src = src;
img.onload = () => {
imageLoaded.value = true;
};
},
{ immediate: true },
);
</script>

View file

@ -1,22 +0,0 @@
<template>
<ButtonBase class="enabled:hover:bg-white/20 text-sm !rounded-sm !ring-0 !py-3 sm:!py-1.5 sm:!px-2 !justify-start">
<Icon :icon="icon" />
<slot />
</ButtonBase>
</template>
<script lang="ts" setup>
import type { ButtonHTMLAttributes } from "vue";
import ButtonBase from "~/packages/ui/components/buttons/button.vue";
import Icon from "~/packages/ui/components/icons/icon.vue";
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
defineProps<
Props & {
icon: string;
}
>();
</script>
<style></style>

View file

@ -1,15 +0,0 @@
<template>
<ButtonBase class="hover:bg-white/5 text-xs max-w-full w-full h-full !p-0">
<Icon :icon="icon" class="!size-6" />
</ButtonBase>
</template>
<script lang="ts" setup>
import ButtonBase from "~/packages/ui/components/buttons/button.vue";
import Icon from "~/packages/ui/components/icons/icon.vue";
defineProps<{
icon: string;
text: string;
}>();
</script>

View file

@ -1,55 +0,0 @@
<template>
<div class="flex flex-row gap-1 border-white/20">
<Button title="Mention someone" @click="content = content + '@'">
<iconify-icon height="1.5rem" width="1.5rem" icon="tabler:at" aria-hidden="true" />
</Button>
<Button title="Toggle Markdown" @click="markdown = !markdown" :toggled="markdown">
<iconify-icon width="1.25rem" height="1.25rem"
:icon="markdown ? 'tabler:markdown' : 'tabler:markdown-off'" aria-hidden="true" />
</Button>
<Button title="Use a custom emoji">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:mood-smile" aria-hidden="true" />
</Button>
<Button title="Add media" @click="emit('filePickerOpen')">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:photo-up" aria-hidden="true" />
</Button>
<Button title="Add a file" @click="emit('filePickerOpen')">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:file-upload" aria-hidden="true" />
</Button>
<Button title="Add content warning" @click="cw = !cw" :toggled="cw">
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:rating-18-plus" aria-hidden="true" />
</Button>
<ButtonBase theme="primary" :loading="loading" @click="emit('send')" class="ml-auto rounded-full"
:disabled="!canSubmit || loading">
{{
respondingType === "edit" ? "Edit!" : "Send!"
}}
</ButtonBase>
</div>
</template>
<script lang="ts" setup>
import ButtonBase from "~/packages/ui/components/buttons/button.vue";
import Button from "./button.vue";
defineProps<{
loading: boolean;
canSubmit: boolean;
respondingType: string | null;
}>();
const emit = defineEmits<{
send: [];
filePickerOpen: [];
}>();
const cw = defineModel<boolean>("cw", {
required: true,
});
const content = defineModel<string>("content", {
required: true,
});
const markdown = defineModel<boolean>("markdown", {
required: true,
});
</script>

View file

@ -1,126 +0,0 @@
<template>
<div class="max-h-40 max-w-full rounded ring-1 ring-dark-300 bg-dark-800 fixed z-20" :style="{
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
}" v-show="topSuggestions && topSuggestions.length > 0">
<OverlayScrollbarsComponent class="w-full [&>div]:flex">
<div v-for="(suggestion, index) in topSuggestions" :key="suggestion.key"
@click="emit('autocomplete', suggestion.key)"
:ref="el => { if (el) suggestionRefs[index] = el as Element }" :title="suggestion.key"
:class="['flex justify-center shrink-0 items-center size-12 p-2 hover:bg-dark-900/70', index === selectedSuggestionIndex && 'bg-primary-500']">
<slot :suggestion="suggestion"></slot>
</div>
</OverlayScrollbarsComponent>
</div>
</template>
<script lang="ts" setup>
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
const props = defineProps<{
currentlyTyping: string | null;
textarea: HTMLTextAreaElement | undefined;
suggestions: Array<{ key: string; value: unknown }>;
distanceFunction: (a: string, b: string) => number;
}>();
const suggestionRefs = ref<Element[]>([]);
// Allow the user to navigate the suggestions with the arrow keys
// and select a suggestion with the Tab or Enter key
const { Tab, ArrowRight, ArrowLeft, Enter } = useMagicKeys({
target: props.textarea,
passive: false,
onEventFired(e) {
if (
["Tab", "Enter", "ArrowRight", "ArrowLeft"].includes(e.key) &&
topSuggestions.value !== null
) {
e.preventDefault();
}
},
});
const topSuggestions = ref<Array<{ key: string; value: unknown }> | null>(null);
const selectedSuggestionIndex = ref<number | null>(null);
const x = ref(0);
const y = ref(0);
const width = ref(0);
const TOP_PADDING = 10;
useEventListener(props.textarea, "keyup", () => {
recalculatePosition();
});
const recalculatePosition = () => {
if (props.textarea) {
const target = props.textarea;
const position = target.selectionEnd;
// Get x, y position of the cursor in the textarea
const { top, left } = target.getBoundingClientRect();
const lineHeight = Number.parseInt(
getComputedStyle(target).lineHeight ?? "0",
10,
);
const lines = target.value.slice(0, position).split("\n");
const line = lines.length - 1;
x.value = left;
// Spawn one line below the cursor, so add +1
y.value = top + (line + 1) * lineHeight + TOP_PADDING;
width.value = target.clientWidth;
}
};
watchEffect(() => {
if (props.currentlyTyping !== null) {
topSuggestions.value = props.suggestions
.map((suggestion) => ({
...suggestion,
distance: props.distanceFunction(
props.currentlyTyping as string,
suggestion.key,
),
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, 20);
} else {
topSuggestions.value = null;
}
if (ArrowRight?.value && topSuggestions.value !== null) {
selectedSuggestionIndex.value =
(selectedSuggestionIndex.value ?? -1) + 1;
if (selectedSuggestionIndex.value >= topSuggestions.value.length) {
selectedSuggestionIndex.value = 0;
}
suggestionRefs.value[selectedSuggestionIndex.value]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
if (ArrowLeft?.value && topSuggestions.value !== null) {
selectedSuggestionIndex.value =
(selectedSuggestionIndex.value ?? topSuggestions.value.length) - 1;
if (selectedSuggestionIndex.value < 0) {
selectedSuggestionIndex.value = topSuggestions.value.length - 1;
}
suggestionRefs.value[selectedSuggestionIndex.value]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
if ((Tab?.value || Enter?.value) && topSuggestions.value !== null) {
const suggestion =
topSuggestions.value[selectedSuggestionIndex.value ?? 0];
if (suggestion) {
emit("autocomplete", suggestion.key);
}
}
});
const emit = defineEmits<{
autocomplete: [suggestion: string];
}>();
</script>

View file

@ -1,18 +0,0 @@
<template>
<button v-bind="$props"
:class="['rounded text-gray-300 hover:bg-dark-900/70 p-2 flex items-center justify-center duration-200', toggled && 'bg-primary-500/70 hover:bg-primary-900/70']">
<slot />
</button>
</template>
<script lang="ts" setup>
import type { ButtonHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
defineProps<
Props & {
toggled?: boolean;
}
>();
</script>

View file

@ -1,222 +1,299 @@
<template>
<RespondingTo v-if="respondingTo" :respondingTo="respondingTo" />
<div class="px-6 pb-4 pt-5">
<RichTextbox v-model:content="content" :loading="loading" :chosenSplash="chosenSplash" :characterLimit="characterLimit"
:handle-paste="handlePaste" />
<ContentWarning v-model:cw="cw" v-model:cwContent="cwContent" />
<FileUploader :files="files" ref="uploader" @add-file="(newFile) => {
files.push(newFile);
}" @change-file="(changedFile) => {
const index = files.findIndex((file) => file.id === changedFile.id);
if (index !== -1) {
files[index] = changedFile;
}
}" @remove-file="(id) => {
files.splice(files.findIndex((file) => file.id === id), 1);
}" />
<ActionButtons v-model:content="content" v-model:markdown="markdown" v-model:cw="cw" :loading="loading" :canSubmit="canSubmit"
:respondingType="respondingType" @send="send" @file-picker-open="openFilePicker" />
<div v-if="relation" class="rounded border overflow-auto max-h-72">
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
</div>
<Input v-model:model-value="state.contentWarning" v-if="state.sensitive"
placeholder="Put your content warning here" />
<Textarea :placeholder="chosenSplash" v-model:model-value="state.content"
class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0"
:disabled="sending" />
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
<Files v-model:files="state.files" />
</div>
<DialogFooter class="items-center flex-row">
<Tooltip>
<TooltipTrigger as="div">
<Button variant="ghost" size="icon">
<AtSign class="!size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ m.game_tough_seal_adore() }}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as="div">
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/markdown'"
@update:pressed="i => state.contentType = i ? 'text/plain' : 'text/markdown'">
<LetterText class="!size-5" />
</Toggle>
</TooltipTrigger>
<TooltipContent>
<p>{{ m.plane_born_koala_hope() }}</p>
</TooltipContent>
</Tooltip>
<Select v-model:model-value="state.visibility">
<SelectTrigger :as-child="true" :disabled="relation?.type === 'edit'">
<Button variant="ghost" size="icon">
<component :is="visibilities[state.visibility].icon" class="!size-5" />
</Button>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="(v, k) in visibilities" :key="k" @click="state.visibility = k" :value="k">
<div class="flex flex-row gap-4 items-center w-full justify-between">
<div class="flex flex-col gap-1">
<span class="font-semibold">{{ v.name }}</span>
<span>{{ v.text }}</span>
</div>
<component :is="v.icon" class="!size-5" />
</div>
</SelectItem>
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger as="div">
<Button variant="ghost" size="icon">
<Smile class="!size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ m.blue_ornate_coyote_tickle() }}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as="div">
<Button variant="ghost" size="icon" @click="fileInput?.click()">
<FilePlus2 class="!size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ m.top_patchy_earthworm_vent() }}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as="div">
<Toggle variant="default" size="sm" v-model:pressed="state.sensitive">
<TriangleAlert class="!size-5" />
</Toggle>
</TooltipTrigger>
<TooltipContent>
<p>{{ m.frail_broad_mallard_dart() }}</p>
</TooltipContent>
</Tooltip>
<Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit">
<Loader v-if="sending" class="!size-5 animate-spin" />
{{ relation?.type === "edit" ? m.gaudy_strong_puma_slide() : m.free_teal_bulldog_learn() }}
</Button>
</DialogFooter>
</template>
<script lang="ts" setup>
import type { Instance, Status } from "@versia/client/types";
import { nanoid } from "nanoid";
import { computed, onMounted, ref, watch, watchEffect } from "vue";
import { useConfig, useEvent, useListen, useMagicKeys } from "#imports";
import ActionButtons from "./action-buttons.vue";
import ContentWarning from "./content-warning.vue";
import RespondingTo from "./responding-to.vue";
import RichTextbox from "./rich-text-box.vue";
// biome-ignore lint/style/useImportType: <explanation>
import FileUploader from "./uploader/uploader.vue";
import type { FileData } from "./uploader/uploader.vue";
import type { ResponseError } from "@versia/client";
import type { Status, StatusSource } from "@versia/client/types";
import {
AtSign,
FilePlus2,
Globe,
LetterText,
Loader,
Lock,
LockOpen,
Smile,
TriangleAlert,
} from "lucide-vue-next";
import { SelectTrigger } from "radix-vue";
import { toast } from "vue-sonner";
import Note from "~/components/notes/note.vue";
import { Select, SelectContent, SelectItem } from "~/components/ui/select";
import * as m from "~/paraglide/messages.js";
import { SettingIds } from "~/settings";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import { Toggle } from "../ui/toggle";
import Files from "./files.vue";
const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
const content = ref("");
const respondingTo = ref<Status | null>(null);
const respondingType = ref<"reply" | "quote" | "edit" | null>(null);
const cw = ref(false);
const cwContent = ref("");
const markdown = ref(true);
const { Control_Enter, Command_Enter } = useMagicKeys();
const ctrlEnterSend = useSetting(SettingIds.CtrlEnterToSend);
const fileInput = ref<HTMLInputElement | null>(null);
const splashes = useConfig().COMPOSER_SPLASHES;
const chosenSplash = ref(
splashes[Math.floor(Math.random() * splashes.length)] as string,
);
const openFilePicker = () => {
uploader.value?.openFilePicker();
};
const files = ref<FileData[]>([]);
const handlePaste = (event: ClipboardEvent) => {
if (event.clipboardData) {
const items = Array.from(event.clipboardData.items);
const newFiles = items
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (newFiles.length > 0) {
event.preventDefault();
files.value.push(
...newFiles.map((file) => ({
id: nanoid(),
file,
progress: 0,
uploading: true,
})),
);
}
}
};
watch(Control_Alt as ComputedRef<boolean>, () => {
chosenSplash.value = splashes[
Math.floor(Math.random() * splashes.length)
] as string;
});
watch(
files,
(newFiles) => {
loading.value = newFiles.some((file) => file.uploading);
},
{
deep: true,
},
);
onMounted(() => {
useListen("composer:reply", (note: Status) => {
respondingTo.value = note;
respondingType.value = "reply";
if (note.account.id !== identity.value?.account.id) {
content.value = `@${note.account.acct} `;
}
});
useListen("composer:quote", (note: Status) => {
respondingTo.value = note;
respondingType.value = "quote";
if (note.account.id !== identity.value?.account.id) {
content.value = `@${note.account.acct} `;
}
});
useListen("composer:edit", async (note: Status) => {
loading.value = true;
files.value = note.media_attachments.map((file) => ({
id: nanoid(),
file: new File([], file.url),
progress: 1,
uploading: false,
api_id: file.id,
alt_text: file.description ?? undefined,
}));
const source = await client.value.getStatusSource(note.id);
if (source?.data) {
respondingTo.value = note;
respondingType.value = "edit";
content.value = source.data.text;
cwContent.value = source.data.spoiler_text;
}
loading.value = false;
});
});
watchEffect(() => {
if (Control_Enter?.value || Command_Enter?.value) {
send();
}
});
const props = defineProps<{
instance: Instance;
}>();
const loading = ref(false);
const canSubmit = computed(
() =>
(content.value?.trim().length > 0 || files.value.length > 0) &&
content.value?.trim().length <= characterLimit.value,
);
const send = async () => {
if (!(identity.value && client.value)) {
throw new Error("Not authenticated");
}
try {
loading.value = true;
if (respondingType.value === "edit" && respondingTo.value) {
const response = await client.value.editStatus(
respondingTo.value.id,
{
status: content.value?.trim() ?? "",
content_type: markdown.value
? "text/markdown"
: "text/plain",
spoiler_text: cw.value ? cwContent.value.trim() : undefined,
sensitive: cw.value,
media_ids: files.value
.filter((file) => !!file.api_id)
.map((file) => file.api_id) as string[],
},
);
if (!response.data) {
throw new Error("Failed to edit status");
}
content.value = "";
loading.value = false;
useEvent("composer:send-edit", response.data);
useEvent("composer:close");
watch([Control_Enter, Command_Enter], () => {
if (sending.value || !ctrlEnterSend.value.value) {
return;
}
const response = await client.value.postStatus(
content.value?.trim() ?? "",
{
content_type: markdown.value ? "text/markdown" : "text/plain",
in_reply_to_id:
respondingType.value === "reply"
? respondingTo.value?.id
: undefined,
quote_id:
respondingType.value === "quote"
? respondingTo.value?.id
: undefined,
spoiler_text: cw.value ? cwContent.value.trim() : undefined,
sensitive: cw.value,
media_ids: files.value
.filter((file) => !!file.api_id)
.map((file) => file.api_id) as string[],
},
);
submit();
});
if (!response.data) {
throw new Error("Failed to send status");
const { relation } = defineProps<{
relation?: {
type: "reply" | "quote" | "edit";
note: Status;
source?: StatusSource;
};
}>();
const getMentions = () => {
if (!relation || relation.type !== "reply") {
return "";
}
content.value = "";
loading.value = false;
useEvent("composer:send", response.data as Status);
const peopleToMention = relation.note.mentions
.concat(relation.note.account)
// Deduplicate mentions
.filter((men, i, a) => a.indexOf(men) === i)
// Remove self
.filter((men) => men.id !== identity.value?.account.id);
const mentions = peopleToMention.map((me) => `@${me.acct}`).join(" ");
return `${mentions} `;
};
const state = reactive({
// If editing, use the original content
// If sending a reply, prefill with mentions
content: relation?.source?.text || getMentions(),
sensitive: relation?.type === "edit" ? relation.note.sensitive : false,
contentWarning: relation?.type === "edit" ? relation.note.spoiler_text : "",
contentType: "text/markdown" as "text/markdown" | "text/plain",
visibility: (relation?.type === "edit"
? relation.note.visibility
: "public") as Status["visibility"],
files: (relation?.type === "edit"
? relation.note.media_attachments.map((a) => ({
apiId: a.id,
file: new File([], a.url),
alt: a.description,
uploading: false,
updating: false,
}))
: []) as {
apiId?: string;
file: File;
alt?: string;
uploading: boolean;
updating: boolean;
}[],
});
const sending = ref(false);
const splashes = useConfig().COMPOSER_SPLASHES;
const chosenSplash = splashes[Math.floor(Math.random() * splashes.length)];
const submit = async () => {
sending.value = true;
try {
if (relation?.type === "edit") {
const { data } = await client.value.editStatus(relation.note.id, {
status: state.content,
content_type: state.contentType,
sensitive: state.sensitive,
spoiler_text: state.sensitive
? state.contentWarning
: undefined,
media_ids: state.files
.map((f) => f.apiId)
.filter((f) => f !== undefined),
});
useEvent("composer:send-edit", data);
useEvent("composer:close");
} catch (error) {
console.error(error);
loading.value = false;
} else {
const { data } = await client.value.postStatus(state.content, {
content_type: state.contentType,
sensitive: state.sensitive,
spoiler_text: state.sensitive
? state.contentWarning
: undefined,
media_ids: state.files
.map((f) => f.apiId)
.filter((f) => f !== undefined),
quote_id:
relation?.type === "quote" ? relation.note.id : undefined,
in_reply_to_id:
relation?.type === "reply" ? relation.note.id : undefined,
visibility: state.visibility,
});
useEvent("composer:send", data as Status);
useEvent("composer:close");
}
} catch (_e) {
const e = _e as ResponseError;
toast.error(e.message);
} finally {
sending.value = false;
}
};
const characterLimit = computed(
() => props.instance?.configuration.statuses.max_characters ?? 0,
);
const uploadFileFromEvent = (e: Event) => {
const target = e.target as HTMLInputElement;
const files = Array.from(target.files ?? []);
uploadFiles(files);
target.value = "";
};
const uploadFiles = (files: File[]) => {
for (const file of files) {
state.files.push({
file,
uploading: true,
updating: false,
});
client.value
.uploadMedia(file)
.then((media) => {
const index = state.files.findIndex((f) => f.file === file);
if (!state.files[index]) {
return;
}
state.files[index].apiId = media.data.id;
state.files[index].uploading = false;
})
.catch(() => {
const index = state.files.findIndex((f) => f.file === file);
if (!state.files[index]) {
return;
}
state.files.splice(index, 1);
});
}
};
const visibilities = {
public: {
icon: Globe,
name: m.lost_trick_dog_grace(),
text: m.last_mean_peacock_zip(),
},
unlisted: {
icon: LockOpen,
name: m.funny_slow_jannes_walk(),
text: m.grand_strong_gibbon_race(),
},
private: {
icon: Lock,
name: m.grassy_empty_raven_startle(),
text: m.white_teal_ostrich_yell(),
},
direct: {
icon: AtSign,
name: m.pretty_bold_baboon_wave(),
text: m.lucky_mean_robin_link(),
},
};
</script>

View file

@ -1,16 +0,0 @@
<template>
<div v-if="cw" class="mb-4">
<input type="text" v-model="cwContent" placeholder="Add a content warning"
class="w-full p-2 mt-1 text-sm prose prose-invert bg-dark-900 rounded focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 appearance-none focus:!border-none focus:!outline-none"
aria-label="Content warning" />
</div>
</template>
<script lang="ts" setup>
const cw = defineModel<boolean>("cw", {
required: true,
});
const cwContent = defineModel<string>("cwContent", {
required: true,
});
</script>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import type { Status, StatusSource } from "@versia/client/types";
import { toast } from "vue-sonner";
import * as m from "~/paraglide/messages.js";
import Composer from "./composer.vue";
useListen("composer:open", () => {
if (identity.value) {
open.value = true;
}
});
useListen("composer:edit", async (note) => {
const id = toast.loading(m.wise_late_fireant_walk(), {
duration: 0,
});
const { data: source } = await client.value.getStatusSource(note.id);
relation.value = {
type: "edit",
note,
source,
};
open.value = true;
toast.dismiss(id);
});
useListen("composer:reply", (note) => {
relation.value = {
type: "reply",
note,
};
open.value = true;
});
useListen("composer:quote", (note) => {
relation.value = {
type: "quote",
note,
};
open.value = true;
});
useListen("composer:close", () => {
open.value = false;
relation.value = null;
});
const open = ref(false);
const relation = ref(
null as {
type: "reply" | "quote" | "edit";
note: Status;
source?: StatusSource;
} | null,
);
</script>
<template>
<Dialog v-model:open="open" @update:open="o => { if (!o) { relation = null } }">
<DialogContent :hide-close="true"
class="sm:max-w-xl max-w-full w-full grid-rows-[minmax(0,1fr)_auto] max-h-[90dvh] p-5 pt-6 top-0 sm:top-1/2 translate-y-0 sm:-translate-y-1/2">
<DialogTitle class="sr-only">
{{ relation?.type === "reply" ? m.loved_busy_mantis_slide() : relation?.type === "quote" ? "Quote" :
m.chunky_dull_marlin_trip() }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ relation?.type === "reply" ? m.tired_grassy_vulture_forgive() : relation?.type === "quote" ?
m.livid_livid_nils_snip() : m.brief_cool_capybara_fear() }}
</DialogDescription>
<Composer :relation="relation ?? undefined" />
</DialogContent>
</Dialog>
</template>

View file

@ -1,29 +0,0 @@
<template>
<AutocompleteSuggestbox :currently-typing="currentlyTypingEmoji" :textarea="textarea" :suggestions="emojis"
:distance-function="distance">
<template #default="{ suggestion }">
<Avatar :src="(suggestion.value as Emoji).url"
class="w-full h-full [&>img]:object-contain !bg-transparent rounded"
:alt="`Emoji with shortcode ${(suggestion.value as Emoji).shortcode}`" />
</template>
</AutocompleteSuggestbox>
</template>
<script lang="ts" setup>
import type { Emoji } from "@versia/client/types";
import { distance } from "fastest-levenshtein";
import Avatar from "../avatars/avatar.vue";
import AutocompleteSuggestbox from "./autocomplete-suggestbox.vue";
defineProps<{
currentlyTypingEmoji: string | null;
textarea: HTMLTextAreaElement | undefined;
}>();
const emojis = computed(
() =>
identity.value?.emojis.map((emoji) => ({
key: emoji.shortcode,
value: emoji,
})) ?? [],
);
</script>

View file

@ -0,0 +1,126 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as="button"
:disabled="file.uploading || file.updating"
class="block bg-card text-card-foreground shadow-sm h-28 overflow-hidden rounded relative min-w-28 *:disabled:opacity-50">
<Avatar class="h-28 w-full" shape="square">
<AvatarImage class="!object-contain" :src="createObjectURL(file.file)" />
</Avatar>
<Badge v-if="!file.uploading && !file.updating" class="absolute bottom-1 right-1" variant="default">{{ formatBytes(file.file.size) }}</Badge>
<Badge v-else class="absolute bottom-1 right-1 rounded px-1 !opacity-100" variant="default"><Loader class="animate-spin size-4" /></Badge>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-48">
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="editName">
<TextCursorInput class="mr-2 h-4 w-4" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem @click="editCaption">
<Captions class="mr-2 h-4 w-4" />
<span>Add caption</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="emit('remove')">
<Delete class="mr-2 h-4 w-4" />
<span>Remove</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script lang="ts" setup>
import { Captions, Delete, Loader, TextCursorInput } from "lucide-vue-next";
import { confirmModalService } from "~/components/modals/composable.ts";
import { Avatar, AvatarImage } from "~/components/ui/avatar";
import { Badge } from "~/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
const file = defineModel<{
apiId?: string;
file: File;
alt?: string;
uploading: boolean;
updating: boolean;
}>("file", {
required: true,
});
const emit = defineEmits<{
remove: [];
}>();
const editName = async () => {
const result = await confirmModalService.confirm({
title: "Enter a new name",
defaultValue: file.value.file.name,
confirmText: "Edit",
inputType: "text",
});
if (result.confirmed) {
file.value.updating = true;
file.value.file = new File(
[file.value.file],
result.value ?? file.value.file.name,
{
type: file.value.file.type,
lastModified: file.value.file.lastModified,
},
);
try {
await client.value.updateMedia(file.value.apiId ?? "", {
file: file.value.file,
});
} finally {
file.value.updating = false;
}
}
};
const editCaption = async () => {
const result = await confirmModalService.confirm({
title: "Enter a caption",
message:
"Captions are useful for people with visual impairments, or when the image can't be displayed.",
defaultValue: file.value.alt,
confirmText: "Add",
inputType: "textarea",
});
if (result.confirmed) {
file.value.updating = true;
file.value.alt = result.value;
try {
await client.value.updateMedia(file.value.apiId ?? "", {
description: file.value.alt,
});
} finally {
file.value.updating = false;
}
}
};
const createObjectURL = URL.createObjectURL;
const formatBytes = (bytes: number) => {
if (bytes === 0) {
return "0 Bytes";
}
const k = 1000;
const digitsAfterPoint = 2;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(digitsAfterPoint))} ${sizes[i]}`;
};
</script>

View file

@ -0,0 +1,19 @@
<template>
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event" @remove="files.splice(index, 1)" />
</template>
<script lang="ts" setup>
import FilePreview from "./file-preview.vue";
defineModel<
{
apiId?: string;
file: File;
alt?: string;
uploading: boolean;
updating: boolean;
}[]
>("files", {
required: true,
});
</script>

View file

@ -1,41 +0,0 @@
<template>
<AutocompleteSuggestbox :currently-typing="currentlyTypingMention" :textarea="textarea" :suggestions="mentions"
:distance-function="distance">
<template #default="{ suggestion }">
<Avatar :src="(suggestion.value as Account).avatar" class="w-full h-full rounded"
:alt="`User ${(suggestion.value as Account).acct}`" />
</template>
</AutocompleteSuggestbox>
</template>
<script lang="ts" setup>
import type { Account } from "@versia/client/types";
import { distance } from "fastest-levenshtein";
import Avatar from "../avatars/avatar.vue";
import AutocompleteSuggestbox from "./autocomplete-suggestbox.vue";
const props = defineProps<{
currentlyTypingMention: string | null;
textarea: HTMLTextAreaElement | undefined;
}>();
const mentions = ref<{ key: string; value: Account }[]>([]);
watch(
() => props.currentlyTypingMention,
async (value) => {
if (!value) {
return;
}
const users = await client.value.searchAccount(value, { limit: 20 });
mentions.value = users.data
.map((user) => ({
key: user.username,
value: user,
distance: distance(value, user.username),
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, 20);
},
);
</script>

View file

@ -1,61 +0,0 @@
<template>
<HeadlessTransitionRoot as="template" :show="open">
<Dialog.Root v-model:open="open" :close-on-escape="true" :close-on-interact-outside="true"
@update:open="o => open = o">
<Teleport to="body">
<Dialog.Positioner
class="flex items-start z-50 justify-center p-4 text-center sm:items-center sm:p-0 fixed inset-0 w-screen h-screen overflow-y-hidden">
<HeadlessTransitionChild as="template" enter="ease-out duration-200" enter-from="opacity-0"
enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100"
leave-to="opacity-0">
<Dialog.Backdrop class="fixed inset-0 bg-black/70" @click="open = false" />
</HeadlessTransitionChild>
<HeadlessTransitionChild as="template" enter="ease-out duration-200"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Content class="overflow-y-auto w-full max-h-full md:py-16">
<div
class="relative overflow-hidden max-w-xl mx-auto rounded-lg bg-dark-700 ring-1 ring-dark-800 text-left shadow-xl transition-all w-full">
<Composer v-if="instance" :instance="instance as any" />
</div>
</Dialog.Content>
</HeadlessTransitionChild>
</Dialog.Positioner>
</Teleport>
</Dialog.Root>
</HeadlessTransitionRoot>
</template>
<script lang="ts" setup>
import { Dialog } from "@ark-ui/vue";
import Composer from "./composer.vue";
const open = ref(false);
useListen("note:reply", async (note) => {
open.value = true;
await nextTick();
useEvent("composer:reply", note);
});
useListen("note:quote", async (note) => {
open.value = true;
await nextTick();
useEvent("composer:quote", note);
});
useListen("note:edit", async (note) => {
open.value = true;
await nextTick();
useEvent("composer:edit", note);
});
useListen("composer:open", () => {
if (identity.value) {
open.value = true;
}
});
useListen("composer:close", () => {
open.value = false;
});
const instance = useInstance();
</script>

View file

@ -1,17 +0,0 @@
<template>
<div v-if="respondingTo" class="mb-4" role="region" aria-label="Responding to">
<OverlayScrollbarsComponent :defer="true" class="max-h-72 overflow-y-auto">
<Note :element="respondingTo" :small="true" :disabled="true" class="!rounded-none !bg-primary-500/10" />
</OverlayScrollbarsComponent>
</div>
</template>
<script lang="ts" setup>
import type { Status } from "@versia/client/types";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import Note from "../social-elements/notes/note.vue";
const props = defineProps<{
respondingTo: Status;
}>();
</script>

View file

@ -1,19 +0,0 @@
<template>
<RichTextboxInput v-model:model-content="content" @paste="handlePaste" :disabled="loading"
:placeholder="chosenSplash" :max-characters="characterLimit" class="focus:!ring-0 max-h-[70dvh]" />
</template>
<script lang="ts" setup>
import RichTextboxInput from "../inputs/rich-textbox-input.vue";
defineProps<{
loading: boolean;
chosenSplash: string;
characterLimit: number;
handlePaste: (event: ClipboardEvent) => void;
}>();
const content = defineModel<string>("content", {
required: true,
});
</script>

View file

@ -1,8 +0,0 @@
<template>
<div class="w-full max-w-full rounded ring-1 ring-dark-300 bg-dark-800 absolute z-20 flex flex-col">
<slot />
</div>
</template>
<script lang="ts" setup>
</script>

View file

@ -1,53 +0,0 @@
<template>
<Popover.Root :positioning="{
strategy: 'fixed',
}" @update:open="o => !o" :close-on-interact-outside="false">
<Popover.Trigger aria-hidden="true"
class="absolute top-1 left-1 p-1 bg-dark-800 ring-1 ring-white/5 text-white text-xs rounded size-6">
<iconify-icon icon="tabler:alt" width="none" class="size-4" />
</Popover.Trigger>
<Popover.Positioner class="!z-[100]">
<Popover.Content
class="p-1 bg-dark-400 rounded text-sm ring-1 ring-white/10 shadow-lg text-gray-300 w-72 space-y-2">
<div class="flex items-center justify-between px-1 pt-1 gap-x-1">
<Popover.CloseTrigger :as-child="true">
<Button theme="outline" aria-label="Close" class="text-xs !p-1">
<iconify-icon icon="tabler:x" width="1rem" height="1rem" />
</Button>
</Popover.CloseTrigger>
<h3 class="text-xs font-semibold">Alt Text</h3>
<a :href="`https://www.w3.org/WAI/tutorials/images/decision-tree/`" target="_blank"
class="text-xs text-gray-300 ml-auto mr-1" title="Learn more about alt text">
<iconify-icon icon="tabler:info-circle" width="1rem" height="1rem" />
</a>
</div>
<PreviewContent :file="fileData.file" class="rounded" />
<textarea :disabled="fileData.uploading" @keydown.enter.stop v-model="fileData.alt_text"
placeholder="Describe this image for screen readers"
rows="5"
class="w-full p-2 text-sm prose prose-invert bg-dark-900 rounded focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 appearance-none focus:!border-none focus:!outline-none" />
<Popover.CloseTrigger :as-child="true">
<Button theme="secondary" @click="$emit('update-alt-text', fileData.alt_text)" class="w-full"
:loading="fileData.uploading">
<span>Edit</span>
</Button>
</Popover.CloseTrigger>
</Popover.Content>
</Popover.Positioner>
</Popover.Root>
</template>
<script lang="ts" setup>
import { Popover } from "@ark-ui/vue";
import Button from "~/packages/ui/components/buttons/button.vue";
import PreviewContent from "./preview-content.vue";
import type { FileData } from "./uploader.vue";
const props = defineProps<{
fileData: FileData;
}>();
defineEmits<{
"update-alt-text": [text?: string];
}>();
</script>

View file

@ -1,30 +0,0 @@
<template>
<div role="button" tabindex="0" :class="[
'size-28 bg-dark-800 rounded flex items-center relative justify-center ring-1 ring-white/20 overflow-hidden',
]" @keydown.enter="$emit('remove', fileData.id)">
<PreviewContent :file="fileData.file" />
<FileShadowOverlay />
<FileSize :size="fileData.file.size" :uploading="fileData.uploading" />
<RemoveButton @remove="$emit('remove', fileData.id)" />
<AltTextEditor v-if="fileData.api_id" :file-data="fileData"
@update-alt-text="(text) => $emit('update-alt-text', fileData.id, text)" />
</div>
</template>
<script lang="ts" setup>
import AltTextEditor from "./alt-text-editor.vue";
import FileShadowOverlay from "./file-shadow-overlay.vue";
import FileSize from "./file-size.vue";
import PreviewContent from "./preview-content.vue";
import RemoveButton from "./remove-button.vue";
import type { FileData } from "./uploader.vue";
defineProps<{
fileData: FileData;
}>();
defineEmits<{
remove: [id: string];
"update-alt-text": [id: string, text?: string];
}>();
</script>

View file

@ -1,3 +0,0 @@
<template>
<div class="absolute inset-0 bg-black/70"></div>
</template>

View file

@ -1,26 +0,0 @@
<template>
<div class="absolute bottom-1 right-1 p-1 bg-dark-800 text-white text-xs rounded cursor-default flex flex-row items-center gap-x-1"
aria-label="File size">
{{ formatBytes(size) }}
<iconify-icon v-if="uploading" icon="tabler:loader-2" width="none"
class="size-4 animate-spin text-primary-500" />
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
size: number;
uploading: boolean;
}>();
const formatBytes = (bytes: number) => {
if (bytes === 0) {
return "0 Bytes";
}
const k = 1000;
const dm = 2;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
};
</script>

View file

@ -1,32 +0,0 @@
<template>
<template v-if="file.type.startsWith('image/')">
<img :src="createObjectURL(file)" class="w-full h-full object-cover cursor-default" alt="Preview of file" />
</template>
<template v-else-if="file.type.startsWith('video/')">
<video :src="createObjectURL(file)" class="w-full h-full object-cover cursor-default" />
</template>
<template v-else>
<iconify-icon :icon="getIcon(file.type)" width="none" class="size-6" />
</template>
</template>
<script lang="ts" setup>
const props = defineProps<{
file: File;
}>();
const createObjectURL = URL.createObjectURL;
const getIcon = (mimeType: string) => {
if (mimeType.startsWith("image/")) {
return "tabler:photo";
}
if (mimeType.startsWith("video/")) {
return "tabler:video";
}
if (mimeType.startsWith("audio/")) {
return "tabler:music";
}
return "tabler:file";
};
</script>

View file

@ -1,12 +0,0 @@
<template>
<button class="absolute top-1 right-1 p-1 bg-dark-800 text-white text-xs rounded size-6" role="button" tabindex="0"
@pointerup="$emit('remove')" @keydown.enter="$emit('remove')">
<iconify-icon icon="tabler:x" width="none" class="size-4" />
</button>
</template>
<script lang="ts" setup>
defineEmits<{
remove: [];
}>();
</script>

View file

@ -1,119 +0,0 @@
<template>
<div>
<input type="file" ref="fileInput" @change="handleFileInput" style="display: none" multiple />
<div class="flex flex-row gap-2 overflow-x-auto *:shrink-0 p-1 mb-4" v-if="files.length > 0">
<FilePreview v-for="data in files" :key="data.id" :file-data="data" @remove="(id: string) => emit('removeFile', id)"
@update-alt-text="updateAltText" />
</div>
</div>
</template>
<script lang="ts" setup>
import { nanoid } from "nanoid";
import FilePreview from "./file-preview.vue";
const files = defineModel<FileData[]>("files", {
required: true,
});
export interface FileData {
id: string;
file: File;
uploading: boolean;
progress: number;
api_id?: string;
alt_text?: string;
}
const fileInput = ref<HTMLInputElement | null>(null);
const openFilePicker = () => {
fileInput.value?.click();
};
defineExpose({
openFilePicker,
});
const emit = defineEmits<{
changeFile: [changedFile: FileData];
addFile: [newFile: FileData];
removeFile: [id: string];
}>();
const handleFileInput = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
files.value.push(
...Array.from(target.files).map((file) => ({
id: nanoid(),
file,
progress: 0,
uploading: true,
})),
);
}
};
// Upload new files (not existing, currently being uploaded files)
watch(
files,
(newFiles) => {
for (const data of newFiles) {
if (data.progress === 0) {
uploadFile(data.file);
}
}
},
{
deep: true,
},
);
const updateAltText = (id: string, altText?: string) => {
const foundFile = files.value.find((data) => data.id === id);
if (!foundFile) {
throw new Error("File with ID doesn't exist");
}
emit("changeFile", {
...foundFile,
uploading: true,
});
client.value
?.updateMedia(foundFile.api_id as string, { description: altText })
.then(() => {
emit("changeFile", {
...foundFile,
uploading: false,
});
});
};
const uploadFile = async (file: File) => {
const foundFile = files.value.find((data) => data.file === file);
if (!foundFile) {
throw new Error("File doesn't exist");
}
emit("changeFile", {
...foundFile,
uploading: true,
progress: 0.1,
});
client.value.uploadMedia(file).then((response) => {
const attachment = response.data;
emit("changeFile", {
...foundFile,
uploading: false,
progress: 1.0,
api_id: attachment.id,
});
});
};
</script>

View file

@ -1,64 +0,0 @@
<template>
<Menu.Root :positioning="{
strategy: 'fixed',
}" @update:open="(o) => open = o" :open="open">
<Menu.Trigger :as-child="true">
<slot name="button"></slot>
</Menu.Trigger>
<Teleport to="body">
<div @mousedown="open = false" @touchstart="open = false" v-if="open"
class="fixed inset-0 z-10 bg-black/50">
</div>
<Menu.Positioner :class="isSmallScreen && '!bottom-0 !top-[unset] fixed inset-x-0 w-full !translate-y-0'">
<transition enter-active-class="transition ease-in duration-100"
enter-from-class="transform opacity-0 translate-y-full sm:translate-y-0 scale-95"
enter-to-class="transform translate-y-0 opacity-100 scale-100"
leave-active-class="transition ease-out duration-75"
leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95">
<Menu.Content v-if="open"
:class="['z-20 mt-2 rounded overflow-hidden p-1 space-y-1 bg-dark-700 shadow-lg ring-1 ring-white/10 focus:outline-none min-w-56', id]">
<div v-if="isSmallScreen" class="w-full py-2">
<div class="rounded-full h-1 bg-gray-400 w-12 mx-auto"></div>
</div>
<slot name="items"></slot>
</Menu.Content>
</transition>
</Menu.Positioner>
</Teleport>
</Menu.Root>
</template>
<script setup lang="ts">
import { Menu } from "@ark-ui/vue";
const { width } = useWindowSize();
const isSmallScreen = computed(() => width.value < 768);
const open = ref(false);
const id = useId();
// HACK: Fix the menu children not reacting to touch events as click for some reason
const registerClickHandlers = () => {
const targetElements = document.querySelectorAll(`.${id} [data-part=item]`);
for (const el of targetElements) {
el.addEventListener("touchstart", (e) => {
e.stopPropagation();
e.preventDefault();
// Click all element children
for (const elChild of Array.from(el.children)) {
if (elChild instanceof HTMLElement) {
elChild.click();
}
}
});
}
};
// When opening, register click handlers
watch(open, async (o) => {
if (o) {
await nextTick();
registerClickHandlers();
}
});
</script>

View file

@ -1,66 +0,0 @@
<template>
<slot name="error" v-if="error" v-bind="{ error }">
<div id="error" class="grid min-h-screen place-items-center px-6 py-24 sm:py-32 lg:px-8">
<div class="text-center prose prose-invert max-w-md w-full">
<h1 class="mt-4 text-3xl font-bold tracking-tight text-gray-100 sm:text-5xl">{{ error.title }}
</h1>
<p class="mt-6 text-base leading-7 text-gray-400" v-html="error.message"></p>
<div class="mt-10 grid grid-cols-2 gap-x-6 mx-auto max-w-md">
<Button theme="primary" class="w-full" @click="back">Go back</Button>
<a href="https://github.com/versia-pub/frontend/issues" target="_blank">
<Button theme="secondary" class="w-full">Report an issue</Button>
</a>
</div>
</div>
</div>
</slot>
<slot v-else />
</template>
<script lang="ts" setup>
import Button from "~/packages/ui/components/buttons/button.vue";
const error = ref<{
title: string;
message: string;
code: string;
} | null>(null);
useListen("error", (err) => {
error.value = err;
useHead({
title: err?.title,
});
});
const back = () => {
useRouter().back();
};
</script>
<style>
#error code:not(pre code)::after,
#error code:not(pre code)::before {
content: ""
}
#error code:not(pre code) {
border-radius: .25rem;
padding: .25rem .5rem;
word-wrap: break-word;
background: transparent;
background-color: #ffffff0d;
-webkit-hyphens: none;
hyphens: none;
margin-top: 1rem;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
--tw-ring-color: hsla(0, 0%, 100%, .1)
}
</style>

View file

@ -1,12 +1,12 @@
<template>
<svg class="absolute inset-0 h-full w-full stroke-white/10 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
<svg class="absolute inset-0 h-full w-full stroke-primary/[0.07] [mask-image:radial-gradient(100%_100%_at_top_right,hsl(var(--primary-foreground)),transparent)] pointer-events-none"
aria-hidden="true">
<defs>
<pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1"
patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none"></path>
</pattern>
</defs><svg x="50%" y="-1" class="overflow-visible fill-gray-800/20">
</defs><svg x="50%" y="-1" class="overflow-visible fill-primary/[0.03]">
<path d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
stroke-width="0"></path>
</svg>

View file

@ -1,25 +0,0 @@
<template>
<div v-if="identity" class="bg-dark-800 z-0 p-6 my-5 relative overflow-hidden rounded ring-1 ring-white/5">
<div class="sm:flex sm:items-center sm:justify-between gap-3">
<div class="sm:flex sm:space-x-5 grow">
<Avatar :src="identity.account.avatar"
class="mx-auto shrink-0 size-20 rounded overflow-hidden ring-1 ring-white/10"
:alt="'Your avatar'" />
<div
class="mt-4 text-center flex flex-col justify-center sm:mt-0 sm:text-left bg-dark-800 py-2 px-4 rounded grow ring-1 ring-white/10">
<p class="text-sm font-medium text-gray-300">Welcome back,</p>
<p class="text-xl font-bold text-gray-50 sm:text-2xl line-clamp-1" v-html="display_name"></p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import Avatar from "../avatars/avatar.vue";
const { display_name } = useParsedAccount(
computed(() => identity.value?.account),
settings,
);
</script>

View file

@ -1,13 +0,0 @@
<template>
<TextInput type="checkbox" v-bind="$attrs, $props"
class="rounded disabled:hover:cursor-wait text-primary-700 !size-5" />
</template>
<script lang="ts" setup>
import type { InputHTMLAttributes } from "vue";
import TextInput from "./text-input.vue";
interface Props extends /* @vue-ignore */ InputHTMLAttributes {}
defineProps<Props>();
</script>

View file

@ -1,9 +0,0 @@
<template>
<p class="text-base/6 disabled:opacity-50 sm:text-sm/6 text-red-500">
<slot />
</p>
</template>
<script lang="ts" setup>
</script>

View file

@ -1,9 +0,0 @@
<template>
<div class="mt-3 flex flex-col gap-2">
<slot />
</div>
</template>
<script lang="ts" setup>
</script>

View file

@ -1,9 +0,0 @@
<template>
<div class="flex flex-col gap-1">
<slot />
</div>
</template>
<script lang="ts" setup>
</script>

View file

@ -1,20 +0,0 @@
<template>
<div class="flex flex-row justify-between">
<label v-bind="$attrs"
class="select-none font-semibold text-base/6 disabled:opacity-50 sm:text-sm/6 text-gray-100">
<slot />
</label>
<div :id="`${$attrs.for}-label-slot`"></div>
</div>
</template>
<script lang="ts" setup>
import type { LabelHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ LabelHTMLAttributes {}
defineOptions({
inheritAttrs: false,
});
const props = defineProps<Props>();
</script>

View file

@ -1,73 +0,0 @@
<template>
<TextInput @input="e => content = (e.target as HTMLInputElement).value" v-bind="$attrs, $props" v-model="content"
:type="showPassword ? 'text' : 'password'" :spellcheck="false" />
<Progress.Root class="flex flex-row items-center gap-x-2" v-if="showStrength">
<Progress.Label class="text-xs text-gray-300 font-semibold w-12">
{{ text }}
</Progress.Label>
<Progress.Track class="rounded-sm w-full h-2 duration-300" :style="{
backgroundColor: color,
}">
<Progress.Range />
</Progress.Track>
</Progress.Root>
<Teleport :to="`#${$attrs.id}-label-slot`" v-if="teleport">
<button type="button" @click="showPassword = !showPassword"
class="text-xs ml-auto block mt-2 font-semibold text-gray-400">
<iconify-icon icon="tabler:eye" class="size-4 align-text-top" height="none" />
{{ showPassword ? "Hide password" : "Show password" }}
</button>
</Teleport>
</template>
<script lang="ts" setup>
import { Progress } from "@ark-ui/vue";
import { passwordStrength } from "~/utils/passwords";
const showPassword = ref(false);
const content = ref("");
import type { InputHTMLAttributes } from "vue";
import TextInput from "./text-input.vue";
interface Props extends /* @vue-ignore */ InputHTMLAttributes {
isInvalid?: boolean;
showStrength?: boolean;
}
defineOptions({
inheritAttrs: false,
});
defineProps<Props>();
const teleport = ref(false);
const strength = computed(() => passwordStrength(content.value ?? ""));
const text = computed(() => {
if (strength.value < 6) {
return "Weak";
}
if (strength.value < 7) {
return "Fair";
}
if (strength.value < 11) {
return "Good";
}
return "Strong";
});
const color = computed(() => {
if (strength.value < 6) {
return "red";
}
if (strength.value < 7) {
return "pink";
}
if (strength.value < 11) {
return "yellow";
}
return "green";
});
onMounted(() => {
// Workaround to make sure the teleport is rendered after the parent component
teleport.value = true;
});
</script>

View file

@ -1,75 +0,0 @@
<template>
<div class="relative">
<textarea v-bind="$attrs" ref="textarea" v-model="content"
class="resize-none min-h-48 prose prose-invert w-full p-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 bg-transparent appearance-none focus:!border-none focus:!outline-none disabled:cursor-not-allowed"
aria-label="Compose your message" :autofocus="true"></textarea>
<div v-if="maxCharacters"
:class="['absolute bottom-0 right-0 p-2 text-gray-300 font-semibold text-xs', remainingCharacters < 0 && 'text-red-500']"
aria-live="polite">
{{ remainingCharacters }}
</div>
<EmojiSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedEmoji"
:currently-typing-emoji="currentlyBeingTypedEmoji" @autocomplete="autocompleteEmoji" />
<MentionSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedMention"
:currently-typing-mention="currentlyBeingTypedMention" @autocomplete="autocompleteMention" />
</div>
</template>
<script lang="ts" setup>
import { char, createRegExp, exactly } from "magic-regexp";
import EmojiSuggestbox from "../composer/emoji-suggestbox.vue";
import MentionSuggestbox from "../composer/mention-suggestbox.vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps<{
maxCharacters?: number;
}>();
const modelContent = defineModel<string>("modelContent", {
required: true,
});
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
const { input: content } = useTextareaAutosize({
element: textarea,
input: modelContent,
});
const remainingCharacters = computed(
() =>
(props.maxCharacters ?? Number.POSITIVE_INFINITY) -
(content.value?.length ?? 0),
);
const currentlyBeingTypedEmoji = computed(() => {
const match = content.value?.match(partiallyTypedEmojiValidator);
return match ? (match.at(-1)?.replace(":", "") ?? "") : null;
});
const currentlyBeingTypedMention = computed(() => {
const match = content.value?.match(partiallyTypedMentionValidator);
return match ? (match.at(-1)?.replace("@", "") ?? "") : null;
});
const autocompleteEmoji = (emoji: string) => {
// Replace the end of the string with the emoji
content.value = content.value?.replace(
createRegExp(
exactly(":"),
exactly(currentlyBeingTypedEmoji.value ?? "").notBefore(char),
),
`:${emoji}:`,
);
};
const autocompleteMention = (mention: string) => {
// Replace the end of the string with the mention
content.value = content.value?.replace(
createRegExp(
exactly("@"),
exactly(currentlyBeingTypedMention.value ?? "").notBefore(char),
),
`@${mention} `,
);
};
</script>

View file

@ -1,16 +0,0 @@
<template>
<input :class="['block disabled:opacity-70 disabled:hover:cursor-wait w-full bg-dark-500 rounded-md border-0 py-1.5 text-gray-50 shadow-sm ring-1 ring-inset ring-white/10 placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6',
isInvalid && '!ring-red-600 ring-2']" v-model="value">
</template>
<script setup lang="ts">
import type { InputHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ InputHTMLAttributes {
isInvalid?: boolean;
}
const value = defineModel<string>("value");
defineProps<Props>();
</script>

View file

@ -1,25 +1,34 @@
import {
confirmModalService,
confirmModalWithInputService,
} from "./service.ts";
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
export type ConfirmModalOptions = {
title?: string;
message?: string;
confirmText?: string;
cancelText?: string;
inputType?: "none" | "text" | "textarea";
defaultValue?: string;
};
export function useConfirmModal() {
const confirm = (
options: ConfirmModalOptions,
): Promise<ConfirmModalResult> => {
return confirmModalService.confirm(options);
};
export type ConfirmModalResult = {
confirmed: boolean;
value?: string;
};
const confirmWithInput = (
options: ConfirmModalOptions,
placeholder?: string,
): Promise<ConfirmModalResult> => {
return confirmModalWithInputService.confirm(options, placeholder);
};
class ConfirmModalService {
private modalRef = ref<{
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
} | null>(null);
return {
confirm,
confirmWithInput,
};
register(modal: {
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
}) {
this.modalRef.value = modal;
}
confirm(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
if (!this.modalRef.value) {
throw new Error("Confirmation modal not initialized");
}
return this.modalRef.value.open(options);
}
}
export const confirmModalService = new ConfirmModalService();

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import * as m from "~/paraglide/messages.js";
import type { ConfirmModalOptions, ConfirmModalResult } from "./composable.ts";
defineProps<{
modalOptions: ConfirmModalOptions;
}>();
defineEmits<{
confirm: (result: ConfirmModalResult) => void;
cancel: () => void;
}>();
const inputValue = ref<string>("");
</script>
<template>
<Dialog>
<DialogTrigger :as-child="true">
<slot />
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{{ modalOptions.title }}</DialogTitle>
<DialogDescription>
{{ modalOptions.message }}
</DialogDescription>
</DialogHeader>
<div v-if="modalOptions.inputType === 'text'" class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="confirmInput" class="text-right">{{ m.mean_mean_badger_inspire() }}</Label>
<Input id="confirmInput" v-model="inputValue" class="col-span-3" />
</div>
</div>
<div v-else-if="modalOptions.inputType === 'textarea'" class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="confirmTextarea" class="text-right">{{ m.mean_mean_badger_inspire() }}</Label>
<Textarea id="confirmTextarea" v-model="inputValue" class="col-span-3" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="() => $emit('cancel')">
{{ modalOptions.cancelText }}
</Button>
<Button @click="() => $emit('confirm', {
confirmed: true,
value: inputValue,
})">
{{ modalOptions.confirmText }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,101 @@
<script setup lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import * as m from "~/paraglide/messages.js";
import {
type ConfirmModalOptions,
type ConfirmModalResult,
confirmModalService,
} from "./composable.ts";
const isOpen = ref(false);
const modalOptions = ref<ConfirmModalOptions>({
title: m.antsy_whole_alligator_blink(),
message: "",
inputType: "none",
confirmText: m.antsy_whole_alligator_blink(),
cancelText: m.soft_bold_ant_attend(),
});
const inputValue = ref("");
const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
function open(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
isOpen.value = true;
modalOptions.value = {
title: options.title || m.antsy_whole_alligator_blink(),
message: options.message,
inputType: options.inputType || "none",
confirmText: options.confirmText || m.antsy_whole_alligator_blink(),
cancelText: options.cancelText || m.soft_bold_ant_attend(),
};
inputValue.value = options.defaultValue || "";
return new Promise((resolve) => {
resolvePromise.value = resolve;
});
}
function handleConfirm() {
if (resolvePromise.value) {
resolvePromise.value({
confirmed: true,
value: inputValue.value,
});
}
isOpen.value = false;
}
function handleCancel() {
if (resolvePromise.value) {
resolvePromise.value({
confirmed: false,
});
}
isOpen.value = false;
}
confirmModalService.register({
open,
});
</script>
<template>
<AlertDialog :key="String(isOpen)" :open="isOpen" @update:open="isOpen = false">
<AlertDialogContent class="sm:max-w-[425px] flex flex-col">
<AlertDialogHeader>
<AlertDialogTitle>{{ modalOptions.title }}</AlertDialogTitle>
<AlertDialogDescription v-if="modalOptions.message">
{{ modalOptions.message }}
</AlertDialogDescription>
</AlertDialogHeader>
<Input v-if="modalOptions.inputType === 'text'" v-model="inputValue" />
<Textarea v-else-if="modalOptions.inputType === 'textarea'" v-model="inputValue" rows="6" />
<AlertDialogFooter class="w-full">
<AlertDialogCancel :as-child="true">
<Button variant="outline" @click="handleCancel">
{{ modalOptions.cancelText }}
</Button>
</AlertDialogCancel>
<AlertDialogAction :as-child="true">
<Button @click="handleConfirm">
{{ modalOptions.confirmText }}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View file

@ -1,124 +0,0 @@
<template>
<HeadlessTransitionRoot as="template" :show="isOpen">
<Dialog.Root :open="isOpen" @update:open="handleOpenChange" :close-on-escape="true"
:close-on-interact-outside="true">
<Teleport to="body">
<Dialog.Positioner class="fixed inset-0 z-50 flex items-end md:items-center justify-center md:p-4">
<HeadlessTransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0"
enter-to="opacity-100" leave="ease-in duration-300" leave-from="opacity-100"
leave-to="opacity-0">
<Dialog.Backdrop class="fixed inset-0 bg-black/70 backdrop-blur-sm" />
</HeadlessTransitionChild>
<HeadlessTransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100" leave="ease-in duration-300" leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95">
<Dialog.Content class="relative w-full md:max-w-md p-6 rounded bg-dark-800 ring-1 ring-white/10 shadow-xl">
<Dialog.Title class="mb-4 text-lg font-bold tracking-tight text-gray-100 sm:text-xl">
{{ modalOptions.title || 'Confirm Action' }}
</Dialog.Title>
<div class="mb-6 text-gray-300">
{{ modalOptions.message }}
</div>
<div v-if="withInput" class="mb-4">
<input v-model="inputValue" type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
:placeholder="inputPlaceholder" />
</div>
<div class="mt-10 grid grid-cols-1 md:grid-cols-2 gap-3 *:!py-2">
<Button @click="handleCancel"
theme="outline">
{{ modalOptions.cancelText || 'Cancel' }}
</button>
<Button @click="handleConfirm"
theme="primary">
{{ modalOptions.confirmText || 'Confirm' }}
</button>
</div>
</Dialog.Content>
</HeadlessTransitionChild>
</Dialog.Positioner>
</Teleport>
</Dialog.Root>
</HeadlessTransitionRoot>
</template>
<script setup lang="ts">
import { Dialog } from "@ark-ui/vue";
import {
confirmModalService,
confirmModalWithInputService,
} from "./service.ts";
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
import Button from "~/packages/ui/components/buttons/button.vue";
const isOpen = ref(false);
const modalOptions = ref<ConfirmModalOptions>({ message: "" });
const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
const inputValue = ref("");
const withInput = ref(false);
const inputPlaceholder = ref("");
const open = async (
options: ConfirmModalOptions,
): Promise<ConfirmModalResult> => {
modalOptions.value = options;
isOpen.value = true;
withInput.value = false;
inputValue.value = "";
return new Promise((resolve) => {
resolvePromise.value = resolve;
});
};
const openWithInput = async (
options: ConfirmModalOptions,
placeholder = "Enter value",
): Promise<ConfirmModalResult> => {
modalOptions.value = options;
isOpen.value = true;
withInput.value = true;
inputValue.value = "";
inputPlaceholder.value = placeholder;
return new Promise((resolve) => {
resolvePromise.value = resolve;
});
};
const handleConfirm = () => {
if (resolvePromise.value) {
resolvePromise.value({
confirmed: true,
value: withInput.value ? inputValue.value : undefined,
});
isOpen.value = false;
}
};
const handleCancel = () => {
if (resolvePromise.value) {
resolvePromise.value({ confirmed: false });
isOpen.value = false;
}
};
const handleOpenChange = (open: boolean) => {
if (!open && resolvePromise.value) {
resolvePromise.value({ confirmed: false });
isOpen.value = false;
}
};
// Register the component with the service
confirmModalService.register({
open,
});
confirmModalWithInputService.register({
open: openWithInput,
});
</script>

View file

@ -0,0 +1,9 @@
<template>
<DrawerContent class="flex flex-col gap-2 px-4 mb-4 [&>:nth-child(2)]:mt-4">
<slot />
</DrawerContent>
</template>
<script lang="ts" setup>
import { DrawerContent } from "../ui/drawer";
</script>

View file

@ -1,52 +0,0 @@
import { ref } from "vue";
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
class ConfirmModalService {
private modalRef = ref<{
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
} | null>(null);
register(modal: {
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
}) {
this.modalRef.value = modal;
}
confirm(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
if (!this.modalRef.value) {
throw new Error("Confirmation modal not initialized");
}
return this.modalRef.value.open(options);
}
}
class ConfirmModalWithInputService {
private modalRef = ref<{
open: (
options: ConfirmModalOptions,
placeholder?: string,
) => Promise<ConfirmModalResult>;
} | null>(null);
register(modal: {
open: (
options: ConfirmModalOptions,
placeholder?: string,
) => Promise<ConfirmModalResult>;
}) {
this.modalRef.value = modal;
}
confirm(
options: ConfirmModalOptions,
placeholder?: string,
): Promise<ConfirmModalResult> {
if (!this.modalRef.value) {
throw new Error("Confirmation modal not initialized");
}
return this.modalRef.value.open(options, placeholder);
}
}
export const confirmModalService = new ConfirmModalService();
export const confirmModalWithInputService = new ConfirmModalWithInputService();

View file

@ -1,11 +0,0 @@
export interface ConfirmModalOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
}
export interface ConfirmModalResult {
confirmed: boolean;
value?: string;
}

View file

@ -0,0 +1,31 @@
<template>
<div
class="fixed md:hidden bottom-0 inset-x-0 border-t h-20 bg-background z-10 flex items-center justify-around *:p-7 *:w-full gap-6 p-6">
<Timelines>
<Button variant="ghost" size="icon">
<Home class="!size-6" />
</Button>
</Timelines>
<Button v-if="identity" :as="NuxtLink" href="/notifications" variant="ghost" size="icon">
<Bell class="!size-6" />
</Button>
<AccountSwitcher>
<Button variant="ghost" size="icon">
<User class="!size-6" />
</Button>
</AccountSwitcher>
<Button v-if="identity" variant="default" size="icon" :title="m.salty_aloof_turkey_nudge()"
@click="useEvent('composer:open')">
<Pen class="!size-6" />
</Button>
</div>
</template>
<script lang="ts" setup>
import { Bell, Home, Pen, User } from "lucide-vue-next";
import * as m from "~/paraglide/messages.js";
import { NuxtLink } from "#components";
import AccountSwitcher from "../sidebars/account-switcher.vue";
import { Button } from "../ui/button";
import Timelines from "./timelines.vue";
</script>

View file

@ -0,0 +1,55 @@
<template>
<Drawer>
<DrawerTrigger :as-child="true">
<slot />
</DrawerTrigger>
<DrawerContent>
<DrawerClose v-for="item in timelines.filter(
i => i.requiresLogin ? !!identity : true,
)" :key="item.name" :as-child="true">
<Button :as="NuxtLink" :href="item.url" variant="outline" size="lg" class="w-full">
<component :is="item.icon" />
{{ item.name }}
</Button>
</DrawerClose>
<DialogTitle class="sr-only">{{ m.trite_real_sawfish_drum() }}</DialogTitle>
<DialogDescription class="sr-only">{{ m.trite_real_sawfish_drum() }}</DialogDescription>
</DrawerContent>
</Drawer>
</template>
<script lang="ts" setup>
import { BedSingle, Globe, House, MapIcon } from "lucide-vue-next";
import * as m from "~/paraglide/messages.js";
import { NuxtLink } from "#components";
import DrawerContent from "../modals/drawer-content.vue";
import { Button } from "../ui/button";
import { Drawer, DrawerTrigger } from "../ui/drawer";
const timelines = [
{
name: m.bland_chunky_sparrow_propel(),
url: "/home",
icon: House,
requiresLogin: true,
},
{
name: m.lost_trick_dog_grace(),
url: "/public",
icon: MapIcon,
requiresLogin: false,
},
{
name: m.crazy_game_parrot_pave(),
url: "/local",
icon: BedSingle,
requiresLogin: false,
},
{
name: m.real_tame_moose_greet(),
url: "/global",
icon: Globe,
requiresLogin: false,
},
];
</script>

View file

@ -0,0 +1,151 @@
<template>
<div class="flex flex-row w-full items-stretch justify-around text-sm *:max-w-28 *:w-full *:text-muted-foreground">
<Button variant="ghost" @click="emit('reply')" :title="m.drab_tense_turtle_comfort()" :disabled="!identity">
<Reply class="size-5 text-primary" />
{{ numberFormat(replyCount) }}
</Button>
<Button variant="ghost" @click="liked ? unlike() : like()" :title="liked ? m.vexed_fluffy_clownfish_dance() : m.royal_close_samuel_scold()" :disabled="!identity">
<Heart class="size-5 text-primary" />
{{ numberFormat(likeCount) }}
</Button>
<Button variant="ghost" @click="reblogged ? unreblog() : reblog()" :title="reblogged ? m.lime_neat_ox_stab() : m.aware_helpful_marlin_drop()" :disabled="!identity">
<Repeat class="size-5 text-primary" />
{{ numberFormat(reblogCount) }}
</Button>
<Button variant="ghost" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity">
<Quote class="size-5 text-primary" />
</Button>
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId" @edit="emit('edit')" :note-id="noteId" @delete="emit('delete')">
<Button variant="ghost" :title="m.busy_merry_cowfish_absorb()">
<Ellipsis class="size-5 text-primary" />
</Button>
</Menu>
</div>
</template>
<script lang="ts" setup>
import { Ellipsis, Heart, Quote, Repeat, Reply } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { Button } from "~/components/ui/button";
import * as m from "~/paraglide/messages.js";
import { SettingIds } from "~/settings";
import { confirmModalService } from "../modals/composable";
import Menu from "./menu.vue";
const { noteId } = defineProps<{
replyCount: number;
likeCount: number;
reblogCount: number;
apiNoteString: string;
noteId: string;
isRemote: boolean;
url: string;
remoteUrl: string;
authorId: string;
liked: boolean;
reblogged: boolean;
}>();
const emit = defineEmits<{
edit: [];
reply: [];
quote: [];
delete: [];
}>();
const confirmLikes = useSetting(SettingIds.ConfirmLike);
const confirmReblogs = useSetting(SettingIds.ConfirmReblog);
const like = async () => {
if (confirmLikes.value.value) {
const confirmation = await confirmModalService.confirm({
title: m.slimy_least_ray_aid(),
message: m.stale_new_ray_jolt(),
confirmText: m.royal_close_samuel_scold(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.slimy_candid_tiger_read());
const { data } = await client.value.favouriteStatus(noteId);
toast.dismiss(id);
toast.success(m.mealy_slow_buzzard_commend());
useEvent("note:edit", data);
};
const unlike = async () => {
if (confirmLikes.value.value) {
const confirmation = await confirmModalService.confirm({
title: m.odd_strong_halibut_prosper(),
message: m.slow_blue_parrot_savor(),
confirmText: m.vexed_fluffy_clownfish_dance(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.busy_active_leopard_strive());
const { data } = await client.value.unfavouriteStatus(noteId);
toast.dismiss(id);
toast.success(m.fresh_direct_bear_affirm());
useEvent("note:edit", data);
};
const reblog = async () => {
if (confirmReblogs.value.value) {
const confirmation = await confirmModalService.confirm({
title: m.best_mellow_llama_surge(),
message: m.salty_plain_mallard_gaze(),
confirmText: m.aware_helpful_marlin_drop(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.late_sunny_cobra_scold());
const { data } = await client.value.reblogStatus(noteId);
toast.dismiss(id);
toast.success(m.weird_moving_hawk_lift());
useEvent("note:edit", data.reblog || data);
};
const unreblog = async () => {
if (confirmReblogs.value.value) {
const confirmation = await confirmModalService.confirm({
title: m.main_fancy_octopus_loop(),
message: m.odd_alive_swan_express(),
confirmText: m.lime_neat_ox_stab(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.white_sharp_gorilla_embrace());
const { data } = await client.value.unreblogStatus(noteId);
toast.dismiss(id);
toast.success(m.royal_polite_moose_catch());
useEvent("note:edit", data);
};
const numberFormat = (number = 0) =>
number !== 0
? new Intl.NumberFormat(getLanguageTag(), {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(number)
: undefined;
</script>

View file

@ -0,0 +1,85 @@
<template>
<Dialog>
<Card class="w-full h-full overflow-hidden relative">
<DialogTrigger v-if="attachment.type === 'image'" :as-child="true">
<img :src="attachment.url" :alt="attachment.description ?? undefined"
class="w-full h-full object-contain bg-muted/20" />
</DialogTrigger>
<video v-else-if="attachment.type === 'video' || attachment.type === 'gifv'" :src="attachment.url"
:alt="attachment.description ?? undefined" class="w-full h-full object-cover bg-muted/20" controls />
<audio v-else-if="attachment.type === 'audio'" :src="attachment.url"
:alt="attachment.description ?? undefined" class="w-full h-full object-cover bg-muted/20" controls />
<DialogTrigger v-else :as-child="true">
<div class="w-full h-full flex flex-col items-center justify-center bg-muted/20">
<File class="size-12" />
<span class="text-sm"></span>
</div>
</DialogTrigger>
<!-- Alt text viewer -->
<Popover v-if="attachment.description">
<div class="absolute top-0 right-0 p-2">
<PopoverTrigger :as-child="true">
<Button variant="outline" size="icon" class="[&_svg]:size-6" title="View alt text">
<Captions />
</Button>
</PopoverTrigger>
</div>
<PopoverContent>
<p class="text-sm">{{ attachment.description }}</p>
</PopoverContent>
</Popover>
</Card>
<DialogContent :hide-close="true"
class="fixed inset-0 z-50 w-screen h-screen p-6 duration-200 bg-transparent border-none grid grid-rows-[auto,1fr,auto] overflow-hidden translate-x-0 translate-y-0 max-w-full !animate-none gap-6">
<div class="flex flex-row gap-2 w-full">
<DialogTitle class="sr-only">{{ attachment.type }}</DialogTitle>
<Button as="a" :href="attachment?.url" target="_blank" :download="true" variant="ghost" size="icon"
class="[&_svg]:size-6 ml-auto">
<Download />
</Button>
<DialogClose :as-child="true">
<Button variant="ghost" size="icon" class="[&_svg]:size-6">
<X />
</Button>
</DialogClose>
</div>
<div class="flex items-center justify-center overflow-hidden *:max-h-[80vh] *:max-w-[80vh]">
<img v-if="attachment.type === 'image'" :src="attachment.url" :alt="attachment.description ?? ''"
class="object-contain" />
<video v-else-if="attachment.type === 'video' || attachment.type === 'gifv'" :src="attachment.url"
:alt="attachment.description ?? ''" class="object-cover" controls />
<audio v-else-if="attachment.type === 'audio'" :src="attachment.url" :alt="attachment.description ?? ''"
class="object-cover" controls />
<div v-else class="flex flex-col items-center justify-center">
<File class="size-12" />
<span class="text-sm"></span>
</div>
</div>
<DialogDescription class="flex items-center justify-center">
<Card v-if="attachment.description" class="p-4 max-w-md max-h-48 overflow-auto">
<p class="text-sm">{{ attachment.description }}</p>
</Card>
</DialogDescription>
</DialogContent>
</Dialog>
</template>
<script lang="ts" setup>
import type { Attachment } from "@versia/client/types";
import { Captions, Download, File, X } from "lucide-vue-next";
import { Card } from "~/components/ui/card";
import { Button } from "../ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
defineProps<{
attachment: Attachment;
}>();
</script>

View file

@ -0,0 +1,15 @@
<template>
<!-- [&:has(>:last-child:nth-child(1))] means "when this element has 1 child" -->
<div class="mt-4 grid gap-4 grid-cols-2 *:max-h-56 sm:[&:has(>:last-child:nth-child(1))]:grid-cols-1 sm:[&:has(>:last-child:nth-child(1))>*]:max-h-72">
<Attachment v-for="attachment in attachments" :key="attachment.id" :attachment="attachment" />
</div>
</template>
<script lang="ts" setup>
import type { Attachment as AttachmentType } from "@versia/client/types";
import Attachment from "./attachment.vue";
defineProps<{
attachments: AttachmentType[];
}>();
</script>

View file

@ -0,0 +1,143 @@
<template>
<Alert variant="warning" v-if="(sensitive || contentWarning) && showCw.value"
class="mb-4 py-2 px-4 grid grid-cols-[auto,1fr,auto] gap-2 items-center [&>svg~*]:pl-0 [&>svg+div]:translate-y-0 [&>svg]:static">
<AlertTitle class="sr-only">{{ m.livid_tangy_lionfish_clasp() }}</AlertTitle>
<div>
<TriangleAlert class="size-4" />
</div>
<AlertDescription>
{{ contentWarning || m.sour_seemly_bird_hike() }}
</AlertDescription>
<Button @click="blurred = !blurred" variant="outline" size="sm">{{ blurred ? m.bald_direct_turtle_win() : m.known_flaky_cockroach_dash() }}</Button>
</Alert>
<div ref="container" :class="cn('overflow-y-hidden relative duration-200', (blurred && showCw.value) && 'blur-md')" :style="{
maxHeight: collapsed ? '18rem' : `${container?.scrollHeight}px`,
}">
<div :class="[
'prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline',
$style.content,
]" v-html="content" v-render-emojis="emojis"></div>
<div v-if="isOverflowing && collapsed"
class="absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black/5 to-transparent rounded-b"></div>
<Button v-if="isOverflowing" @click="collapsed = !collapsed"
class="absolute bottom-2 right-1/2 translate-x-1/2">{{
collapsed
? `${m.lazy_honest_mammoth_bump()}${plainContent ? `${m.dark_spare_goldfish_charm({
count: formattedCharacterCount ?? '0',
})}` : "" }`
: m.that_misty_mule_arrive()
}}</Button>
</div>
<Attachments v-if="attachments.length > 0" :attachments="attachments" :class="(blurred && showCw.value) && 'blur-xl'" />
<div v-if="quote" class="mt-4 rounded border overflow-hidden">
<Note :note="quote" :hide-actions="true" :small-layout="true" />
</div>
</template>
<script lang="ts" setup>
import { cn } from "@/lib/utils";
import type { Attachment, Emoji, Status } from "@versia/client/types";
import { TriangleAlert } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import * as m from "~/paraglide/messages.js";
import { type BooleanSetting, SettingIds } from "~/settings";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import Attachments from "./attachments.vue";
import Note from "./note.vue";
const { content, plainContent, sensitive, contentWarning } = defineProps<{
plainContent?: string;
content: string;
quote?: NonNullable<Status["quote"]>;
emojis: Emoji[];
attachments: Attachment[];
sensitive: boolean;
contentWarning?: string;
}>();
const container = ref<HTMLDivElement | null>(null);
const collapsed = ref(true);
const blurred = ref(sensitive || !!contentWarning);
const showCw = useSetting(SettingIds.ShowContentWarning) as Ref<BooleanSetting>;
// max-h-72 is 18rem
const remToPx = (rem: number) =>
rem *
Number.parseFloat(
getComputedStyle(document.documentElement).fontSize || "16px",
);
const isOverflowing = computed(() => {
if (!container.value) {
return false;
}
return container.value.scrollHeight > remToPx(18);
});
const characterCount = plainContent?.length;
const formattedCharacterCount = characterCount
? new Intl.NumberFormat(getLanguageTag()).format(characterCount)
: undefined;
</script>
<style module>
.content pre:has(code) {
word-wrap: normal;
background: transparent;
background-color: #ffffff0d;
border-radius: 0.25rem;
hyphens: none;
margin-top: 1rem;
overflow-x: auto;
padding: 0.75rem 1rem;
tab-size: 4;
white-space: pre;
word-break: normal;
word-spacing: normal;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
var(--tw-shadow, 0 0 #0000);
--tw-ring-color: hsla(0, 0%, 100%, 0.1);
}
.content pre code {
display: block;
padding: 0;
}
.content code:not(pre code)::after,
.content code:not(pre code)::before {
content: "";
}
.content ol li input[type="checkbox"],
.content ul li input[type="checkbox"] {
border-radius: 0.25rem;
margin-bottom: 0.2rem;
margin-right: 0.5rem;
margin-top: 0;
vertical-align: middle;
--tw-text-opacity: 1;
color: var(--theme-primary-400);
}
.content code:not(pre code) {
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
word-wrap: break-word;
background: transparent;
background-color: #ffffff0d;
hyphens: none;
margin-top: 1rem;
tab-size: 4;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
var(--tw-shadow, 0 0 #0000);
--tw-ring-color: hsla(0, 0%, 100%, 0.1);
}
</style>

View file

@ -0,0 +1,33 @@
<template>
<span :class="cn('text-primary group', $props.class)">
<span class="group-hover:hidden">
<slot />
</span>
<span class="hidden group-hover:inline">
<span @click="copyText"
class="select-none cursor-pointer space-x-1">
<Clipboard class="size-4 -translate-y-0.5 inline" />
{{ m.clean_yummy_owl_reside() }}
</span>
</span>
</span>
</template>
<script lang="tsx" setup>
import { cn } from "@/lib/utils";
import { Check, Clipboard } from "lucide-vue-next";
import type { HTMLAttributes } from "vue";
import { toast } from "vue-sonner";
import * as m from "~/paraglide/messages.js";
const { text } = defineProps<{
text: string;
class?: HTMLAttributes["class"];
}>();
const { copy } = useClipboard();
const copyText = () => {
copy(text);
toast.success("Copied to clipboard");
};
</script>

117
components/notes/header.vue Normal file
View file

@ -0,0 +1,117 @@
<template>
<div class="rounded flex flex-row items-center gap-3">
<HoverCard v-model:open="popupOpen" @update:open="() => {
if (!enableHoverCard.value) {
popupOpen = false;
}
}" :open-delay="2000">
<HoverCardTrigger :as-child="true">
<NuxtLink :href="urlAsPath" :class="cn('relative size-14', smallLayout && 'size-8')">
<Avatar :class="cn('size-14 border border-card', smallLayout && 'size-8')" :src="author.avatar"
:name="author.display_name" />
<Avatar v-if="cornerAvatar" class="size-6 border absolute -bottom-1 -right-1" :src="cornerAvatar" />
</NuxtLink>
</HoverCardTrigger>
<HoverCardContent class="w-96">
<SmallCard :account="author" />
</HoverCardContent>
</HoverCard>
<div
:class="cn('flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight', smallLayout && 'text-sm')">
<span class="truncate font-semibold" v-render-emojis="author.emojis">{{
author.display_name
}}</span>
<span class="truncate text-sm tracking-tight">
<CopyableText :text="author.acct">
<span
class="font-semibold bg-gradient-to-tr from-pink-700 dark:from-indigo-400 via-purple-700 dark:via-purple-400 to-indigo-700 dark:to-indigo-400 text-transparent bg-clip-text">
@{{ username }}
</span>
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
</CopyableText>
&middot;
<span class="text-muted-foreground ml-auto tracking-normal" :title="fullTime">{{ timeAgo }}</span>
</span>
</div>
<div class="flex flex-col gap-1 justify-center items-end" v-if="!smallLayout">
<NuxtLink :href="noteUrlAsPath" class="text-xs text-muted-foreground"
:title="visibilities[visibility].text">
<component :is="visibilities[visibility].icon" class="size-5" />
</NuxtLink>
</div>
</div>
</template>
<script lang="ts" setup>
import { cn } from "@/lib/utils";
import type { Account, StatusVisibility } from "@versia/client/types";
import type {
UseTimeAgoMessages,
UseTimeAgoUnitNamesDefault,
} from "@vueuse/core";
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
import { SettingIds } from "~/settings";
import Avatar from "../profiles/avatar.vue";
import SmallCard from "../profiles/small-card.vue";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../ui/hover-card";
import CopyableText from "./copyable-text.vue";
const { createdAt, noteUrl, author, authorUrl } = defineProps<{
cornerAvatar?: string;
visibility: StatusVisibility;
noteUrl: string;
createdAt: Date;
smallLayout?: boolean;
author: Account;
authorUrl: string;
}>();
const [username, instance] = author.acct.split("@");
const digitRegex = /\d/;
const urlAsPath = new URL(authorUrl).pathname;
const noteUrlAsPath = new URL(noteUrl).pathname;
const timeAgo = useTimeAgo(createdAt, {
messages: {
justNow: "now",
past: (n) => (n.match(digitRegex) ? `${n}` : n),
future: (n) => (n.match(digitRegex) ? `in ${n}` : n),
month: (n) => `${n}mo`,
year: (n) => `${n}y`,
day: (n) => `${n}d`,
week: (n) => `${n}w`,
hour: (n) => `${n}h`,
minute: (n) => `${n}m`,
second: (n) => `${n}s`,
invalid: "",
} as UseTimeAgoMessages<UseTimeAgoUnitNamesDefault>,
});
const fullTime = new Intl.DateTimeFormat(getLanguageTag(), {
dateStyle: "medium",
timeStyle: "short",
}).format(createdAt);
const enableHoverCard = useSetting(SettingIds.PopupAvatarHover);
const popupOpen = ref(false);
const visibilities = {
public: {
icon: Globe,
text: "This note is public: it can be seen by anyone.",
},
unlisted: {
icon: LockOpen,
text: "This note is unlisted: it can be seen by anyone with the link.",
},
private: {
icon: Lock,
text: "This note is private: it can only be seen by followers.",
},
direct: {
icon: AtSign,
text: "This note is direct: it can only be seen by mentioned users.",
},
};
</script>

144
components/notes/menu.vue Normal file
View file

@ -0,0 +1,144 @@
<script setup lang="tsx">
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Ban,
Code,
Delete,
ExternalLink,
Flag,
Hash,
Link,
Pencil,
Trash,
} from "lucide-vue-next";
import { toast } from "vue-sonner";
import { confirmModalService } from "~/components/modals/composable.ts";
import * as m from "~/paraglide/messages.js";
import { SettingIds } from "~/settings";
const { authorId, noteId } = defineProps<{
apiNoteString: string;
isRemote: boolean;
url: string;
remoteUrl: string;
authorId: string;
noteId: string;
}>();
const emit = defineEmits<{
edit: [];
delete: [];
}>();
const { copy } = useClipboard();
const loggedIn = !!identity.value;
const authorIsMe = loggedIn && authorId === identity.value?.account.id;
const confirmDeletes = useSetting(SettingIds.ConfirmDelete);
const copyText = (text: string) => {
copy(text);
toast.success(m.flat_nice_worm_dream());
};
const blockUser = async (userId: string) => {
const id = toast.loading(m.top_cute_bison_nudge());
await client.value.blockAccount(userId);
toast.dismiss(id);
toast.success(m.main_weary_racoon_peek());
};
const _delete = async () => {
if (confirmDeletes.value.value) {
const confirmation = await confirmModalService.confirm({
title: m.calm_icy_weasel_twirl(),
message: m.gray_fun_toucan_slide(),
confirmText: m.royal_best_tern_transform(),
inputType: "none",
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.new_funny_fox_boil());
await client.value.deleteStatus(noteId);
toast.dismiss(id);
toast.success(m.green_tasty_bumblebee_beam());
emit("delete");
};
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<slot />
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-56">
<DropdownMenuLabel>{{ m.many_misty_parakeet_fall() }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem v-if="authorIsMe" as="button" @click="emit('edit')">
<Pencil class="mr-2 size-4" />
{{ m.front_lime_grizzly_persist() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
<Code class="mr-2 size-4" />
{{ m.yummy_moving_scallop_sail() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(noteId)">
<Hash class="mr-2 size-4" />
{{ m.sunny_zany_jellyfish_pop() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as="button" @click="copyText(url)">
<Link class="mr-2 size-4" />
{{ m.ago_new_pelican_drip() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" v-if="isRemote" @click="copyText(remoteUrl)">
<Link class="mr-2 size-4" />
{{ m.solid_witty_zebra_walk() }}
</DropdownMenuItem>
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
<ExternalLink class="mr-2 size-4" />
{{ m.active_trite_lark_inspire() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="authorIsMe" />
<DropdownMenuGroup v-if="authorIsMe">
<DropdownMenuItem as="button" :disabled="true">
<Delete class="mr-2 size-4" />
{{ m.real_green_clownfish_pet() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="_delete">
<Trash class="mr-2 size-4" />
{{ m.tense_quick_cod_favor() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
<DropdownMenuItem as="button" :disabled="true">
<Flag class="mr-2 size-4" />
{{ m.great_few_jaguar_rise() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="blockUser(authorId)">
<Ban class="mr-2 size-4" />
{{ m.misty_soft_sparrow_vent() }}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>

49
components/notes/note.vue Normal file
View file

@ -0,0 +1,49 @@
<template>
<Card as="article" class="rounded-none border-0 duration-200 shadow- max-w-full">
<CardHeader class="pb-4" as="header">
<ReblogHeader v-if="note.reblog" :avatar="note.account.avatar" :display-name="note.account.display_name"
:url="reblogAccountUrl" :emojis="note.account.emojis" />
<Header :author="noteToUse.account" :author-url="accountUrl"
:corner-avatar="note.reblog ? note.account.avatar : undefined" :note-url="url"
:visibility="noteToUse.visibility" :created-at="new Date(noteToUse.created_at)"
:small-layout="smallLayout" />
</CardHeader>
<CardContent>
<Content :content="noteToUse.content" :quote="note.quote ?? undefined"
:attachments="noteToUse.media_attachments" :plain-content="noteToUse.plain_content ?? undefined"
:emojis="noteToUse.emojis" :sensitive="noteToUse.sensitive" :content-warning="noteToUse.spoiler_text" />
</CardContent>
<CardFooter v-if="!hideActions" class="p-4 pt-0">
<Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count" :url="url"
:api-note-string="JSON.stringify(note, null, 4)" :reblog-count="noteToUse.reblogs_count"
:remote-url="noteToUse.url" :is-remote="isRemote" :author-id="noteToUse.account.id"
@edit="useEvent('composer:edit', note)" @reply="useEvent('composer:reply', note)"
@quote="useEvent('composer:quote', note)" @delete="useEvent('note:delete', note)"
:note-id="noteToUse.id" :liked="noteToUse.favourited ?? false"
:reblogged="noteToUse.reblogged ?? false" />
</CardFooter>
</Card>
</template>
<script setup lang="ts">
import type { Status } from "@versia/client/types";
import { Card, CardFooter, CardHeader } from "../ui/card";
import Actions from "./actions.vue";
import Content from "./content.vue";
import Header from "./header.vue";
import ReblogHeader from "./reblog-header.vue";
const { note } = defineProps<{
note: Status;
hideActions?: boolean;
smallLayout?: boolean;
}>();
// Notes can be reblogs, in which case the actual thing to render is inside the reblog property
const noteToUse = computed(() => (note.reblog ? note.reblog : note));
const url = wrapUrl(`/@${noteToUse.value.account.acct}/${noteToUse.value.id}`);
const accountUrl = wrapUrl(`/@${noteToUse.value.account.acct}`);
const reblogAccountUrl = wrapUrl(`/@${note.account.acct}`);
const isRemote = noteToUse.value.account.acct.includes("@");
</script>

View file

@ -0,0 +1,24 @@
<template>
<NuxtLink :href="urlAsPath" class="rounded border hover:bg-muted duration-100 text-sm flex flex-row items-center gap-2 px-2 py-1 mb-4">
<Repeat class="size-4 text-primary" />
<Avatar class="size-6 border" :src="avatar" :name="displayName" />
<span class="font-semibold" v-render-emojis="emojis">{{ displayName }}</span>
{{ m.large_vivid_horse_catch() }}
</NuxtLink>
</template>
<script lang="ts" setup>
import type { Emoji } from "@versia/client/types";
import { Repeat } from "lucide-vue-next";
import * as m from "~/paraglide/messages.js";
import Avatar from "../profiles/avatar.vue";
const { url } = defineProps<{
avatar: string;
displayName: string;
emojis: Emoji[];
url: string;
}>();
const urlAsPath = new URL(url).pathname;
</script>

View file

@ -0,0 +1,87 @@
<template>
<div v-if="relationship?.requested_by !== false" class="flex flex-row items-center gap-3 p-4">
<NuxtLink :href="followerUrl" class="relative size-10">
<Avatar class="size-10 border border-border" :src="follower.avatar" :name="follower.display_name" />
</NuxtLink>
<div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight text-sm">
<span class="truncate font-semibold" v-render-emojis="follower.emojis">{{
follower.display_name
}}</span>
<span class="truncate tracking-tight">
<CopyableText :text="follower.acct">
<span
class="font-semibold bg-gradient-to-tr from-pink-700 dark:from-indigo-400 via-purple-700 dark:via-purple-400 to-indigo-700 dark:to-indigo-400 text-transparent bg-clip-text">
@{{ username }}
</span>
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
</CopyableText>
</span>
</div>
</div>
<div v-if="loading" class="flex p-2 items-center justify-center h-12">
<Loader class="size-4 animate-spin" />
</div>
<div v-else-if="relationship?.requested_by === false" class="flex p-2 items-center justify-center h-12">
<Check class="size-4" />
</div>
<div v-else class="grid grid-cols-2 p-2 gap-2">
<Button variant="outline" size="sm" @click="accept">
<Check />
{{ m.slow_these_kestrel_sail() }}
</Button>
<Button variant="ghost" size="sm" @click="reject">
<X />
{{ m.weary_steep_yak_embrace() }}
</Button>
</div>
</template>
<script lang="ts" setup>
import type { Account } from "@versia/client/types";
import { Check, Loader, X } from "lucide-vue-next";
import { toast } from "vue-sonner";
import CopyableText from "~/components/notes/copyable-text.vue";
import { Button } from "~/components/ui/button";
import * as m from "~/paraglide/messages.js";
import Avatar from "../profiles/avatar.vue";
const { follower } = defineProps<{
follower: Account;
}>();
const loading = ref(true);
const followerUrl = `/@${follower.acct}`;
const [username, instance] = follower.acct.split("@");
const { relationship } = useRelationship(client, follower.id);
// TODO: Add "followed" notification
watch(relationship, () => {
if (relationship.value) {
loading.value = false;
}
});
const accept = async () => {
const id = toast.loading(m.cool_slimy_coyote_affirm());
loading.value = true;
const { data } = await client.value.acceptFollowRequest(follower.id);
toast.dismiss(id);
toast.success(m.busy_awful_mouse_jump());
relationship.value = data;
loading.value = false;
};
const reject = async () => {
const id = toast.loading(m.front_sunny_penguin_flip());
loading.value = true;
const { data } = await client.value.rejectFollowRequest(follower.id);
toast.dismiss(id);
toast.success(m.green_flat_mayfly_trust());
relationship.value = data;
loading.value = false;
};
</script>

View file

@ -0,0 +1,104 @@
<template>
<Card>
<Collapsible :default-open="true" v-slot="{ open }">
<Tooltip>
<TooltipTrigger :as-child="true">
<CardHeader v-if="notification.account"
class="flex-row items-center gap-2 px-4 py-2 border-b border-border">
<component :is="icon" class="size-5 shrink-0" />
<Avatar class="size-6 border border-card" :src="notification.account.avatar" :name="notification.account.display_name" />
<span class="font-semibold" v-render-emojis="notification.account.emojis">{{
notification.account.display_name
}}</span>
<CollapsibleTrigger :as-child="true">
<Button variant="ghost" size="icon" class="ml-auto [&_svg]:data-[state=open]:-rotate-180" :title="open ? 'Collapse' : 'Expand'">
<ChevronDown class="duration-200" />
</Button>
</CollapsibleTrigger>
</CardHeader>
</TooltipTrigger>
<TooltipContent>
<p>{{ text }}</p>
</TooltipContent>
</Tooltip>
<CollapsibleContent :as-child="true">
<CardContent class="p-0">
<Note v-if="notification.status" :note="notification.status" :small-layout="true"
:hide-actions="true" />
<FollowRequest v-else-if="notification.type === 'follow_request' && notification.account" :follower="notification.account" />
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</template>
<script lang="ts" setup>
import type { Notification } from "@versia/client/types";
import {
AtSign,
ChevronDown,
Heart,
Repeat,
User,
UserCheck,
UserPlus,
} from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import * as m from "~/paraglide/messages.js";
import Note from "../notes/note.vue";
import Avatar from "../profiles/avatar.vue";
import FollowRequest from "./follow-request.vue";
const { notification } = defineProps<{
notification: Notification;
}>();
const icon = computed(() => {
switch (notification.type) {
case "mention":
return AtSign;
case "reblog":
return Repeat;
case "follow":
return UserPlus;
case "favourite":
return Heart;
case "follow_request":
return User;
case "follow_accept":
return UserCheck;
default:
return null;
}
});
const text = computed(() => {
switch (notification.type) {
case "mention":
return m.fuzzy_orange_tuna_succeed();
case "reblog":
return m.grand_proof_quail_read();
case "follow":
return m.top_steep_scallop_care();
case "favourite":
return m.swift_just_beetle_devour();
case "follow_request":
return m.seemly_short_thrush_bloom();
case "follow_accept":
return m.weird_seemly_termite_scold();
default:
return "";
}
});
</script>

View file

@ -1,42 +0,0 @@
<template>
<Teleport to="body">
<Toaster :toaster="toaster" v-slot="toast">
<Toast.Root
class="rounded-lg w-[calc(100vw-2rem)] sm:w-80 bg-dark-500 duration-200 shadow-lg ring-1 ring-white/10 p-4 [&:nth-child(n+5)]:opacity-0 data-[stack]:!opacity-100 scale-[--scale,100%] translate-x-[--x] translate-y-[--y] z-[--z-index] will-change-transform">
<div class="grid grid-cols-[auto_1fr_auto]">
<div class="shrink-0 h-6 w-6">
<iconify-icon v-if="toast.type === 'success'" icon="tabler:check" height="none"
class="h-6 w-6 text-green-400" aria-hidden="true" />
<iconify-icon v-else-if="toast.type === 'error'" icon="tabler:alert-triangle" height="none"
class="h-6 w-6 text-red-400" aria-hidden="true" />
<iconify-icon v-else-if="toast.type === 'loading'" icon="tabler:loader" height="none"
class="h-6 w-6 text-primary-500 animate-spin" aria-hidden="true" />
<iconify-icon v-else-if="toast.type === 'info'" icon="tabler:info-circle" height="none"
class="h-6 w-6 text-blue-500" aria-hidden="true" />
</div>
<div class="ml-3 flex-1 pt-0.5 shrink-0 min-w-48">
<Toast.Title class="text-sm font-semibold text-gray-50">{{ toast.title }}</Toast.Title>
<Toast.Description class="mt-1 text-sm text-gray-400">{{
toast.description }}</Toast.Description>
</div>
<div class="ml-4 flex shrink-0">
<Toast.CloseTrigger type="button" title="Close this notification"
class="inline-flex rounded-md text-gray-400 hover:text-gray-300 duration-200">
<iconify-icon icon="tabler:x" class="h-5 w-5" aria-hidden="true" />
</Toast.CloseTrigger>
</div>
</div>
</Toast.Root>
</Toaster>
</Teleport>
</template>
<script setup lang="tsx">
import { Toast, Toaster, createToaster } from "@ark-ui/vue";
const toaster = createToaster({ placement: "top-end", overlap: true, gap: 24 });
useListen("notification:new", (notification) => {
toaster.create(notification);
});
</script>

138
components/oauth/login.vue Normal file
View file

@ -0,0 +1,138 @@
<script setup lang="ts">
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { toTypedSchema } from "@vee-validate/zod";
import type { Instance } from "@versia/client";
import { Loader } from "lucide-vue-next";
import { useForm } from "vee-validate";
import * as z from "zod";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import * as m from "~/paraglide/messages.js";
const { instance } = defineProps<{
instance: Instance;
}>();
const isLoading = ref(false);
const ssoConfig = computed(() => instance.sso);
const formSchema = toTypedSchema(
z.object({
identifier: z
.string()
.min(3, {
message: m.aware_house_dolphin_win(),
})
.or(
z.string().email({
message: m.weary_fresh_dragonfly_bless(),
}),
),
password: z.string().min(3, {
message: m.aware_house_dolphin_win(),
}),
}),
);
const form = useForm({
validationSchema: formSchema,
});
const redirectUrl = new URL("/api/auth/login", `https://${instance.domain}`);
const params = useUrlSearchParams();
for (const name of [
"redirect_uri",
"response_type",
"client_id",
"scope",
"state",
]) {
if (params[name]) {
redirectUrl.searchParams.set(name, params[name] as string);
}
}
const issuerRedirectUrl = (issuerId: string) => {
const url = new URL("/oauth/sso", useBaseUrl().value);
for (const name of [
"redirect_uri",
"response_type",
"client_id",
"scope",
"state",
]) {
if (params[name]) {
url.searchParams.set(name, params[name] as string);
}
}
url.searchParams.set("issuer", issuerId);
return url.toString();
};
</script>
<template>
<div class="grid gap-6">
<form @submit="form.submitForm" method="post" :action="redirectUrl.toString()">
<div class="grid gap-6">
<FormField v-slot="{ componentField }" name="identifier">
<FormItem>
<FormLabel>
{{ m.fluffy_soft_wolf_cook() }}
</FormLabel>
<FormControl>
<Input placeholder="petergriffin" type="text" auto-capitalize="none"
auto-complete="idenfifier" auto-correct="off" :disabled="isLoading"
v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>
{{ m.livid_bright_wallaby_quiz() }}
</FormLabel>
<FormControl>
<Input placeholder="hunter2" type="password" auto-capitalize="none"
auto-complete="password" auto-correct="off" :disabled="isLoading"
v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button :disabled="isLoading" type="submit">
<Loader v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ m.fuzzy_sea_moth_absorb() }}
</Button>
</div>
</form>
<div v-if="ssoConfig && ssoConfig.providers.length > 0" class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">
{{ m.tidy_tidy_cow_cut() }}
</span>
</div>
</div>
<div v-if="ssoConfig && ssoConfig.providers.length > 0" class="flex flex-col gap-2">
<Button as="a" :href="issuerRedirectUrl(provider.id)" variant="outline" type="button" :disabled="isLoading" v-for="provider of ssoConfig.providers">
<Loader v-if="isLoading" class="mr-2 animate-spin" />
<img crossorigin="anonymous" :src="provider.icon" :alt="`${provider.name}'s logo`"
class="size-4 mr-2" />
{{ provider.name }}
</Button>
</div>
</div>
</template>

View file

@ -0,0 +1,49 @@
<template>
<Collapsible :as="Card" class="grid items-center p-6 gap-4" v-slot="{ open }">
<div class="grid grid-cols-[1fr,auto] items-center gap-4">
<CardHeader class="space-y-0.5 p-0">
<CardTitle class="text-base">
{{ setting.title() }}
</CardTitle>
<CardDescription>
{{ setting.description() }}
</CardDescription>
</CardHeader>
<CollapsibleTrigger :as-child="true">
<Button variant="outline" size="icon" class="ml-auto [&_svg]:data-[state=open]:-rotate-180"
:title="open ? 'Collapse' : 'Expand'">
<ChevronDown class="duration-200" />
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent :as-child="true">
<CardFooter class="p-1">
<Textarea :rows="10" :model-value="setting.value"
@update:model-value="v => { setting.value = String(v) }" />
</CardFooter>
</CollapsibleContent>
</Collapsible>
</template>
<script lang="ts" setup>
import { ChevronDown } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { Textarea } from "~/components/ui/textarea";
import type { CodeSetting } from "~/settings.ts";
defineModel<CodeSetting>("setting", {
required: true,
});
</script>

View file

@ -0,0 +1,34 @@
<template>
<Collapsible :default-open="true">
<div class="grid grid-cols-[1fr,auto] gap-4 items-baseline">
<h2 class="text-2xl font-semibold tracking-tight">
{{ name }}
</h2>
<CollapsibleTrigger :as-child="true">
<Button size="icon" variant="outline" class="[&_svg]:data-[state=open]:-rotate-180">
<ChevronDown class="duration-200" />
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-3 mt-4">
<Emoji v-for="emoji in emojis" :key="emoji.id" :emoji="emoji" />
</CollapsibleContent>
</Collapsible>
</template>
<script lang="ts" setup>
import type { Emoji as EmojiType } from "@versia/client/types";
import { ChevronDown } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import Emoji from "./emoji.vue";
defineProps<{
emojis: EmojiType[];
name: string;
}>();
</script>

View file

@ -0,0 +1,136 @@
<template>
<DropdownMenu>
<Card
:class="cn('grid hover:cursor-pointer gap-4 items-center p-4', canEdit ? 'grid-cols-[auto,1fr,auto]' : 'grid-cols-[auto,1fr]')">
<Avatar shape="square">
<AvatarImage :src="emoji.url" />
</Avatar>
<CardHeader class="p-0 gap-0 overflow-hidden">
<CardTitle as="span" class="text-sm font-mono truncate">
{{ emoji.shortcode }}
</CardTitle>
<CardDescription>
{{ emoji.global ? m.real_tame_moose_greet() : m.witty_heroic_trout_cry() }}
</CardDescription>
</CardHeader>
<CardFooter class="p-0" v-if="canEdit">
<DropdownMenuTrigger :as-child="true">
<Button variant="ghost" size="icon">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
</CardFooter>
</Card>
<DropdownMenuContent class="min-w-48">
<DropdownMenuLabel class="font-mono">{{ emoji.shortcode }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="editName">
<TextCursorInput class="mr-2 h-4 w-4" />
{{ m.cuddly_such_swallow_hush() }}
</DropdownMenuItem>
<!-- <DropdownMenuItem @click="editCaption">
<Captions class="mr-2 h-4 w-4" />
<span>Add caption</span>
</DropdownMenuItem>
<DropdownMenuSeparator /> -->
<DropdownMenuItem @click="_delete">
<Delete class="mr-2 h-4 w-4" />
{{ m.tense_quick_cod_favor() }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script lang="ts" setup>
import { cn } from "@/lib/utils";
import { type Emoji, RolePermission } from "@versia/client/types";
import { Delete, Ellipsis, TextCursorInput } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { confirmModalService } from "~/components/modals/composable";
import { Avatar } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardTitle,
} from "~/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import * as m from "~/paraglide/messages.js";
const { emoji } = defineProps<{
emoji: Emoji;
}>();
const permissions = usePermissions();
const canEdit =
(!emoji.global &&
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
permissions.value.includes(RolePermission.ManageEmojis);
const editName = async () => {
if (!identity.value) {
return;
}
const result = await confirmModalService.confirm({
title: m.slimy_awful_florian_sail(),
defaultValue: emoji.shortcode,
confirmText: m.teary_antsy_panda_aid(),
inputType: "text",
});
if (result.confirmed) {
const id = toast.loading(m.teary_tame_gull_bless());
try {
const { data } = await client.value.updateEmoji(emoji.id, {
shortcode: result.value,
});
toast.dismiss(id);
toast.success(m.gaudy_lime_bison_adore());
identity.value.emojis = identity.value.emojis.map((e) =>
e.id === emoji.id ? data : e,
);
} catch {
toast.dismiss(id);
}
}
};
const _delete = async () => {
if (!identity.value) {
return;
}
const { confirmed } = await confirmModalService.confirm({
title: m.tense_quick_cod_favor(),
message: m.honest_factual_carp_aspire(),
confirmText: m.tense_quick_cod_favor(),
});
if (confirmed) {
const id = toast.loading(m.weary_away_liger_zip());
try {
await client.value.deleteEmoji(emoji.id);
toast.dismiss(id);
toast.success(m.crisp_whole_canary_tear());
identity.value.emojis = identity.value.emojis.filter(
(e) => e.id !== emoji.id,
);
} catch {
toast.dismiss(id);
}
}
};
</script>

View file

@ -0,0 +1,245 @@
<template>
<Dialog v-model:open="open">
<DialogTrigger>
<slot />
</DialogTrigger>
<DialogContent>
<DialogTitle>
{{ m.whole_icy_puffin_smile() }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ m.frail_great_marten_pet() }}
</DialogDescription>
<form class="p-4 grid gap-6" @submit="submit">
<div v-if="values.image" class="flex items-center justify-around *:size-20 *:p-2 *:rounded *:border *:shadow">
<div class="bg-background">
<img class="h-full object-cover" :src="createObjectURL(values.image as File)" :alt="values.alt" />
</div>
<div class="bg-zinc-700">
<img class="h-full object-cover" :src="createObjectURL(values.image as File)" :alt="values.alt" />
</div>
<div class="bg-zinc-400">
<img class="h-full object-cover" :src="createObjectURL(values.image as File)" :alt="values.alt" />
</div>
<div class="bg-foreground">
<img class="h-full object-cover" :src="createObjectURL(values.image as File)" :alt="values.alt" />
</div>
</div>
<FormField v-slot="{ handleChange, handleBlur }" name="image">
<FormItem>
<FormLabel>
{{ m.active_direct_bear_compose() }}
</FormLabel>
<FormControl>
<Input type="file" accept="image/*" @change="(e: any) => {
handleChange(e);
if (!values.shortcode) {
setFieldValue('shortcode', e.target.files[0].name.replace(/\.[^/.]+$/, ''));
}
}" @blur="handleBlur" :disabled="isSubmitting" />
</FormControl>
<FormDescription>
{{ m.lime_late_millipede_urge() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="shortcode">
<FormItem>
<FormLabel>
{{ m.happy_mild_fox_gleam() }}
</FormLabel>
<FormControl>
<Input v-bind="componentField" :disabled="isSubmitting" />
</FormControl>
<FormDescription>
{{ m.glad_day_kestrel_amaze() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="category">
<FormItem>
<FormLabel>
{{ m.short_cute_jackdaw_comfort() }}
</FormLabel>
<FormControl>
<Input v-bind="componentField" :disabled="isSubmitting" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="alt">
<FormItem>
<FormLabel>
{{ m.watery_left_shrimp_bless() }}
</FormLabel>
<FormControl>
<Textarea rows="2" v-bind="componentField" :disabled="isSubmitting" />
</FormControl>
<FormDescription>
{{ m.weird_fun_jurgen_arise() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, value, handleChange }" v-if="hasEmojiAdmin" name="global"
:as="Card">
<FormItem class="grid grid-cols-[1fr,auto] items-center p-6 gap-2">
<CardHeader class="space-y-0.5 p-0">
<FormLabel :as="CardTitle">
{{ m.pink_sharp_carp_work() }}
</FormLabel>
<CardDescription>
{{ m.dark_pretty_hyena_link() }}
</CardDescription>
</CardHeader>
<FormControl>
<Switch :checked="value" @update:checked="handleChange" v-bind="componentField" :disabled="isSubmitting" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<DialogClose :as-child="true">
<Button variant="outline" :disabled="isSubmitting">
{{ m.soft_bold_ant_attend() }}
</Button>
</DialogClose>
<Button type="submit" variant="default" :disabled="isSubmitting">
{{ m.flat_safe_haddock_gaze() }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import { RolePermission } from "@versia/client/types";
import { useForm } from "vee-validate";
import { toast } from "vue-sonner";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import { Card, CardTitle } from "~/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import * as m from "~/paraglide/messages.js";
const open = ref(false);
const permissions = usePermissions();
const hasEmojiAdmin = permissions.value.includes(RolePermission.ManageEmojis);
const createObjectURL = URL.createObjectURL;
const formSchema = toTypedSchema(
z.object({
image: z
.instanceof(File, {
message: m.sound_topical_gopher_offer(),
})
.refine(
(v) =>
v.size <=
// @ts-expect-error Types aren't updated with this new value yet
(identity.value?.instance.configuration.emojis
.emoji_size_limit ?? 0),
m.orange_weird_parakeet_hug({
// @ts-expect-error Types aren't updated with this new value yet
count: identity.value?.instance.configuration.emojis
.emoji_size_limit,
}),
),
shortcode: z
.string()
.min(1)
.max(
// @ts-expect-error Types aren't updated with this new value yet
identity.value?.instance.configuration.emojis
.max_emoji_shortcode_characters,
m.solid_inclusive_owl_hug({
// @ts-expect-error Types aren't updated with this new value yet
count: identity.value?.instance.configuration.emojis
.max_emoji_shortcode_characters,
}),
)
.regex(emojiValidator),
global: z.boolean().default(false),
category: z
.string()
.max(
64,
m.home_cool_orangutan_hug({
count: 64,
}),
)
.optional(),
alt: z
.string()
.max(
// @ts-expect-error Types aren't updated with this new value yet
identity.value?.instance.configuration.emojis
.max_emoji_description_characters,
m.key_ago_hound_emerge({
// @ts-expect-error Types aren't updated with this new value yet
count: identity.value?.instance.configuration.emojis
.max_emoji_description_characters,
}),
)
.optional(),
}),
);
const { isSubmitting, handleSubmit, values, setFieldValue } = useForm({
validationSchema: formSchema,
});
const submit = handleSubmit(async (values) => {
if (!identity.value) {
return;
}
const id = toast.loading(m.factual_gray_mouse_believe());
try {
const { data } = await client.value.uploadEmoji(
values.shortcode,
values.image,
{
alt: values.alt,
category: values.category,
global: values.global,
},
);
toast.dismiss(id);
toast.success(m.cool_trite_gull_quiz());
identity.value.emojis = [...identity.value.emojis, data];
open.value = false;
} catch {
toast.dismiss(id);
}
});
</script>

View file

@ -0,0 +1,329 @@
<template>
<Card v-if="identity" class="w-full max-h-full overflow-auto">
<form class="p-4 grid gap-6" ref="formRef" @submit="handleSubmit">
<FormField v-slot="{ handleChange, handleBlur }" name="banner">
<FormItem>
<FormLabel>
{{ m.bright_late_osprey_renew() }}
</FormLabel>
<FormControl>
<Input type="file" accept="image/*" @change="handleChange" @blur="handleBlur" />
</FormControl>
<FormDescription>
{{ m.great_level_lamb_sway() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ handleChange, handleBlur }" name="avatar">
<FormItem>
<FormLabel>
{{ m.safe_icy_bulldog_quell() }}
</FormLabel>
<FormControl>
<Input type="file" accept="image/*" @change="handleChange" @blur="handleBlur" />
</FormControl>
<FormDescription>
{{ m.aware_quiet_opossum_catch() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>
{{ m.mild_known_mallard_jolt() }}
</FormLabel>
<FormControl>
<Input v-bind="componentField" />
</FormControl>
<FormDescription>
{{ m.lime_dry_skunk_loop() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>
{{ m.neat_silly_dog_prosper() }}
</FormLabel>
<FormControl>
<Input v-bind="componentField" />
</FormControl>
<FormDescription>
{{ m.petty_plane_tadpole_earn() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bio">
<FormItem>
<FormLabel>
{{ m.next_caring_ladybug_hack() }}
</FormLabel>
<FormControl>
<Textarea rows="10" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ m.stale_just_anaconda_earn() }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="fields">
<FormItem>
<FormLabel>
{{ m.aqua_mealy_toucan_pride() }}
</FormLabel>
<FormControl>
<div class="grid gap-4">
<div v-for="(field, index) in value" :key="index"
class="grid items-center grid-cols-[auto,repeat(3,minmax(0,1fr))] gap-2">
<Button variant="destructive" size="icon"
@click="handleChange([...value.slice(0, index), ...value.slice(index + 1)])">
<Trash />
</Button>
<Input v-model="field.name" placeholder="Name" @update:model-value="e => {
handleChange([...value.slice(0, index), { name: e, value: field.value }, ...value.slice(index + 1)]);
}" />
<Input v-model="field.value" placeholder="Value" class="col-span-2" @update:model-value="e => {
handleChange([...value.slice(0, index), { name: field.name, value: e }, ...value.slice(index + 1)]);
}" />
</div>
<Button type="button" variant="secondary"
@click="handleChange([...value, { name: '', value: '' }])">
{{ m.front_north_eel_gulp() }}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, value, handleChange }" name="bot" :as="Card">
<FormItem class="grid grid-cols-[1fr,auto] items-center p-6 gap-2">
<CardHeader class="space-y-0.5 p-0">
<FormLabel :as="CardTitle">
{{ m.gaudy_each_opossum_play() }}
</FormLabel>
<CardDescription>
{{ m.grassy_acidic_gadfly_cure() }}
</CardDescription>
</CardHeader>
<FormControl>
<Switch :checked="value" @update:checked="handleChange" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, value, handleChange }" name="locked" :as="Card">
<FormItem class="grid grid-cols-[1fr,auto] items-center p-6 gap-2">
<CardHeader class="space-y-0.5 p-0">
<FormLabel :as="CardTitle">
{{ m.dirty_moving_shark_emerge() }}
</FormLabel>
<CardDescription>
{{ m.bright_fun_mouse_boil() }}
</CardDescription>
</CardHeader>
<FormControl>
<Switch :checked="value" @update:checked="handleChange" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, value, handleChange }" name="discoverable" :as="Card">
<FormItem class="grid grid-cols-[1fr,auto] items-center p-6 gap-2">
<CardHeader class="space-y-0.5 p-0">
<FormLabel :as="CardTitle">
{{ m.red_vivid_cuckoo_spark() }}
</FormLabel>
<CardDescription>
{{ m.plain_zany_donkey_dart() }}
</CardDescription>
</CardHeader>
<FormControl>
<Switch :checked="value" @update:checked="handleChange" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
</Card>
<Profile :account="account" class="max-w-lg overflow-auto hidden xl:block" />
</template>
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import type { ResponseError } from "@versia/client";
import { Trash } from "lucide-vue-next";
import { useForm } from "vee-validate";
import { toast } from "vue-sonner";
import { z } from "zod";
import Profile from "~/components/profiles/profile.vue";
import { Button } from "~/components/ui/button";
import { Card, CardTitle } from "~/components/ui/card";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { Textarea } from "~/components/ui/textarea";
import * as m from "~/paraglide/messages.js";
if (!identity.value) {
throw new Error("Identity not found.");
}
const account = ref(identity.value.account);
const formSchema = toTypedSchema(
z.object({
banner: z
.instanceof(File)
.refine(
(v) =>
v.size <=
(identity.value?.instance.configuration.accounts
.header_size_limit ?? 0),
m.civil_icy_ant_mend({
size: identity.value?.instance.configuration.accounts
.header_size_limit,
}),
)
.optional(),
avatar: z
.instanceof(File)
.refine(
(v) =>
v.size <=
(identity.value?.instance.configuration.accounts
.avatar_size_limit ?? 0),
m.zippy_caring_raven_edit({
size: identity.value?.instance.configuration.accounts
.avatar_size_limit,
}),
)
.optional(),
name: z
.string()
.max(
identity.value.instance.configuration.accounts
.max_displayname_characters,
),
username: z
.string()
.regex(/^[a-z0-9_-]+$/, m.still_upper_otter_dine())
.max(
identity.value.instance.configuration.accounts
.max_username_characters,
),
bio: z
.string()
.max(
identity.value.instance.configuration.accounts
.max_note_characters,
),
bot: z.boolean(),
locked: z.boolean(),
discoverable: z.boolean(),
fields: z.array(z.object({ name: z.string(), value: z.string() })),
}),
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
bio: account.value.source?.note ?? "",
bot: account.value.bot ?? false,
locked: account.value.locked ?? false,
discoverable: account.value.discoverable ?? true,
username: account.value.username,
name: account.value.display_name,
fields: account.value.source?.fields ?? [],
},
});
const handleSubmit = form.handleSubmit(async (values) => {
const id = toast.loading(m.jolly_noble_sloth_breathe());
const changedData = {
display_name:
values.name === account.value.display_name
? undefined
: values.name,
username:
values.username === account.value.username
? undefined
: values.username,
note:
values.bio === account.value.source?.note ? undefined : values.bio,
bot: values.bot === account.value.bot ? undefined : values.bot,
locked:
values.locked === account.value.locked ? undefined : values.locked,
discoverable:
values.discoverable === account.value.discoverable
? undefined
: values.discoverable,
// Can't compare two arrays directly in JS, so we need to check if all fields are the same
fields_attributes: values.fields.every((field) =>
account.value.source?.fields?.some(
(f) => f.name === field.name && f.value === field.value,
),
)
? undefined
: values.fields,
header: values.banner ? values.banner : undefined,
avatar: values.avatar ? values.avatar : undefined,
};
if (
Object.values(changedData).filter((v) => v !== undefined).length === 0
) {
toast.dismiss(id);
toast.error(m.tough_alive_niklas_promise());
return;
}
try {
const { data } = await client.value.updateCredentials(
Object.fromEntries(
Object.entries(changedData).filter(([, v]) => v !== undefined),
),
);
toast.dismiss(id);
toast.success(m.spry_honest_kestrel_arrive());
if (identity.value) {
identity.value.account = data;
}
account.value = data;
} catch (e) {
const error = e as ResponseError<{ error: string }>;
toast.dismiss(id);
}
});
const formRef = ref<HTMLFormElement | null>(null);
defineExpose({
submitForm: () => handleSubmit(),
dirty: computed(() => form.meta.value.dirty),
});
</script>

View file

@ -0,0 +1,46 @@
<template>
<Card class="grid grid-cols-[1fr,auto] items-center p-6 gap-2">
<CardHeader class="space-y-0.5 p-0">
<CardTitle class="text-base">
{{ setting.title() }}
</CardTitle>
<CardDescription>
{{ setting.description() }}
</CardDescription>
</CardHeader>
<CardFooter class="p-0">
<Select :model-value="setting.value" @update:model-value="v => { setting.value = v }">
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option of setting.options" :value="option.value">
{{ option.label() }}
</SelectItem>
</SelectContent>
</Select>
</CardFooter>
</Card>
</template>
<script lang="ts" setup>
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import type { EnumSetting } from "~/settings.ts";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
defineModel<EnumSetting>("setting", {
required: true,
});
</script>

View file

@ -0,0 +1,31 @@
<template>
<Card class="grid grid-rows-[1fr,auto] xl:grid-rows-none xl:grid-cols-[1fr,auto] items-center p-6 gap-4">
<CardHeader class="space-y-0.5 p-0">
<CardTitle class="text-base">
{{ setting.title() }}
</CardTitle>
<CardDescription>
{{ setting.description() }}
</CardDescription>
</CardHeader>
<CardFooter class="p-0">
<Input :model-value="setting.value" @update:model-value="v => { setting.value = String(v) }" />
</CardFooter>
</Card>
</template>
<script lang="ts" setup>
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import type { StringSetting } from "~/settings.ts";
defineModel<StringSetting>("setting", {
required: true,
});
</script>

View file

@ -0,0 +1,31 @@
<template>
<Card class="grid grid-cols-[1fr,auto] items-center p-6 gap-2">
<CardHeader class="space-y-0.5 p-0">
<CardTitle class="text-base">
{{ setting.title() }}
</CardTitle>
<CardDescription>
{{ setting.description() }}
</CardDescription>
</CardHeader>
<CardFooter class="p-0">
<Switch :disabled="setting.notImplemented" :checked="setting.value" @update:checked="v => { setting.value = v }" />
</CardFooter>
</Card>
</template>
<script lang="ts" setup>
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Switch } from "~/components/ui/switch";
import type { BooleanSetting } from "~/settings.ts";
defineModel<BooleanSetting>("setting", {
required: true,
});
</script>

View file

@ -0,0 +1,34 @@
<template>
<Avatar :shape="(shape.value as 'circle' | 'square')">
<AvatarFallback v-if="name">
{{ getInitials(name) }}
</AvatarFallback>
<AvatarImage v-if="src" :src="src" :alt="`${name}'s avatar`" />
</Avatar>
</template>
<script lang="ts" setup>
import { SettingIds } from "~/settings";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
const { name } = defineProps<{
src?: string;
name?: string;
}>();
/**
* Gets the initials of any string, even if it's not a name.
* If not a name, it will return the first two characters.
* @param name
*/
const getInitials = (name: string): string => {
const initials = name.match(/\b\w/g) || [];
const firstLetter = initials.shift() || name[0] || "";
const secondLetter = initials.pop() || name[1] || "";
return `${firstLetter}${secondLetter}`.toUpperCase();
};
const shape = useSetting(SettingIds.AvatarShape);
</script>

View file

@ -0,0 +1,131 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<slot />
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-56">
<DropdownMenuLabel>{{ m.spicy_loved_giraffe_empower() }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as="button" @click="copyText(account.username)">
<AtSign class="mr-2 size-4" />
{{ m.cool_dark_tapir_belong() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(JSON.stringify(account, null, 4))">
<Code class="mr-2 size-4" />
{{ m.yummy_moving_scallop_sail() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(account.id)">
<Hash class="mr-2 size-4" />
{{ m.sunny_zany_jellyfish_pop() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as="button" @click="copyText(url)">
<Link class="mr-2 size-4" />
{{ m.ago_new_pelican_drip() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="copyText(account.url)">
<Link class="mr-2 size-4" />
{{ m.solid_witty_zebra_walk() }}
</DropdownMenuItem>
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="account.url">
<ExternalLink class="mr-2 size-4" />
{{ m.active_trite_lark_inspire() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
<DropdownMenuItem as="button" @click="muteUser(account.id)">
<VolumeX class="mr-2 size-4" />
{{ m.spare_wild_mole_intend() }}
</DropdownMenuItem>
<DropdownMenuItem as="button" @click="blockUser(account.id)">
<Ban class="mr-2 size-4" />
{{ m.misty_soft_sparrow_vent() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="isRemote" />
<DropdownMenuGroup v-if="isRemote">
<DropdownMenuItem as="button" @click="refresh">
<RefreshCw class="mr-2 size-4" />
{{ m.slow_chunky_chipmunk_hush() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
<DropdownMenuItem as="button" :disabled="true">
<Flag class="mr-2 size-4" />
{{ m.great_few_jaguar_rise() }}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script lang="ts" setup>
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { Account } from "@versia/client/types";
import {
AtSign,
Ban,
Code,
ExternalLink,
Flag,
Hash,
Link,
RefreshCw,
VolumeX,
} from "lucide-vue-next";
import { toast } from "vue-sonner";
import * as m from "~/paraglide/messages.js";
const { account } = defineProps<{
account: Account;
}>();
const isMe = identity.value?.account.id === account.id;
const isLoggedIn = !!identity.value;
const { copy } = useClipboard();
const copyText = (text: string) => {
copy(text);
toast.success(m.flat_nice_worm_dream());
};
const url = wrapUrl(`/@${account.acct}`);
const isRemote = account.acct.includes("@");
const muteUser = async (userId: string) => {
const id = toast.loading(m.ornate_tidy_coyote_grow());
await client.value.muteAccount(userId);
toast.dismiss(id);
toast.success("User muted");
};
const blockUser = async (userId: string) => {
const id = toast.loading(m.empty_smug_raven_bloom());
await client.value.blockAccount(userId);
toast.dismiss(id);
toast.success("User blocked");
};
const refresh = async () => {
const id = toast.loading(m.real_every_macaw_wish());
await client.value.refetchAccount(account.id);
toast.dismiss(id);
toast.success(m.many_cool_fox_love());
};
</script>

View file

@ -0,0 +1,36 @@
<template>
<Tooltip>
<TooltipTrigger :as-child="true">
<Badge variant="outline" class="gap-1">
<svg viewBox="0 0 22 22" v-if="verified" aria-hidden="true" class="size-4 fill-secondary-foreground">
<g>
<path
d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z">
</path>
</g>
</svg>
<img v-else-if="icon" :src="icon" alt="" class="size-4 rounded-sm" />
{{ name }}
</Badge>
</TooltipTrigger>
<TooltipContent v-if="description">
<p>{{ description }}</p>
</TooltipContent>
</Tooltip>
</template>
<script lang="ts" setup>
import { Badge } from "~/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
defineProps<{
name: string;
description?: string;
icon?: string;
verified?: boolean;
}>();
</script>

View file

@ -0,0 +1,72 @@
<template>
<div :class="['prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" v-html="content" v-render-emojis="emojis">
</div>
</template>
<script lang="ts" setup>
import type { Emoji } from "@versia/client/types";
const { content } = defineProps<{
content: string;
emojis: Emoji[];
}>();
</script>
<style module>
.content pre:has(code) {
word-wrap: normal;
background: transparent;
background-color: #ffffff0d;
border-radius: .25rem;
hyphens: none;
margin-top: 1rem;
overflow-x: auto;
padding: .75rem 1rem;
tab-size: 4;
white-space: pre;
word-break: normal;
word-spacing: normal;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
--tw-ring-color: hsla(0, 0%, 100%, .1)
}
.content pre code {
display: block;
padding: 0
}
.content code:not(pre code)::after,
.content code:not(pre code)::before {
content: ""
}
.content ol li input[type=checkbox],
.content ul li input[type=checkbox] {
border-radius:.25rem;
margin-bottom:0.2rem;
margin-right:.5rem;
margin-top:0;
vertical-align: middle;
--tw-text-opacity:1;
color: var(--theme-primary-400);
}
.content code:not(pre code) {
border-radius: .25rem;
padding: .25rem .5rem;
word-wrap: break-word;
background: transparent;
background-color: #ffffff0d;
hyphens: none;
margin-top: 1rem;
tab-size: 4;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
--tw-ring-color: hsla(0, 0%, 100%, .1)
}
</style>

View file

@ -0,0 +1,17 @@
<template>
<div class="flex flex-col gap-y-4">
<div v-for="field in fields" :key="field.name" class="flex flex-col gap-1 break-words">
<h3 class="font-semibold text-sm" v-render-emojis="emojis">{{ field.name }}</h3>
<div v-html="field.value" class="prose prose-sm prose-zinc dark:prose-invert" v-render-emojis="emojis"></div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Emoji, Field } from "@versia/client/types";
defineProps<{
fields: Field[];
emojis: Emoji[];
}>();
</script>

View file

@ -0,0 +1,23 @@
<template>
<CardHeader class="p-0 relative">
<div class="bg-muted rounded overflow-hidden h-48 md:h-72 w-full">
<img :src="header" alt="" class="object-cover w-full h-full" />
<!-- Shadow overlay at the bottom -->
<div class="absolute bottom-0 w-full h-1/3 bg-gradient-to-b from-black/0 to-black/40"></div>
</div>
<div class="absolute bottom-0 translate-y-1/3 left-4 flex flex-row items-start gap-2">
<Avatar size="lg" class="border" :src="avatar" :name="displayName" />
</div>
</CardHeader>
</template>
<script lang="ts" setup>
import { CardHeader } from "~/components/ui/card";
import Avatar from "./avatar.vue";
defineProps<{
header: string;
avatar: string;
displayName: string;
}>();
</script>

View file

@ -0,0 +1,40 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-row flex-wrap gap-2 *:flex *:items-center *:gap-1 *:text-muted-foreground">
<div>
<CalendarDays class="size-4" />
{{ m.gross_fancy_platypus_seek() }} <span class="text-primary font-semibold">{{ formattedCreationDate }}</span>
</div>
</div>
<div class="flex flex-row flex-wrap gap-2 *:flex *:items-center *:gap-1 *:text-muted-foreground">
<div>
<span class="text-primary font-semibold">{{ noteCount }}</span> {{ m.real_gray_stork_seek() }}
</div>
&middot;
<div>
<span class="text-primary font-semibold">{{ followerCount }}</span> {{ m.teal_helpful_parakeet_hike() }}
</div>
&middot;
<div>
<span class="text-primary font-semibold">{{ followingCount }}</span> {{ m.aloof_royal_samuel_startle() }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { CalendarDays } from "lucide-vue-next";
import * as m from "~/paraglide/messages.js";
const { creationDate } = defineProps<{
creationDate: Date;
noteCount: number;
followerCount: number;
followingCount: number;
}>();
const formattedCreationDate = new Intl.DateTimeFormat(getLanguageTag(), {
month: "long",
year: "numeric",
}).format(creationDate);
</script>

View file

@ -0,0 +1,132 @@
<template>
<Card>
<ProfileHeader :header="account.header" :avatar="account.avatar" :display-name="account.display_name" />
<CardContent class="pt-3 gap-4 flex flex-col">
<div class="flex flex-row justify-end gap-2">
<Button variant="secondary" :disabled="isLoading || relationship?.requested" v-if="!isMe && identity"
@click="relationship?.following ? unfollow() : follow()">
<Loader v-if="isLoading" class="animate-spin" />
<span v-else>
{{ relationship?.following ? m.brief_upper_otter_cuddle() : relationship?.requested ? m.weak_bright_larva_grasp() : m.lazy_major_loris_grasp() }}
</span>
</Button>
<ProfileActions :account="account">
<Button variant="secondary" size="icon">
<Ellipsis />
</Button>
</ProfileActions>
</div>
<div class="flex flex-col -mt-1 gap-2 justify-center">
<CardTitle class="" v-render-emojis="account.emojis">
{{ account.display_name }}
</CardTitle>
<CopyableText :text="account.acct">
<span
class="font-semibold bg-gradient-to-tr from-pink-700 dark:from-indigo-400 via-purple-700 dark:via-purple-400 to-indigo-700 dark:to-indigo-400 text-transparent bg-clip-text">
@{{ username }}
</span>
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
</CopyableText>
</div>
<div class="flex flex-row flex-wrap gap-2 -mx-2" v-if="isDeveloper || account.bot || roles.length > 0">
<ProfileBadge v-if="isDeveloper" :name="m.nice_bad_grizzly_coax()" :description="m.honest_jolly_shell_blend()"
:verified="true" />
<ProfileBadge v-if="account.bot" :name="m.merry_red_shrimp_bump()"
:description="m.sweet_mad_jannes_create()" />
<ProfileBadge v-for="role in roles" :key="role.id" :name="role.name" :description="role.description"
:icon="role.icon" />
</div>
<ProfileContent :content="account.note" :emojis="account.emojis" />
</CardContent>
<CardFooter class="flex-col items-start gap-4">
<ProfileStats :creation-date="new Date(account.created_at || 0)" :follower-count="account.followers_count"
:following-count="account.following_count" :note-count="account.statuses_count" />
<Separator v-if="account.fields.length > 0" />
<ProfileFields v-if="account.fields.length > 0" :fields="account.fields" :emojis="account.emojis" />
</CardFooter>
</Card>
</template>
<script lang="ts" setup>
import type { Account } from "@versia/client/types";
import { Ellipsis, Loader } from "lucide-vue-next";
import { toast } from "vue-sonner";
import CopyableText from "~/components/notes/copyable-text.vue";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardFooter, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import * as m from "~/paraglide/messages.js";
import { SettingIds } from "~/settings";
import { confirmModalService } from "../modals/composable";
import ProfileActions from "./profile-actions.vue";
import ProfileBadge from "./profile-badge.vue";
import ProfileContent from "./profile-content.vue";
import ProfileFields from "./profile-fields.vue";
import ProfileHeader from "./profile-header.vue";
import ProfileStats from "./profile-stats.vue";
const { account } = defineProps<{
account: Account;
}>();
const config = useConfig();
const { relationship, isLoading } = useRelationship(client, account.id);
const isMe = identity.value?.account.id === account.id;
const [username, instance] = account.acct.split("@");
const roles = account.roles.filter((r) => r.visible);
// Get user handle in username@instance format
const handle = account.acct.includes("@")
? account.acct
: `${account.acct}@${identity.value?.instance.domain ?? window.location.host}`;
const isDeveloper = config.DEVELOPER_HANDLES.includes(handle);
const confirmFollows = useSetting(SettingIds.ConfirmFollow);
const follow = async () => {
if (confirmFollows.value.value) {
const confirmation = await confirmModalService.confirm({
title: m.many_fair_capybara_imagine(),
message: m.mellow_yummy_jannes_cuddle({
acct: `@${account.acct}`,
}),
confirmText: m.cuddly_even_tern_loop(),
cancelText: m.soft_bold_ant_attend(),
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.quick_basic_peacock_bubble());
const { data } = await client.value.followAccount(account.id);
toast.dismiss(id);
relationship.value = data;
toast.success(m.awake_quick_cuckoo_smile());
};
const unfollow = async () => {
if (confirmFollows.value.value) {
const confirmation = await confirmModalService.confirm({
title: m.funny_aloof_swan_loop(),
message: m.white_best_dolphin_catch({
acct: `@${account.acct}`,
}),
confirmText: m.cute_polite_oryx_blend(),
cancelText: m.soft_bold_ant_attend(),
});
if (!confirmation.confirmed) {
return;
}
}
const id = toast.loading(m.big_safe_guppy_mix());
const { data } = await client.value.unfollowAccount(account.id);
toast.dismiss(id);
relationship.value = data;
toast.success(m.misty_level_stingray_expand());
};
</script>

View file

@ -0,0 +1,41 @@
<template>
<div class="relative">
<div class="bg-muted rounded overflow-hidden h-32 w-full">
<img :src="account.header" alt="" class="object-cover w-full h-full" />
<!-- Shadow overlay at the bottom -->
<div class="absolute bottom-0 w-full h-1/3 bg-gradient-to-b from-black/0 to-black/40"></div>
</div>
<div class="absolute bottom-0 left-1/2 translate-y-1/3 -translate-x-1/2 flex flex-row items-start gap-2">
<Avatar size="base" class="border" :src="account.avatar" :name="account.display_name" />
</div>
</div>
<div class="flex flex-col justify-center items-center mt-8">
<span class="font-semibold" v-render-emojis="account.emojis">
{{ account.display_name }}
</span>
<CopyableText :text="account.acct" class="text-sm">
<span
class="font-semibold bg-gradient-to-tr from-pink-700 dark:from-indigo-400 via-purple-700 dark:via-purple-400 to-indigo-700 dark:to-indigo-400 text-transparent bg-clip-text">
@{{ username }}
</span>
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
</CopyableText>
</div>
<ProfileContent :content="account.note" :emojis="account.emojis" class="mt-4 max-h-72 overflow-y-auto" />
<Separator v-if="account.fields.length > 0" class="mt-4" />
<ProfileFields v-if="account.fields.length > 0" :fields="account.fields" :emojis="account.emojis" class="mt-4 max-h-48 overflow-y-auto" />
</template>
<script lang="ts" setup>
import type { Account } from "@versia/client/types";
import CopyableText from "../notes/copyable-text.vue";
import Avatar from "./avatar.vue";
import ProfileContent from "./profile-content.vue";
import ProfileFields from "./profile-fields.vue";
const { account } = defineProps<{
account: Account;
}>();
const [username, instance] = account.acct.split("@");
</script>

View file

@ -1,46 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-[1fr,auto,auto] gap-4 items-baseline">
<h2 class="text-xl font-bold">{{ name }}</h2>
<!-- <Button theme="primary">
<Icon icon="tabler:upload" />
<span class="hidden md:block">New</span>
</Button> -->
<Button theme="outline">
<Icon icon="tabler:chevron-up" class="duration-100" :style="{
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
}" @click="collapsed = !collapsed" />
</Button>
</div>
<div ref="container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 overflow-hidden duration-200">
<GridItem v-for="emoji in emojis" :key="emoji.id" :emoji="emoji" />
</div>
</div>
</template>
<script lang="ts" setup>
import type { Emoji } from "@versia/client/types";
import Button from "~/packages/ui/components/buttons/button.vue";
import Icon from "~/packages/ui/components/icons/icon.vue";
import GridItem from "./grid-item.vue";
defineProps<{
emojis: Emoji[];
name: string;
}>();
const collapsed = ref(false);
const container = ref<HTMLDivElement | null>(null);
watch(collapsed, (value) => {
// Use requestAnimationFrame to prevent layout thrashing
requestAnimationFrame(() => {
if (!container.value) {
return;
}
container.value.style.maxHeight = value
? "0px"
: `${container.value.scrollHeight}px`;
});
});
</script>

View file

@ -1,52 +0,0 @@
<template>
<div class="max-w-7xl mx-auto py-12 px-4">
<div class="md:max-w-sm w-full relative mb-4">
<TextInput v-model="search" placeholder="Search" class="pl-8" />
<iconify-icon icon="tabler:search"
class="absolute size-4 top-1/2 left-2.5 transform -translate-y-1/2 text-gray-200" aria-hidden="true"
width="unset" />
</div>
<Category v-if="emojis.length > 0" v-for="([name, emojis]) in categories" :key="name" :emojis="emojis"
:name="name" />
<div v-else class="flex flex-col items-center justify-center gap-2 text-gray-200 text-center p-10">
<span class="text-lg font-semibold">No emojis found.</span>
<span class="text-sm">
You can ask your administrator to add some emojis.
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Emoji } from "@versia/client/types";
import TextInput from "~/components/inputs/text-input.vue";
import Category from "./category.vue";
const emojis = computed(() =>
((identity.value?.emojis as Emoji[] | undefined) ?? []).filter((emoji) =>
emoji.shortcode.toLowerCase().includes(search.value.toLowerCase()),
),
);
const search = ref("");
const categories = computed(() => {
const categories = new Map<string, Emoji[]>();
for (const emoji of emojis.value) {
if (!emoji.category) {
if (!categories.has("Uncategorized")) {
categories.set("Uncategorized", []);
}
categories.get("Uncategorized")?.push(emoji);
continue;
}
if (!categories.has(emoji.category)) {
categories.set(emoji.category, []);
}
categories.get(emoji.category)?.push(emoji);
}
return categories;
});
</script>

View file

@ -1,33 +0,0 @@
<template>
<AdaptiveDropdown>
<template #button>
<Button theme="outline">
<iconify-icon width="none" icon="tabler:dots" class="size-5 text-gray-200"
aria-hidden="true" />
<span class="sr-only">Open menu</span>
</Button>
</template>
<template #items>
<Menu.ItemGroup>
<Menu.Item value="">
<ButtonDropdown icon="tabler:trash" class="w-full">
Delete
</ButtonDropdown>
</Menu.Item>
</Menu.ItemGroup>
</template>
</AdaptiveDropdown>
</template>
<script lang="ts" setup>
import { Menu } from "@ark-ui/vue";
import type { Emoji } from "@versia/client/types";
import ButtonDropdown from "~/components/buttons/button-dropdown.vue";
import AdaptiveDropdown from "~/components/dropdowns/AdaptiveDropdown.vue";
import Button from "~/packages/ui/components/buttons/button.vue";
defineProps<{
emoji: Emoji;
}>();
</script>

View file

@ -1,17 +0,0 @@
<template>
<div class="rounded ring-1 m-1 ring-white/10 grid grid-cols-[auto,1fr] gap-x-4 p-3 bg-dark-400 hover:ring-2 hover:ring-primary-600 duration-100 items-center">
<Avatar :src="emoji.url" class="size-12 rounded bg-transparent" />
<div class="text-ellipsis font-mono text-wrap w-full overflow-hidden">{{ emoji.shortcode }}</div>
<!-- <GridItemMenu :emoji="emoji" /> -->
</div>
</template>
<script lang="ts" setup>
import type { Emoji } from "@versia/client/types";
import Avatar from "~/components/avatars/avatar.vue";
import GridItemMenu from "./grid-item-menu.vue";
defineProps<{
emoji: Emoji;
}>();
</script>

View file

@ -1,18 +0,0 @@
<template>
<Renderer :id="id" v-for="id of settingsIds" :key="id" />
</template>
<script lang="ts" setup>
import {
type SettingIds,
type SettingPages,
getSettingsForPage,
} from "~/settings";
import Renderer from "./renderer.vue";
const props = defineProps<{
page: SettingPages;
}>();
const settingsIds = Object.keys(getSettingsForPage(props.page)) as SettingIds[];
</script>

View file

@ -1,125 +0,0 @@
<template>
<div
class="w-full md:px-8 px-4 py-4 grid justify-center lg:grid-cols-[minmax(auto,_36rem)_1fr] grid-cols-1 gap-4">
<form class="w-full ring-1 ring-inset ring-white/5 pb-5 bg-dark-800 rounded overflow-hidden"
@submit.prevent="save">
<Avatar :src="account?.header" :alt="`${account?.acct}'s header image'`"
class="w-full aspect-[8/3] border-b border-white/10 bg-dark-700 !rounded-none" />
<div class="flex items-start justify-between px-4 py-3">
<Avatar :src="account?.avatar" :alt="`${account?.acct}'s avatar'`"
class="h-32 w-32 -mt-[4.5rem] z-10 shrink-0 rounded ring-2 ring-dark-800" />
</div>
<div class="mt-2 px-4">
<TextInput @input="displayName = ($event.target as HTMLInputElement).value" :value="displayName"
aria-label="Display name" :disabled="loading" />
<div class="mt-2 grid grid-cols-[auto_1fr] items-center gap-x-2">
<iconify-icon icon="tabler:at" width="none" class="size-6" aria-hidden="true" />
<TextInput @input="acct = ($event.target as HTMLInputElement).value" :value="acct"
aria-label="Username" :disabled="loading" />
</div>
<p class="text-gray-300 text-xs mt-2">
Changing your username will break all links to your profile.
</p>
</div>
<div class="mt-3 px-4">
<RichTextboxInput v-model:model-content="note" :max-characters="bio" :disabled="loading"
class="rounded ring-white/10 ring-2 focus:ring-primary-600 px-4 py-2 max-h-[40dvh] max-w-full" />
</div>
<div class="px-4 mt-4 grid grid-cols-2 gap-2">
<Button theme="primary" class="w-full" type="submit" :loading="loading">
<span>Save</span>
</Button>
<Button theme="secondary" class="w-full" @click="revert" type="button" :loading="loading">
<span>Revert</span>
</Button>
</div>
</form>
<div>
<Oidc />
</div>
</div>
</template>
<script lang="ts" setup>
import type { ResponseError } from "@versia/client";
import Button from "~/packages/ui/components/buttons/button.vue";
import Avatar from "../avatars/avatar.vue";
import RichTextboxInput from "../inputs/rich-textbox-input.vue";
import TextInput from "../inputs/text-input.vue";
import Oidc from "./oidc.vue";
const account = computed(() => identity.value?.account);
const note = ref(account.value?.source?.note ?? "");
const displayName = ref(account.value?.display_name ?? "");
const acct = ref(account.value?.acct ?? "");
const bio = computed(
() => identity.value?.instance.configuration.statuses.max_characters ?? 0,
);
const loading = ref(false);
const revert = () => {
useEvent("notification:new", {
title: "Reverted to current bio",
type: "success",
});
note.value = account.value?.source?.note ?? "";
};
const save = async () => {
const changedData = {
display_name:
displayName.value === account.value?.display_name
? undefined
: displayName.value,
username: acct.value === account.value?.acct ? undefined : acct.value,
note:
note.value === account.value?.source?.note ? undefined : note.value,
};
if (
Object.values(changedData).filter((v) => v !== undefined).length === 0
) {
useEvent("notification:new", {
title: "No changes",
type: "error",
});
return;
}
loading.value = true;
try {
const { data } = await client.value.updateCredentials(
Object.fromEntries(
Object.entries(changedData).filter(([, v]) => v !== undefined),
),
);
useEvent("notification:new", {
title: "Profile updated",
type: "success",
});
if (identity.value) {
identity.value.account = data;
}
} catch (e) {
const error = e as ResponseError<{ error: string }>;
useEvent("notification:new", {
title: "Failed to update profile",
description: error.response.data.error,
type: "error",
});
}
loading.value = false;
};
</script>

View file

@ -1,27 +0,0 @@
<template>
<div class="w-full px-8 py-4 bg-dark-700 hover:bg-dark-500 duration-100 h-full">
<div class="max-w-7xl mx-auto h-full">
<SettingBoolean v-if="setting.type === SettingType.Boolean" :id="id" />
<SettingCode v-else-if="setting.type === SettingType.Code" :id="id" />
<SettingEnum v-else-if="setting.type === SettingType.Enum" :id="id" />
<SettingString v-else-if="setting.type === SettingType.String" :id="id" />
<SettingOther v-else :id="id" />
</div>
</div>
</template>
<script lang="ts" setup>
import { type SettingIds, SettingType } from "~/settings";
import SettingBoolean from "./types/Boolean.vue";
import SettingCode from "./types/Code.vue";
import SettingEnum from "./types/Enum.vue";
import SettingOther from "./types/Other.vue";
import SettingString from "./types/String.vue";
const props = defineProps<{
id: SettingIds;
}>();
const setting = useSetting(props.id);
</script>

View file

@ -1,41 +0,0 @@
<template>
<Switch.Root v-model:checked="checked" class="grid grid-cols-[1fr_auto] gap-x-4"
@click="setting.notImplemented ? $event.preventDefault() : undefined"
v-if="setting.type === SettingType.Boolean" @update:checked="c => checked = c">
<Switch.Label :data-disabled="setting.notImplemented ? '' : undefined"
class="row-start-1 select-none text-base/6 data-[disabled]:opacity-50 sm:text-sm/6 text-white font-semibold">
{{
setting.title
}}</Switch.Label>
<p v-if="setting.notImplemented" class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not
implemented
</p>
<p v-else :data-disabled="setting.notImplemented ? '' : undefined"
class="text-base/6 row-start-2 data-[disabled]:opacity-50 sm:text-sm/6 text-gray-300">{{
setting.description }}
</p>
<Switch.Control :data-disabled="setting.notImplemented ? '' : undefined"
:data-checked="checked ? '' : undefined"
class="group col-start-2 relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8 transition duration-0 ease-in-out data-[changing]:duration-200 forced-colors:outline forced-colors:[--switch-bg:Highlight] ring-1 ring-inset bg-white/5 ring-white/15 data-[checked]:bg-[--switch-bg] data-[checked]:ring-[--switch-bg-ring] focus:outline-none focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-blue-500 hover:data-[checked]:ring-[--switch-bg-ring] hover:ring-white/25 data-[disabled]:bg-zinc-200 data-[disabled]:data-[checked]:bg-zinc-200 data-[disabled]:opacity-50 data-[disabled]:bg-white/15 data-[disabled]:data-[checked]:bg-white/15 data-[disabled]:data-[checked]:ring-white/15 [--switch-bg-ring:transparent] [--switch-bg:theme(colors.primary.600/25%)] [--switch-shadow:theme(colors.black/10%)] [--switch:white] [--switch-ring:theme(colors.white/10%)]">
<Switch.Thumb
class="pointer-events-none relative inline-block size-[1.125rem] rounded-full sm:size-3.5 translate-x-0 transition duration-200 ease-in-out border border-transparent bg-white shadow ring-1 ring-black/5 group-data-[checked]:bg-[--switch] group-data-[checked]:shadow-[--switch-shadow] group-data-[checked]:ring-[--switch-ring] group-data-[checked]:translate-x-4 sm:group-data-[checked]:translate-x-3 group-data-[disabled]:group-data-[checked]:bg-white group-data-[disabled]:group-data-[checked]:shadow group-data-[disabled]:group-data-[checked]:ring-black/5" />
</Switch.Control>
<Switch.HiddenInput />
</Switch.Root>
</template>
<script lang="ts" setup>
import { Switch } from "@ark-ui/vue";
import { type SettingIds, SettingType } from "~/settings";
const props = defineProps<{
id: SettingIds;
}>();
const setting = useSetting(props.id);
const checked = ref(setting.value.value as boolean);
watch(checked, (c) => {
setting.value.value = c;
});
</script>

View file

@ -1,26 +0,0 @@
<template>
<div class="flex flex-col gap-y-1">
<h4 class="row-start-1 select-none text-base/6 sm:text-sm/6 text-white font-semibold">{{ setting.title
}}
</h4>
<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>
<script lang="ts" setup>
import type { SettingIds } from "~/settings";
const props = defineProps<{
id: SettingIds;
}>();
const setting = useSetting(props.id);
const content = ref(setting.value.value as string);
watch(content, (c) => {
setting.value.value = c;
});
</script>

View file

@ -1,64 +0,0 @@
<template>
<Select.Root :collection="collection" v-model:model-value="selectedValues">
<Select.Label class="select-none text-base/6 data-[disabled]:opacity-50 sm:text-sm/6 text-white font-semibold">{{ setting.title }}</Select.Label>
<Select.Control class="mt-1">
<Select.Trigger :disabled="setting.notImplemented" class="disabled:opacity-70 disabled:hover:cursor-not-allowed bg-dark-500 rounded-md border-0 py-1.5 text-gray-50 shadow-sm ring-1 ring-inset ring-white/10 sm:text-sm sm:leading-6 w-full md:w-auto min-w-72 text-left px-4 flex flew-row justify-between items-center">
<Select.ValueText placeholder="Select an option" />
<Select.Indicator class="size-4">
<iconify-icon icon="tabler:chevron-down" class="size-4" width="unset" aria-hidden="true" />
</Select.Indicator>
</Select.Trigger>
</Select.Control>
<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
class="z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-dark-700 py-1 text-base shadow-lg ring-1 ring-white/10 focus:outline-none sm:text-sm min-w-72">
<Select.ItemGroup>
<Select.Item v-for="item in collection.items" :key="item.value" :item="item"
:class="['text-gray-100 hover:bg-dark-900 flex flex-row gap-4 justify-between items-center duration-100 relative cursor-default select-none py-2 px-4 group']">
<Select.ItemText
:class="['group-data-[state=checked]:font-semibold font-normal block truncate']">{{
item.label }}</Select.ItemText>
<Select.ItemIndicator
:class="['text-primary-600 hidden group-data-[state=checked]:flex items-center justify-center']">
<iconify-icon icon="tabler:check" class="size-4" width="unset" aria-hidden="true" />
</Select.ItemIndicator>
</Select.Item>
</Select.ItemGroup>
</Select.Content>
</Select.Positioner>
</Teleport>
<Select.HiddenSelect />
</Select.Root>
</template>
<script lang="ts" setup>
import { Select, createListCollection } from "@ark-ui/vue/select";
import type { EnumSetting, SettingIds } from "~/settings";
const props = defineProps<{
id: SettingIds;
}>();
const setting = useSetting(props.id) as Ref<EnumSetting>;
const selectedValues = ref([setting.value.value]);
const collection = createListCollection({
items: setting.value.options.map((option) => ({
value: option.value,
label: option.label,
})),
});
watch(selectedValues, (value) => {
if (!value[0]) {
return;
}
setting.value.value = value[0];
});
</script>

View file

@ -1,18 +0,0 @@
<template>
<div class="grid grid-cols-[1fr_auto] gap-x-4">
<h4 class="row-start-1 select-none text-base/6 sm:text-sm/6 text-white font-semibold">{{ setting.title
}}
</h4>
<p class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not implemented</p>
</div>
</template>
<script lang="ts" setup>
import type { SettingIds } from "~/settings";
const props = defineProps<{
id: SettingIds;
}>();
const setting = useSetting(props.id);
</script>

View file

@ -1,25 +0,0 @@
<template>
<div class="flex flex-col gap-y-1">
<h4 class="row-start-1 select-none text-base/6 sm:text-sm/6 text-white font-semibold">{{ setting.title
}}
</h4>
<TextInput v-model:value="content" class="w-full md:w-auto min-w-72" />
<p v-if="setting.description" class="text-xs mt-2 text-gray-400">{{ setting.description }}</p>
</div>
</template>
<script lang="ts" setup>
import TextInput from "~/components/inputs/text-input.vue";
import type { SettingIds } from "~/settings";
const props = defineProps<{
id: SettingIds;
}>();
const setting = useSetting(props.id);
const content = ref(setting.value.value as string);
watch(content, (c) => {
setting.value.value = c;
});
</script>

View file

@ -1,100 +0,0 @@
<template>
<AdaptiveDropdown>
<template #button>
<slot>
<div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%] duration-100"
v-if="identity">
<div class="shrink-0">
<Avatar class="size-12 rounded ring-1 ring-white/5" :src="identity.account.avatar"
:alt="`${identity.account.acct}'s avatar'`" />
</div>
<div class="flex flex-col items-start p-1 justify-around grow overflow-hidden">
<div class="flex flex-row items-center justify-between w-full">
<div class="font-semibold text-gray-200 text-sm line-clamp-1 break-all">
{{
identity.account.display_name }}
</div>
</div>
<span class="text-gray-400 text-xs line-clamp-1 break-all w-full">
Change account
</span>
</div>
</div>
<ButtonBase theme="secondary" v-else class="w-full !justify-start overflow-hidden">
<Icon icon="tabler:login" class="!size-6" />
<span class="shrink-0 line-clamp-1">Sign In</span>
</ButtonBase>
</slot>
</template>
<template #items>
<div class="p-2">
<h3 class="text-gray-400 text-xs text-center md:text-left uppercase font-semibold">Switch to account
</h3>
</div>
<div class="px-2 py-4 md:py-2 flex flex-col gap-3 max-w-[100vw]">
<Menu.Item value="" v-for="identity of identities" class="hover:scale-[95%] duration-100">
<div class="flex flex-row gap-x-4">
<div class="shrink-0" data-part="item" @click="useEvent('identity:change', identity)">
<Avatar class="h-12 w-12 rounded ring-1 ring-white/5" :src="identity.account.avatar"
:alt="`${identity.account.acct}'s avatar'`" />
</div>
<div data-part="item" class="flex flex-col items-start justify-around grow overflow-hidden"
@click="useEvent('identity:change', identity)">
<div class="flex flex-row items-center justify-between w-full">
<div class="font-semibold text-gray-200 line-clamp-1 break-all">
{{
identity.account.display_name }}
</div>
</div>
<span class="text-gray-400 text-sm line-clamp-1 break-all w-full">
@{{
identity.account.acct
}}
</span>
</div>
<button data-part="item"
class="shrink-0 ml-6 size-12 ring-white/5 ring-1 flex items-center justify-center rounded"
@click="$emit('signOut', identity.id)">
<iconify-icon icon="tabler:logout" class="size-6 text-gray-200" width="none" />
</button>
</div>
</Menu.Item>
<Menu.Item value="" v-if="identity">
<NuxtLink href="/settings" class="w-full">
<ButtonBase theme="ghost" class="w-full !justify-start">
<Icon icon="tabler:adjustments" class="!size-6" />
<span class="shrink-0 line-clamp-1">Settings</span>
</ButtonBase>
</NuxtLink>
</Menu.Item>
<Menu.Item value="">
<ButtonBase @click="$emit('signIn')" theme="ghost" class="w-full !justify-start">
<Icon icon="tabler:user-plus" class="!size-6" />
<span class="shrink-0 line-clamp-1">Add new account</span>
</ButtonBase>
</Menu.Item>
<Menu.Item value="" v-if="!identity">
<NuxtLink href="/register" class="w-full">
<ButtonBase theme="outline" class="w-full !justify-start">
<Icon icon="tabler:certificate" class="!size-6" />
<span class="shrink-0 line-clamp-1">Create new account</span>
</ButtonBase>
</NuxtLink>
</Menu.Item>
</div>
</template>
</AdaptiveDropdown>
</template>
<script lang="ts" setup>
import { Menu } from "@ark-ui/vue";
import ButtonBase from "~/packages/ui/components/buttons/button.vue";
import Icon from "~/packages/ui/components/icons/icon.vue";
import Avatar from "../avatars/avatar.vue";
import AdaptiveDropdown from "../dropdowns/AdaptiveDropdown.vue";
defineEmits<{
signIn: [];
signOut: [identityId: string];
}>();
</script>

Some files were not shown because too many files have changed in this diff Show more