mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
commit
2352bec77b
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
|
||||||
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
|
|
@ -7,7 +7,7 @@ name: Docker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main", "refactor/shadcn"]
|
||||||
# Publish semver tags as releases.
|
# Publish semver tags as releases.
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
@ -61,6 +61,12 @@ jobs:
|
||||||
uses: docker/metadata-action@v5 # v5.0.0
|
uses: docker/metadata-action@v5 # v5.0.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
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)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
|
|
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["inlang.vs-code-extension"]
|
||||||
|
}
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Paraglide doesn't properly work with Bun, so it needs Node
|
||||||
FROM imbios/bun-node:22-alpine AS base
|
FROM imbios/bun-node:22-alpine AS base
|
||||||
|
|
||||||
# Install dependencies into temp directory
|
# Install dependencies into temp directory
|
||||||
|
|
@ -6,6 +7,7 @@ FROM base AS install
|
||||||
|
|
||||||
RUN mkdir -p /temp/dev
|
RUN mkdir -p /temp/dev
|
||||||
COPY package.json bun.lockb /temp/dev/
|
COPY package.json bun.lockb /temp/dev/
|
||||||
|
COPY project.inlang /temp/dev/project.inlang
|
||||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
@ -13,11 +15,13 @@ FROM base AS builder
|
||||||
COPY . /app
|
COPY . /app
|
||||||
COPY --from=install /temp/dev/node_modules /app/node_modules
|
COPY --from=install /temp/dev/node_modules /app/node_modules
|
||||||
RUN cd /app && bun run emojis:generate
|
RUN cd /app && bun run 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.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/frontend"
|
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"
|
LABEL org.opencontainers.image.description="Frontend for the Versia Server Project"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
CMD ["bun", "run", "server/index.mjs"]
|
EXPOSE 3000
|
||||||
|
CMD ["static-web-server", "--config-file", "/etc/config.toml"]
|
||||||
108
README.md
108
README.md
|
|
@ -1,12 +1,42 @@
|
||||||
<p align="center">
|
<div align="center">
|
||||||
<a href="https://versia.pub"><img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110"></a>
|
<a href="https://versia.pub">
|
||||||
</p>
|
<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] Timelines: public, home, local
|
||||||
- [x] Login
|
- [x] Login
|
||||||
|
|
@ -22,12 +52,11 @@
|
||||||
- [x] Note editing
|
- [x] Note editing
|
||||||
- [x] Alt text support everywhere
|
- [x] Alt text support everywhere
|
||||||
- [x] Media uploads
|
- [x] Media uploads
|
||||||
- [x] WCAG 2.2 AAA compliance
|
- [x] WCAG 2.2 AAA testing
|
||||||
- Testing is automated and may not catch all issues, please report any accessibility issues you find.
|
|
||||||
- [x] Settings
|
- [x] Settings
|
||||||
- [x] Profile editing
|
- [x] Profile editing
|
||||||
|
|
||||||
### Browser Support
|
## Browser Support
|
||||||
|
|
||||||
The following browsers are **supported** (issues will be prioritized):
|
The following browsers are **supported** (issues will be prioritized):
|
||||||
- **Chromium**: `110+`
|
- **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.
|
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).
|
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).
|
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:
|
Here are the steps to install Versia-FE manually:
|
||||||
|
|
||||||
#### Docker/Podman
|
### Docker/Podman
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
|
|
@ -73,29 +102,52 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- versia-net
|
- 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.
|
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.
|
- [**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.
|
- [**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.
|
- [**April John**](https://github.com/cutestnekoaqua): Creator and maintainer of the Versia Server ActivityPub bridge.
|
||||||
46
app.vue
46
app.vue
|
|
@ -1,34 +1,46 @@
|
||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<TooltipProvider>
|
||||||
<Component is="style">
|
<Component is="style">
|
||||||
{{ customCss.value }}
|
{{ customCss.value }}
|
||||||
</Component>
|
</Component>
|
||||||
</ClientOnly>
|
|
||||||
<NuxtPwaAssets />
|
<NuxtPwaAssets />
|
||||||
<ClientOnly>
|
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
<NotificationsRenderer />
|
|
||||||
<ConfirmationModal />
|
<ConfirmationModal />
|
||||||
</ClientOnly>
|
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
|
||||||
|
<Toaster class="pointer-events-auto" />
|
||||||
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import "~/styles/theme.css";
|
import "~/styles/theme.css";
|
||||||
|
import "~/styles/index.css";
|
||||||
import { convert } from "html-to-text";
|
import { convert } from "html-to-text";
|
||||||
import "iconify-icon";
|
import ConfirmationModal from "./components/modals/confirm.vue";
|
||||||
import NotificationsRenderer from "./components/notifications/notifications-renderer.vue";
|
import { Toaster } from "./components/ui/sonner";
|
||||||
import { SettingIds } from "./settings";
|
import { setLanguageTag } from "./paraglide/runtime";
|
||||||
import ConfirmationModal from "./components/modals/confirmation.vue";
|
import { type EnumSetting, SettingIds } from "./settings";
|
||||||
// Use SSR-safe IDs for Headless UI
|
// Sin
|
||||||
provideHeadlessUseId(() => useId());
|
//import "~/styles/mcdonalds.css";
|
||||||
|
|
||||||
|
const lang = useLanguage();
|
||||||
|
setLanguageTag(lang.value);
|
||||||
|
|
||||||
const code = useRequestURL().searchParams.get("code");
|
const code = useRequestURL().searchParams.get("code");
|
||||||
const appData = useAppData();
|
const appData = useAppData();
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
const description = useExtendedDescription(client);
|
const description = useExtendedDescription(client);
|
||||||
const customCss = useSetting(SettingIds.CustomCSS);
|
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({
|
useSeoMeta({
|
||||||
titleTemplate: (titleChunk) => {
|
titleTemplate: (titleChunk) => {
|
||||||
|
|
@ -59,7 +71,7 @@ useHead({
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (code && appData.value) {
|
if (code && appData.value && route.path !== "/oauth/code") {
|
||||||
signInWithCode(code, appData.value);
|
signInWithCode(code, appData.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,17 +84,7 @@ useCacheRefresh(client);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@import url("overlayscrollbars/overlayscrollbars.css");
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Inter, sans-serif;
|
font-family: Inter, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.os-scrollbar .os-scrollbar-handle {
|
|
||||||
background: #9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.os-scrollbar .os-scrollbar-handle:hover {
|
|
||||||
background: #6666;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
47
app/spa-loading-template.html
Normal file
47
app/spa-loading-template.html
Normal 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
BIN
assets/ipad-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
assets/ipad-light.webp
Normal file
BIN
assets/ipad-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
21
biome.json
21
biome.json
|
|
@ -1,8 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"ignore": ["node_modules/**/*", "dist/**/*", ".output", ".nuxt"]
|
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
@ -11,6 +10,9 @@
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noConsole": "off"
|
"noConsole": "off"
|
||||||
},
|
},
|
||||||
|
"performance": {
|
||||||
|
"noBarrelFile": "off"
|
||||||
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"noNodejsModules": "off",
|
"noNodejsModules": "off",
|
||||||
"noUndeclaredVariables": "off",
|
"noUndeclaredVariables": "off",
|
||||||
|
|
@ -65,13 +67,20 @@
|
||||||
"noDuplicateElseIf": "error",
|
"noDuplicateElseIf": "error",
|
||||||
"noCommonJs": "error"
|
"noCommonJs": "error"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"ignore": ["node_modules/**/*", "dist/**/*", ".output", ".nuxt"]
|
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 4,
|
"indentWidth": 4
|
||||||
"ignore": ["node_modules/**/*", "dist/**/*", ".output", ".nuxt"]
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": [
|
||||||
|
"node_modules/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
".output",
|
||||||
|
".nuxt",
|
||||||
|
"paraglide"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
components.json
Normal file
18
components.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,222 +1,299 @@
|
||||||
<template>
|
<template>
|
||||||
<RespondingTo v-if="respondingTo" :respondingTo="respondingTo" />
|
<div v-if="relation" class="rounded border overflow-auto max-h-72">
|
||||||
<div class="px-6 pb-4 pt-5">
|
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
|
||||||
<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>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Instance, Status } from "@versia/client/types";
|
import type { ResponseError } from "@versia/client";
|
||||||
import { nanoid } from "nanoid";
|
import type { Status, StatusSource } from "@versia/client/types";
|
||||||
import { computed, onMounted, ref, watch, watchEffect } from "vue";
|
import {
|
||||||
import { useConfig, useEvent, useListen, useMagicKeys } from "#imports";
|
AtSign,
|
||||||
import ActionButtons from "./action-buttons.vue";
|
FilePlus2,
|
||||||
import ContentWarning from "./content-warning.vue";
|
Globe,
|
||||||
import RespondingTo from "./responding-to.vue";
|
LetterText,
|
||||||
import RichTextbox from "./rich-text-box.vue";
|
Loader,
|
||||||
// biome-ignore lint/style/useImportType: <explanation>
|
Lock,
|
||||||
import FileUploader from "./uploader/uploader.vue";
|
LockOpen,
|
||||||
import type { FileData } from "./uploader/uploader.vue";
|
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 } = useMagicKeys();
|
||||||
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
|
const ctrlEnterSend = useSetting(SettingIds.CtrlEnterToSend);
|
||||||
const content = ref("");
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
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 splashes = useConfig().COMPOSER_SPLASHES;
|
watch([Control_Enter, Command_Enter], () => {
|
||||||
const chosenSplash = ref(
|
if (sending.value || !ctrlEnterSend.value.value) {
|
||||||
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");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.value.postStatus(
|
submit();
|
||||||
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[],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.data) {
|
const { relation } = defineProps<{
|
||||||
throw new Error("Failed to send status");
|
relation?: {
|
||||||
|
type: "reply" | "quote" | "edit";
|
||||||
|
note: Status;
|
||||||
|
source?: StatusSource;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const getMentions = () => {
|
||||||
|
if (!relation || relation.type !== "reply") {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
content.value = "";
|
const peopleToMention = relation.note.mentions
|
||||||
loading.value = false;
|
.concat(relation.note.account)
|
||||||
useEvent("composer:send", response.data as Status);
|
// 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");
|
useEvent("composer:close");
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error(error);
|
const { data } = await client.value.postStatus(state.content, {
|
||||||
loading.value = false;
|
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(
|
const uploadFileFromEvent = (e: Event) => {
|
||||||
() => props.instance?.configuration.statuses.max_characters ?? 0,
|
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>
|
</script>
|
||||||
|
|
@ -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>
|
|
||||||
74
components/composer/dialog.vue
Normal file
74
components/composer/dialog.vue
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
126
components/composer/file-preview.vue
Normal file
126
components/composer/file-preview.vue
Normal 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>
|
||||||
19
components/composer/files.vue
Normal file
19
components/composer/files.vue
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="absolute inset-0 bg-black/70"></div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<template>
|
<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">
|
aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1"
|
<pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1"
|
||||||
patternUnits="userSpaceOnUse">
|
patternUnits="userSpaceOnUse">
|
||||||
<path d="M.5 200V.5H200" fill="none"></path>
|
<path d="M.5 200V.5H200" fill="none"></path>
|
||||||
</pattern>
|
</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"
|
<path d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
|
||||||
stroke-width="0"></path>
|
stroke-width="0"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,25 +1,34 @@
|
||||||
import {
|
export type ConfirmModalOptions = {
|
||||||
confirmModalService,
|
title?: string;
|
||||||
confirmModalWithInputService,
|
message?: string;
|
||||||
} from "./service.ts";
|
confirmText?: string;
|
||||||
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
|
cancelText?: string;
|
||||||
|
inputType?: "none" | "text" | "textarea";
|
||||||
export function useConfirmModal() {
|
defaultValue?: string;
|
||||||
const confirm = (
|
|
||||||
options: ConfirmModalOptions,
|
|
||||||
): Promise<ConfirmModalResult> => {
|
|
||||||
return confirmModalService.confirm(options);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmWithInput = (
|
export type ConfirmModalResult = {
|
||||||
options: ConfirmModalOptions,
|
confirmed: boolean;
|
||||||
placeholder?: string,
|
value?: string;
|
||||||
): Promise<ConfirmModalResult> => {
|
|
||||||
return confirmModalWithInputService.confirm(options, placeholder);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
class ConfirmModalService {
|
||||||
confirm,
|
private modalRef = ref<{
|
||||||
confirmWithInput,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confirmModalService = new ConfirmModalService();
|
||||||
|
|
|
||||||
70
components/modals/confirm-inline.vue
Normal file
70
components/modals/confirm-inline.vue
Normal 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>
|
||||||
101
components/modals/confirm.vue
Normal file
101
components/modals/confirm.vue
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
9
components/modals/drawer-content.vue
Normal file
9
components/modals/drawer-content.vue
Normal 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>
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export interface ConfirmModalOptions {
|
|
||||||
title?: string;
|
|
||||||
message: string;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfirmModalResult {
|
|
||||||
confirmed: boolean;
|
|
||||||
value?: string;
|
|
||||||
}
|
|
||||||
31
components/navigation/mobile-navbar.vue
Normal file
31
components/navigation/mobile-navbar.vue
Normal 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>
|
||||||
55
components/navigation/timelines.vue
Normal file
55
components/navigation/timelines.vue
Normal 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>
|
||||||
151
components/notes/actions.vue
Normal file
151
components/notes/actions.vue
Normal 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>
|
||||||
85
components/notes/attachment.vue
Normal file
85
components/notes/attachment.vue
Normal 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>
|
||||||
15
components/notes/attachments.vue
Normal file
15
components/notes/attachments.vue
Normal 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>
|
||||||
143
components/notes/content.vue
Normal file
143
components/notes/content.vue
Normal 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>
|
||||||
33
components/notes/copyable-text.vue
Normal file
33
components/notes/copyable-text.vue
Normal 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
117
components/notes/header.vue
Normal 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>
|
||||||
|
·
|
||||||
|
<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
144
components/notes/menu.vue
Normal 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
49
components/notes/note.vue
Normal 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>
|
||||||
24
components/notes/reblog-header.vue
Normal file
24
components/notes/reblog-header.vue
Normal 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>
|
||||||
87
components/notifications/follow-request.vue
Normal file
87
components/notifications/follow-request.vue
Normal 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>
|
||||||
104
components/notifications/notification.vue
Normal file
104
components/notifications/notification.vue
Normal 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>
|
||||||
|
|
@ -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
138
components/oauth/login.vue
Normal 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>
|
||||||
49
components/preferences/code.vue
Normal file
49
components/preferences/code.vue
Normal 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>
|
||||||
34
components/preferences/emojis/category.vue
Normal file
34
components/preferences/emojis/category.vue
Normal 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>
|
||||||
136
components/preferences/emojis/emoji.vue
Normal file
136
components/preferences/emojis/emoji.vue
Normal 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>
|
||||||
245
components/preferences/emojis/uploader.vue
Normal file
245
components/preferences/emojis/uploader.vue
Normal 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>
|
||||||
329
components/preferences/profile/editor.vue
Normal file
329
components/preferences/profile/editor.vue
Normal 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>
|
||||||
46
components/preferences/select.vue
Normal file
46
components/preferences/select.vue
Normal 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>
|
||||||
31
components/preferences/string.vue
Normal file
31
components/preferences/string.vue
Normal 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>
|
||||||
31
components/preferences/switch.vue
Normal file
31
components/preferences/switch.vue
Normal 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>
|
||||||
34
components/profiles/avatar.vue
Normal file
34
components/profiles/avatar.vue
Normal 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>
|
||||||
131
components/profiles/profile-actions.vue
Normal file
131
components/profiles/profile-actions.vue
Normal 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>
|
||||||
36
components/profiles/profile-badge.vue
Normal file
36
components/profiles/profile-badge.vue
Normal 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>
|
||||||
72
components/profiles/profile-content.vue
Normal file
72
components/profiles/profile-content.vue
Normal 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>
|
||||||
17
components/profiles/profile-fields.vue
Normal file
17
components/profiles/profile-fields.vue
Normal 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>
|
||||||
23
components/profiles/profile-header.vue
Normal file
23
components/profiles/profile-header.vue
Normal 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>
|
||||||
40
components/profiles/profile-stats.vue
Normal file
40
components/profiles/profile-stats.vue
Normal 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>
|
||||||
|
·
|
||||||
|
<div>
|
||||||
|
<span class="text-primary font-semibold">{{ followerCount }}</span> {{ m.teal_helpful_parakeet_hike() }}
|
||||||
|
</div>
|
||||||
|
·
|
||||||
|
<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>
|
||||||
132
components/profiles/profile.vue
Normal file
132
components/profiles/profile.vue
Normal 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>
|
||||||
41
components/profiles/small-card.vue
Normal file
41
components/profiles/small-card.vue
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue