forked from Mirrors/elk
Merge branch 'main' into feature/remove-link-if-matches-preview-URL
This commit is contained in:
commit
445891299f
305 changed files with 13806 additions and 5354 deletions
19
.dockerignore
Normal file
19
.dockerignore
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Modified from .gitignore
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
dist
|
||||||
|
.output
|
||||||
|
.nuxt
|
||||||
|
#.env # Not ignoring this file because it can contain build-related settings.
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vite-inspect
|
||||||
|
.netlify/
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
public/shiki
|
||||||
|
public/emojis
|
||||||
|
|
||||||
|
*~
|
||||||
|
*swp
|
||||||
|
*swo
|
|
@ -1,5 +1,6 @@
|
||||||
NUXT_PUBLIC_TRANSLATE_API=
|
NUXT_PUBLIC_TRANSLATE_API=
|
||||||
NUXT_PUBLIC_DEFAULT_SERVER=
|
NUXT_PUBLIC_DEFAULT_SERVER=
|
||||||
|
NUXT_PUBLIC_PRIVACY_POLICY_URL=
|
||||||
|
|
||||||
# Production only
|
# Production only
|
||||||
NUXT_CLOUDFLARE_ACCOUNT_ID=
|
NUXT_CLOUDFLARE_ACCOUNT_ID=
|
||||||
|
@ -10,6 +11,8 @@ NUXT_CLOUDFLARE_API_TOKEN=
|
||||||
NUXT_STORAGE_DRIVER=
|
NUXT_STORAGE_DRIVER=
|
||||||
NUXT_STORAGE_FS_BASE=
|
NUXT_STORAGE_FS_BASE=
|
||||||
|
|
||||||
|
NUXT_ADMIN_KEY=
|
||||||
|
|
||||||
NUXT_PUBLIC_DISABLE_VERSION_CHECK=
|
NUXT_PUBLIC_DISABLE_VERSION_CHECK=
|
||||||
|
|
||||||
NUXT_GITHUB_CLIENT_ID=
|
NUXT_GITHUB_CLIENT_ID=
|
||||||
|
|
|
@ -3,5 +3,9 @@
|
||||||
*.ico
|
*.ico
|
||||||
*.toml
|
*.toml
|
||||||
*.patch
|
*.patch
|
||||||
|
*.txt
|
||||||
|
Dockerfile
|
||||||
|
public/
|
||||||
https-dev-config/localhost.crt
|
https-dev-config/localhost.crt
|
||||||
https-dev-config/localhost.key
|
https-dev-config/localhost.key
|
||||||
|
Dockerfile
|
||||||
|
|
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
|
@ -1 +1,2 @@
|
||||||
github: [antfu, patak-dev, sxzz, danielroe]
|
github: [elk-zone]
|
||||||
|
open_collective: elk
|
||||||
|
|
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
name: 🐞 Bug report
|
||||||
|
about: Report an issue
|
||||||
|
labels: ['s: pending triage', 'c: bug']
|
||||||
|
---
|
56
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
56
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -1,56 +0,0 @@
|
||||||
name: 🐞 Bug report
|
|
||||||
description: Report an issue
|
|
||||||
labels: ['s: pending triage', 'c: bug']
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for taking the time to fill out this bug report!
|
|
||||||
|
|
||||||
If you are unsure whether your problem is a bug or not, you can check the following:
|
|
||||||
|
|
||||||
- use our [Discord community](https://chat.elk.zone)
|
|
||||||
- open a new [discussion](https://github.com/elk-zone/elk/discussions) and ask your question there
|
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
id: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: Pre-Checks
|
|
||||||
description: Before submitting the issue, please make sure you do the following
|
|
||||||
options:
|
|
||||||
# - label: Follow our [Code of Conduct](https://github.com/elk-zone/elk/blob/main/CODE_OF_CONDUCT.md).
|
|
||||||
# required: true
|
|
||||||
# - label: Read the [Contributing Guidelines](https://github.com/elk-zone/elk/blob/main/CONTRIBUTING.md).
|
|
||||||
# required: true
|
|
||||||
- label: Check that there isn't [already an issue](https://github.com/elk-zone/elk/issues) that reports the same bug to avoid creating a duplicate.
|
|
||||||
required: true
|
|
||||||
- label: Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/elk-zone/elk/discussions) or join our [Discord Chat Server](https://chat.elk.zone).
|
|
||||||
required: true
|
|
||||||
- label: Providing a screenshot or video to reproduce the issue or show visually what was meant.
|
|
||||||
required: true
|
|
||||||
- label: I am willing to provide a PR.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: bug-description
|
|
||||||
attributes:
|
|
||||||
label: Describe the bug
|
|
||||||
description: A clear and concise description of what the bug is.
|
|
||||||
placeholder: I am doing ... What I expect is ... What actually happening is ...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: reproduction
|
|
||||||
attributes:
|
|
||||||
label: Reproduction video or screenshot
|
|
||||||
description: |
|
|
||||||
A video or screenshot that visually shows the issue.
|
|
||||||
**Tip:** You can attach images or recordings files by clicking this area to highlight it and then dragging files in.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: |
|
|
||||||
Anything else relevant? Please tell us here, e.g. your used web browser and/or you are on desktop or mobile.
|
|
||||||
**Tip:** You can attach images or recordings files by clicking this area to highlight it and then dragging files in.
|
|
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
name: 🚀 New feature proposal
|
||||||
|
about: Propose a new feature
|
||||||
|
labels: 's: pending triage'
|
||||||
|
---
|
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
@ -1,35 +0,0 @@
|
||||||
name: 🚀 New feature proposal
|
|
||||||
description: Propose a new feature
|
|
||||||
labels: ['s: pending triage'] # This will automatically assign the 's: pending triage' label
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: Thanks for your interest in the project and taking the time to fill out this feature report!
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: feature-description
|
|
||||||
attributes:
|
|
||||||
label: Clear and concise description of the problem
|
|
||||||
description: 'As a user I want [goal / wish] so that [benefit]. If you intend to submit a PR for this issue, tell us in the description. Thanks!'
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: suggested-solution
|
|
||||||
attributes:
|
|
||||||
label: Suggested solution
|
|
||||||
description: 'In section [xy] we could provide following feature...'
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternative
|
|
||||||
attributes:
|
|
||||||
label: Alternative
|
|
||||||
description: Clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Any other context about the feature request here.
|
|
5
.github/ISSUE_TEMPLATE/freestyle.md
vendored
5
.github/ISSUE_TEMPLATE/freestyle.md
vendored
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
name: Freestyle Report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
labels: 'pending triage' # This will automatically assign the 'pending triage' label
|
|
||||||
---
|
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -1,5 +1,7 @@
|
||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
|
46
.github/workflows/docker.yml
vendored
Normal file
46
.github/workflows/docker.yml
vendored
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
name: build & push docker container
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Docker meta
|
||||||
|
id: metal
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/elk-zone/elk
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.metal.outputs.tags }}
|
||||||
|
labels: ${{ steps.metal.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
@ -1,5 +1,8 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
|
|
1
.npmrc
1
.npmrc
|
@ -1,3 +1,4 @@
|
||||||
shamefully-hoist=true
|
shamefully-hoist=true
|
||||||
strict-peer-dependencies=false
|
strict-peer-dependencies=false
|
||||||
shell-emulator=true
|
shell-emulator=true
|
||||||
|
ignore-workspace-root-check=true
|
||||||
|
|
7
.stackblitz/codeflow.json
Normal file
7
.stackblitz/codeflow.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"bot": {
|
||||||
|
"issues": {
|
||||||
|
"trigger": "all-issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,9 +4,13 @@ Hi! We are really excited that you are interested in contributing to Elk. Before
|
||||||
|
|
||||||
Refer also to https://github.com/antfu/contribute.
|
Refer also to https://github.com/antfu/contribute.
|
||||||
|
|
||||||
## Set up your local development environment
|
### Online
|
||||||
|
|
||||||
The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/) (Note: on Linux in a standard Node 16+ environment, you should follow the instructions to install via Node's `corepack` rather than using the `curl` command).
|
You can use [StackBlitz Codeflow](https://stackblitz.com/codeflow) to fix bugs or implement features. You'll also see a Codeflow button on PRs to review them without a local setup. Once the elk repo has been cloned in Codeflow, the dev server will start automatically and print the URL to open the App. You should receive a prompt in the bottom-right suggesting to open it in the Editor or in another Tab. To learn more, check out the [Codeflow docs](https://developer.stackblitz.com/codeflow/what-is-codeflow).
|
||||||
|
|
||||||
|
[![Open in Codeflow](https://developer.stackblitz.com/img/open_in_codeflow.svg)](https://pr.new/elk-zone/elk)
|
||||||
|
|
||||||
|
### Local Setup
|
||||||
|
|
||||||
To develop and test the Elk package:
|
To develop and test the Elk package:
|
||||||
|
|
||||||
|
@ -14,22 +18,37 @@ To develop and test the Elk package:
|
||||||
|
|
||||||
2. Ensure using the latest Node.js (16.x)
|
2. Ensure using the latest Node.js (16.x)
|
||||||
|
|
||||||
3. Elk uses pnpm v7, you must enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`.
|
3. The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/) v7. To use it you must first enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`. (Note: on Linux in a standard Node 16+ environment, you should follow the instructions to install via Node's `corepack` rather than using the `curl` command)
|
||||||
|
|
||||||
4. Check out a branch where you can work and commit your changes:
|
4. Check out a branch where you can work and commit your changes:
|
||||||
```shell
|
```shell
|
||||||
git checkout -b my-new-branch
|
git checkout -b my-new-branch
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Run `pnpm i` in Elk's root folder
|
1. Run `pnpm i` in Elk's root folder
|
||||||
|
|
||||||
6. Run `pnpm nuxi prepare` in Elk's root folder
|
2. Run `pnpm nuxi prepare` in Elk's root folder
|
||||||
|
|
||||||
7. Run `pnpm dev` in Elk's root folder to start dev server or `pnpm dev:mocked` to start dev server with `@elkdev@universeodon.com` user.
|
3. Run `pnpm dev` in Elk's root folder to start dev server or `pnpm dev:mocked` to start dev server with `@elkdev@universeodon.com` user.
|
||||||
|
|
||||||
|
We recommend installing [ni](https://github.com/antfu/ni#ni), that will use the right package manager in each of your projects. If `ni` is installed, you can instead run:
|
||||||
|
|
||||||
|
```
|
||||||
|
ni
|
||||||
|
nr dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
|
||||||
|
|
||||||
|
```
|
||||||
|
nr test
|
||||||
|
```
|
||||||
|
|
||||||
### Running PWA on dev server
|
### Running PWA on dev server
|
||||||
|
|
||||||
In order to run Elk with PWA enabled, run `pnpm run dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user.
|
In order to run Elk with PWA enabled, run `pnpm dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user.
|
||||||
|
|
||||||
You should test the Elk PWA application on private browsing mode on any Chromium based browser: will not work on Firefox and Safari.
|
You should test the Elk PWA application on private browsing mode on any Chromium based browser: will not work on Firefox and Safari.
|
||||||
|
|
||||||
|
@ -72,11 +91,19 @@ We are using [vue-i18n](https://vue-i18n.intlify.dev/) via [nuxt-i18n](https://i
|
||||||
|
|
||||||
### Adding a new language
|
### Adding a new language
|
||||||
|
|
||||||
1. Add a new file in [locales](../locales) folder with the language code as the filename.
|
1. Add a new file in [locales](./locales) folder with the language code as the filename.
|
||||||
2. Copy [en-US](../locales/en-US.json) and translate the strings.
|
2. Copy [en-US](./locales/en-US.json) and translate the strings.
|
||||||
3. Add the language to the `locales` array in [config/i18n.ts](../config/i18n.ts#L13)
|
3. Add the language to the `locales` array in [config/i18n.ts](./config/i18n.ts#L61), below `en` and `ar`:
|
||||||
4. If the language is `right-to-left`, add `dir` option with `rtl` value, for example, for [ar-EG](../config/i18n.ts#L63)
|
- If your language have multiple country variants, add the generic one for language only (only if there are a lot of common entries, you can always add it as a new one)
|
||||||
5. If the language requires special pluralization rules, add `pluralRule` callback option, for example, for [ar-EG](../config/i18n.ts#L64)
|
- Add all country variants in [country variants object](./config/i18n.ts#L12)
|
||||||
|
- Add all country variants files with empty `messages` object: `{}`
|
||||||
|
- Translate the strings in the generic language file
|
||||||
|
- Later, when anyone wants to add the corresponding translations for the country variant, just override any entry in the corresponding file: you can see an example with `en` variants.
|
||||||
|
- If the generic language already exists:
|
||||||
|
- If the translation doesn't differ from the generic language, then add the corresponding translations in the corresponding file
|
||||||
|
- If the translation differs from the generic language, then add the corresponding translations in the corresponding file and remove it from the country variants entry
|
||||||
|
4. If the language is `right-to-left`, add `dir` option with `rtl` value, for example, for [ar](./config/i18n.ts#L71)
|
||||||
|
5. If the language requires special pluralization rules, add `pluralRule` callback option, for example, for [ar](./config/i18n.ts#L72)
|
||||||
|
|
||||||
Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html#custom-pluralization) for more info.
|
Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html#custom-pluralization) for more info.
|
||||||
|
|
||||||
|
@ -139,14 +166,14 @@ You can run this code in your browser console to see how it works:
|
||||||
Either **{0}** or **{v}** should be used with the exception being custom plurals entries using the `{n}` placeholder.
|
Either **{0}** or **{v}** should be used with the exception being custom plurals entries using the `{n}` placeholder.
|
||||||
|
|
||||||
This is the full list of entries that will be available for number formatting in Elk:
|
This is the full list of entries that will be available for number formatting in Elk:
|
||||||
- `action.boost_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
|
- `action.boost_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
|
||||||
- `action.favourite_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
|
- `action.favourite_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
|
||||||
- `action.reply_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
|
- `action.reply_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
|
||||||
- `account.followers_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
|
- `account.followers_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
|
||||||
- `account.following_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
|
- `account.following_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
|
||||||
- `account.posts_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
|
- `account.posts_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
|
||||||
- `compose.drafts`: `{v}` for formatted number and `{n}` for raw number - **{v} should be use**
|
- `compose.drafts`: `{v}` for formatted number and `{n}` for raw number - **{v} should be used**
|
||||||
- `notification.followed_you_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
|
- `notification.followed_you_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
|
||||||
- `status.poll.count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
|
- `status.poll.count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
|
||||||
- `time_ago_options.*`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**: since numbers will be always small, we can also use `{n}`
|
- `time_ago_options.*`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**: since numbers will be always small, we can also use `{n}`
|
||||||
- `timeline.show_new_items`: `{v}` for formatted number and `{n}` for raw number - **{v} should be use**
|
- `timeline.show_new_items`: `{v}` for formatted number and `{n}` for raw number - **{v} should be used**
|
||||||
|
|
56
Dockerfile
Normal file
56
Dockerfile
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
FROM docker.io/library/node:lts-alpine AS base
|
||||||
|
|
||||||
|
# Prepare work directory
|
||||||
|
WORKDIR /elk
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
# Prepare pnpm https://pnpm.io/installation#using-corepack
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# Prepare deps
|
||||||
|
RUN apk update
|
||||||
|
RUN apk add git --no-cache
|
||||||
|
|
||||||
|
# Prepare build deps ( ignore postinstall scripts for now )
|
||||||
|
COPY package.json ./
|
||||||
|
COPY pnpm-lock.yaml ./
|
||||||
|
COPY patches ./patches
|
||||||
|
RUN pnpm i --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# Copy all source files
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
# Run full install with every postinstall script ( This needs project file )
|
||||||
|
RUN pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
|
||||||
|
ARG UID=911
|
||||||
|
ARG GID=911
|
||||||
|
|
||||||
|
# Create a dedicated user and group
|
||||||
|
RUN set -eux; \
|
||||||
|
addgroup -g $UID elk; \
|
||||||
|
adduser -u $GID -D -G elk elk;
|
||||||
|
|
||||||
|
USER elk
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=builder /elk/.output ./.output
|
||||||
|
|
||||||
|
EXPOSE 5314/tcp
|
||||||
|
|
||||||
|
ENV PORT=5314
|
||||||
|
|
||||||
|
# Specify container only environment variables ( can be overwritten by runtime env )
|
||||||
|
ENV NUXT_STORAGE_FS_BASE='/elk/data'
|
||||||
|
|
||||||
|
# Persistent storage data
|
||||||
|
VOLUME [ "/elk/data" ]
|
||||||
|
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
63
README.md
63
README.md
|
@ -1,32 +1,55 @@
|
||||||
# Elk
|
|
||||||
*A nimble Mastodon web client*
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://elk.zone" target="_blank" rel="noopener noreferrer">
|
<a href="https://elk.zone" target="_blank" rel="noopener noreferrer">
|
||||||
<img width="180" height="180" src="./elk.svg" alt="Elk logo">
|
<img width="160" height="160" src="./public/logo.svg" alt="Elk logo">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center"/>Elk <sup><em>alpha</em></sup></h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
A nimble Mastodon web client
|
||||||
|
</p>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://chat.elk.zone"><img src="https://img.shields.io/badge/chat-discord-blue?style=flat&logo=discord" alt="discord chat"></a>
|
<a href="https://chat.elk.zone"><img src="https://img.shields.io/badge/chat-discord-blue?style=flat&logo=discord" alt="discord chat"></a>
|
||||||
<a href="https://pr.new/elk-zone/elk"><img src="https://developer.stackblitz.com/img/start_pr_dark_small.svg" alt="Start new PR in StackBlitz Codeflow"></a>
|
<a href="https://pr.new/elk-zone/elk"><img src="https://developer.stackblitz.com/img/start_pr_dark_small.svg" alt="Start new PR in StackBlitz Codeflow"></a>
|
||||||
|
<a href="https://volta.net/elk-zone/elk?utm_source=elk_readme"><img src="https://user-images.githubusercontent.com/904724/209143798-32345f6c-3cf8-4e06-9659-f4ace4a6acde.svg" alt="Open board on Volta"></a>
|
||||||
</p>
|
</p>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
# Elk is in early alpha ⚠️
|
<p align="center">
|
||||||
|
<a href="https://elk.zone/" target="_blank" rel="noopener noreferrer" >
|
||||||
|
<img src="./public/elk-og.png" alt="Elk screenshots" width="600" height="auto">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
It is already quite usable, but it isn't ready for wide adoption yet. We recommend you to use it if you would like to help us build it. We appreciate your feedback and contributions. Check out the [Open Issues](https://github.com/elk-zone/elk/issues) and jump in the action. Join the [Elk discord server](https://chat.elk.zone) to chat with us and learn more about the project.
|
## ⚠️ Elk is in Alpha
|
||||||
|
|
||||||
The client is deployed on:
|
It is already quite usable, but it isn't ready for wide adoption yet. We recommend you use it if you would like to help us build it. We appreciate your feedback and contributions. Check out the [Open Issues](https://github.com/elk-zone/elk/issues) and jump in the action. Join the [Elk discord server](https://chat.elk.zone) to chat with us and learn more about the project.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Official Deployment
|
||||||
|
|
||||||
|
The Elk team maintains a deployment at:
|
||||||
|
|
||||||
- 🦌 Production: [elk.zone](https://elk.zone)
|
- 🦌 Production: [elk.zone](https://elk.zone)
|
||||||
- 🐙 Canary: [main.elk.zone](https://main.elk.zone) (deploys on every commit to `main` branch)
|
- 🐙 Canary: [main.elk.zone](https://main.elk.zone) (deploys on every commit to `main` branch)
|
||||||
|
|
||||||
You can share screenshots on social media but we prefer you avoid sharing this URL directly until the app is more polished. Feel free to share the URL with your friends and invite others you think could be interested in helping to improve Elk.
|
### Ecosystem
|
||||||
|
|
||||||
## Sponsors
|
These are known deployments using Elk as an alternative Web client for Mastodon servers or as a base for other projects in the fediverse:
|
||||||
|
|
||||||
We want to thanks the generous sponsoring and help of:
|
- [elk.h4.io](https://elk.h4.io) - Use Elk for the `h4.io` Server
|
||||||
|
- [elk.universeodon.com](https://elk.universeodon.com) - Use Elk for the Universeodon Server
|
||||||
|
- [elk.vmst.io](https://elk.vmst.io) - Use Elk for the `vmst.io` Server
|
||||||
|
|
||||||
|
> **Note**: Community deployments are **NOT** maintained by the Elk team. It may not be synced with Elk's source code. Please do your own research about the host servers before using them.
|
||||||
|
|
||||||
|
## 💖 Sponsors
|
||||||
|
|
||||||
|
We are grateful for the generous sponsorship and help of:
|
||||||
|
|
||||||
<a href="https://nuxtlabs.com/" target="_blank" rel="noopener noreferrer" >
|
<a href="https://nuxtlabs.com/" target="_blank" rel="noopener noreferrer" >
|
||||||
<img src="./images/nuxtlabs.svg" alt="NuxtLabs" height="85">
|
<img src="./images/nuxtlabs.svg" alt="NuxtLabs" height="85">
|
||||||
|
@ -37,7 +60,11 @@ We want to thanks the generous sponsoring and help of:
|
||||||
</a>
|
</a>
|
||||||
<br><br>
|
<br><br>
|
||||||
|
|
||||||
And all the companies and individuals sponsoring Elk Team members. If you're enjoying the app, consider sponsoring our team:
|
And all the companies and individuals sponsoring Elk Team and the members. If you're enjoying the app, consider sponsoring us:
|
||||||
|
|
||||||
|
- [Elk Team's GitHub Sponsors](https://github.com/sponsors/elk-zone)
|
||||||
|
|
||||||
|
Or you can sponsor our core team members individually:
|
||||||
|
|
||||||
- [Anthony Fu](https://github.com/sponsors/antfu)
|
- [Anthony Fu](https://github.com/sponsors/antfu)
|
||||||
- [Daniel Roe](https://github.com/sponsors/danielroe)
|
- [Daniel Roe](https://github.com/sponsors/danielroe)
|
||||||
|
@ -46,11 +73,11 @@ And all the companies and individuals sponsoring Elk Team members. If you're enj
|
||||||
|
|
||||||
We would also appreciate sponsoring other contributors to the Elk project. If someone helps you solve an issue or implement a feature you wanted, supporting them would help make this project and OS more sustainable.
|
We would also appreciate sponsoring other contributors to the Elk project. If someone helps you solve an issue or implement a feature you wanted, supporting them would help make this project and OS more sustainable.
|
||||||
|
|
||||||
## Roadmap
|
## 📍 Roadmap
|
||||||
|
|
||||||
[Open board on Volta](https://volta.net/elk-zone/elk)
|
[Open board on Volta](https://volta.net/elk-zone/elk)
|
||||||
|
|
||||||
## Contributing
|
## 🧑💻 Contributing
|
||||||
|
|
||||||
We're really excited that you're interested in contributing to Elk! Before submitting your contribution, please read through the following guide.
|
We're really excited that you're interested in contributing to Elk! Before submitting your contribution, please read through the following guide.
|
||||||
|
|
||||||
|
@ -86,7 +113,7 @@ Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
|
||||||
nr test
|
nr test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stack
|
## 🦄 Stack
|
||||||
|
|
||||||
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling
|
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling
|
||||||
- [Nuxt](https://nuxt.com/) - The Intuitive Web Framework
|
- [Nuxt](https://nuxt.com/) - The Intuitive Web Framework
|
||||||
|
@ -100,6 +127,12 @@ nr test
|
||||||
- [shiki](https://shiki.matsu.io/) - A beautiful Syntax Highlighter
|
- [shiki](https://shiki.matsu.io/) - A beautiful Syntax Highlighter
|
||||||
- [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update and push notifications
|
- [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update and push notifications
|
||||||
|
|
||||||
## License
|
## 👨💻 Contributors
|
||||||
|
|
||||||
|
<a href="https://github.com/elk-zone/elk/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=elk-zone/elk" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
[MIT](./LICENSE) © 2022-PRESENT Elk contributors
|
[MIT](./LICENSE) © 2022-PRESENT Elk contributors
|
||||||
|
|
10
app.vue
10
app.vue
|
@ -2,6 +2,16 @@
|
||||||
setupPageHeader()
|
setupPageHeader()
|
||||||
provideGlobalCommands()
|
provideGlobalCommands()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
if (process.server && !route.path.startsWith('/settings')) {
|
||||||
|
useHead({
|
||||||
|
meta: [
|
||||||
|
{ property: 'og:url', content: `https://elk.zone${route.path}` },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// We want to trigger rerendering the page when account changes
|
// We want to trigger rerendering the page when account changes
|
||||||
const key = computed(() => `${currentUser.value?.server ?? currentServer.value}:${currentUser.value?.account.id || ''}`)
|
const key = computed(() => `${currentUser.value?.server ?? currentServer.value}:${currentUser.value?.account.id || ''}`)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -15,9 +15,11 @@ const error = $ref(false)
|
||||||
:key="account.avatar"
|
:key="account.avatar"
|
||||||
width="400"
|
width="400"
|
||||||
height="400"
|
height="400"
|
||||||
:src="error ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar"
|
select-none
|
||||||
|
:src="(error || !loaded) ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar"
|
||||||
:alt="$t('account.avatar_description', [account.username])"
|
:alt="$t('account.avatar_description', [account.username])"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
class="account-avatar"
|
||||||
:class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')"
|
:class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')"
|
||||||
:style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }"
|
:style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
|
|
@ -8,18 +8,28 @@ const { account, command, context, ...props } = defineProps<{
|
||||||
command?: boolean
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
const { t } = useI18n()
|
||||||
|
const isSelf = $(useSelfAccount(() => account))
|
||||||
const enable = $computed(() => !isSelf && currentUser.value)
|
const enable = $computed(() => !isSelf && currentUser.value)
|
||||||
const relationship = $computed(() => props.relationship || useRelationship(account).value)
|
const relationship = $computed(() => props.relationship || useRelationship(account).value)
|
||||||
|
|
||||||
const masto = useMasto()
|
const { client } = $(useMasto())
|
||||||
async function toggleFollow() {
|
async function toggleFollow() {
|
||||||
|
if (relationship!.following) {
|
||||||
|
if (await openConfirmDialog({
|
||||||
|
title: t('confirm.unfollow.title'),
|
||||||
|
confirm: t('confirm.unfollow.confirm'),
|
||||||
|
cancel: t('confirm.unfollow.cancel'),
|
||||||
|
}) !== 'confirm')
|
||||||
|
return
|
||||||
|
}
|
||||||
relationship!.following = !relationship!.following
|
relationship!.following = !relationship!.following
|
||||||
try {
|
try {
|
||||||
const newRel = await masto.v1.accounts[relationship!.following ? 'follow' : 'unfollow'](account.id)
|
const newRel = await client.v1.accounts[relationship!.following ? 'follow' : 'unfollow'](account.id)
|
||||||
Object.assign(relationship!, newRel)
|
Object.assign(relationship!, newRel)
|
||||||
}
|
}
|
||||||
catch {
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
relationship!.following = !relationship!.following
|
relationship!.following = !relationship!.following
|
||||||
}
|
}
|
||||||
|
@ -28,10 +38,11 @@ async function toggleFollow() {
|
||||||
async function unblock() {
|
async function unblock() {
|
||||||
relationship!.blocking = false
|
relationship!.blocking = false
|
||||||
try {
|
try {
|
||||||
const newRel = await masto.v1.accounts.unblock(account.id)
|
const newRel = await client.v1.accounts.unblock(account.id)
|
||||||
Object.assign(relationship!, newRel)
|
Object.assign(relationship!, newRel)
|
||||||
}
|
}
|
||||||
catch {
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
relationship!.blocking = true
|
relationship!.blocking = true
|
||||||
}
|
}
|
||||||
|
@ -40,17 +51,16 @@ async function unblock() {
|
||||||
async function unmute() {
|
async function unmute() {
|
||||||
relationship!.muting = false
|
relationship!.muting = false
|
||||||
try {
|
try {
|
||||||
const newRel = await masto.v1.accounts.unmute(account.id)
|
const newRel = await client.v1.accounts.unmute(account.id)
|
||||||
Object.assign(relationship!, newRel)
|
Object.assign(relationship!, newRel)
|
||||||
}
|
}
|
||||||
catch {
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
relationship!.muting = true
|
relationship!.muting = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
useCommand({
|
useCommand({
|
||||||
scope: 'Actions',
|
scope: 'Actions',
|
||||||
order: -2,
|
order: -2,
|
||||||
|
|
|
@ -9,7 +9,7 @@ const serverName = $computed(() => getServerName(account))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p line-clamp-1 whitespace-pre-wrap break-all text-secondary-light dir="ltr">
|
<p line-clamp-1 whitespace-pre-wrap break-all text-secondary-light leading-tight dir="ltr">
|
||||||
<!-- fix: #274 only line-clamp-1 can be used here, using text-ellipsis is not valid -->
|
<!-- fix: #274 only line-clamp-1 can be used here, using text-ellipsis is not valid -->
|
||||||
<span text-secondary>{{ getShortHandle(account) }}</span>
|
<span text-secondary>{{ getShortHandle(account) }}</span>
|
||||||
<span v-if="serverName" text-secondary-light>@{{ serverName }}</span>
|
<span v-if="serverName" text-secondary-light>@{{ serverName }}</span>
|
||||||
|
|
|
@ -6,6 +6,8 @@ const { account } = defineProps<{
|
||||||
command?: boolean
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { client } = $(useMasto())
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const createdAt = $(useFormattedDateTime(() => account.createdAt, {
|
const createdAt = $(useFormattedDateTime(() => account.createdAt, {
|
||||||
|
@ -14,13 +16,20 @@ const createdAt = $(useFormattedDateTime(() => account.createdAt, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const relationship = $(useRelationship(account))
|
||||||
|
|
||||||
const namedFields = ref<mastodon.v1.AccountField[]>([])
|
const namedFields = ref<mastodon.v1.AccountField[]>([])
|
||||||
const iconFields = ref<mastodon.v1.AccountField[]>([])
|
const iconFields = ref<mastodon.v1.AccountField[]>([])
|
||||||
|
const hasHeader = $computed(() => !account.header.endsWith('/original/missing.png'))
|
||||||
|
|
||||||
function getFieldIconTitle(fieldName: string) {
|
function getFieldIconTitle(fieldName: string) {
|
||||||
return fieldName === 'Joined' ? t('account.joined') : fieldName
|
return fieldName === 'Joined' ? t('account.joined') : fieldName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNotificationIconTitle() {
|
||||||
|
return relationship?.notifying ? t('account.notifications_on_post_disable', { username: `@${account.username}` }) : t('account.notifications_on_post_enable', { username: `@${account.username}` })
|
||||||
|
}
|
||||||
|
|
||||||
function previewHeader() {
|
function previewHeader() {
|
||||||
openMediaPreview([{
|
openMediaPreview([{
|
||||||
id: `${account.acct}:header`,
|
id: `${account.acct}:header`,
|
||||||
|
@ -39,6 +48,18 @@ function previewAvatar() {
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleNotifications() {
|
||||||
|
relationship!.notifying = !relationship?.notifying
|
||||||
|
try {
|
||||||
|
const newRel = await client.v1.accounts.follow(account.id, { notify: relationship?.notifying })
|
||||||
|
Object.assign(relationship!, newRel)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// TODO error handling
|
||||||
|
relationship!.notifying = !relationship?.notifying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const named: mastodon.v1.AccountField[] = []
|
const named: mastodon.v1.AccountField[] = []
|
||||||
const icons: mastodon.v1.AccountField[] = []
|
const icons: mastodon.v1.AccountField[] = []
|
||||||
|
@ -59,14 +80,15 @@ watchEffect(() => {
|
||||||
iconFields.value = icons
|
iconFields.value = icons
|
||||||
})
|
})
|
||||||
|
|
||||||
const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
const isSelf = $(useSelfAccount(() => account))
|
||||||
|
const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex flex-col>
|
<div flex flex-col>
|
||||||
<button border="b base" z-1>
|
<component :is="hasHeader ? 'button' : 'div'" border="b base" z-1 @click="hasHeader ? previewHeader() : undefined">
|
||||||
<img h-50 height="200" w-full object-cover :src="account.header" :alt="t('account.profile_description', [account.username])" @click="previewHeader">
|
<img h-50 height="200" w-full object-cover :src="account.header" :alt="t('account.profile_description', [account.username])">
|
||||||
</button>
|
</component>
|
||||||
<div p4 mt--18 flex flex-col gap-4>
|
<div p4 mt--18 flex flex-col gap-4>
|
||||||
<div relative>
|
<div relative>
|
||||||
<div flex="~ col gap-2 1">
|
<div flex="~ col gap-2 1">
|
||||||
|
@ -83,6 +105,32 @@ const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
||||||
</div>
|
</div>
|
||||||
<div absolute top-18 inset-ie-0 flex gap-2 items-center>
|
<div absolute top-18 inset-ie-0 flex gap-2 items-center>
|
||||||
<AccountMoreButton :account="account" :command="command" />
|
<AccountMoreButton :account="account" :command="command" />
|
||||||
|
<CommonTooltip v-if="!isSelf && relationship?.following" :content="getNotificationIconTitle()">
|
||||||
|
<button
|
||||||
|
:aria-pressed="isNotifiedOnPost"
|
||||||
|
:aria-label="t('account.notifications_on_post_enable', { username: `@${account.username}` })"
|
||||||
|
rounded-full text-sm p2 border-1 transition-colors
|
||||||
|
:class="isNotifiedOnPost ? 'text-primary border-primary hover:bg-red/20 hover:text-red hover:border-red' : 'border-base hover:text-primary'"
|
||||||
|
@click="toggleNotifications"
|
||||||
|
>
|
||||||
|
<span v-if="isNotifiedOnPost" i-ri:notification-4-fill block text-current />
|
||||||
|
<span v-else i-ri-notification-4-line block text-current />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<CommonTooltip :content="$t('list.modify_account')">
|
||||||
|
<VDropdown v-if="!isSelf && relationship?.following">
|
||||||
|
<button
|
||||||
|
:aria-label="$t('list.modify_account')"
|
||||||
|
rounded-full text-sm p2 border-1 transition-colors
|
||||||
|
border-base hover:text-primary
|
||||||
|
>
|
||||||
|
<span i-ri:play-list-add-fill block text-current />
|
||||||
|
</button>
|
||||||
|
<template #popper>
|
||||||
|
<ListLists :user-id="account.id" />
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
</CommonTooltip>
|
||||||
<AccountFollowButton :account="account" :command="command" />
|
<AccountFollowButton :account="account" :command="command" />
|
||||||
<!-- Edit profile -->
|
<!-- Edit profile -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
@ -93,11 +141,6 @@ const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
||||||
>
|
>
|
||||||
{{ $t('settings.profile.appearance.title') }}
|
{{ $t('settings.profile.appearance.title') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- <button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group>
|
|
||||||
<div rounded p2 group-hover="bg-rose/10">
|
|
||||||
<div i-ri:bell-line />
|
|
||||||
</div>
|
|
||||||
</button> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="account.note" max-h-100 overflow-y-auto>
|
<div v-if="account.note" max-h-100 overflow-y-auto>
|
||||||
|
|
|
@ -8,13 +8,15 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const account = props.account || (props.handle ? useAccountByHandle(props.handle!) : undefined)
|
const account = props.account || (props.handle ? useAccountByHandle(props.handle!) : undefined)
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VMenu v-if="!disabled && account" placement="bottom-start" :delay="{ show: 500, hide: 100 }" v-bind="$attrs" :close-on-content-click="false">
|
<VMenu v-if="!disabled && account && !getPreferences(userSettings, 'hideAccountHoverCard')" placement="bottom-start" :delay="{ show: 500, hide: 100 }" v-bind="$attrs" :close-on-content-click="false">
|
||||||
<slot />
|
<slot />
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<AccountHoverCard v-if="account" :account="account" />
|
<AccountHoverCard v-if="account" :account="account" />
|
||||||
|
|
|
@ -20,7 +20,7 @@ defineOptions({
|
||||||
<AccountHoverWrapper :disabled="!hoverCard" :account="account">
|
<AccountHoverWrapper :disabled="!hoverCard" :account="account">
|
||||||
<AccountBigAvatar :account="account" shrink-0 :square="square" />
|
<AccountBigAvatar :account="account" shrink-0 :square="square" />
|
||||||
</AccountHoverWrapper>
|
</AccountHoverWrapper>
|
||||||
<div flex="~ col" shrink pt-1 h-full overflow-hidden justify-center leading-none>
|
<div flex="~ col" shrink pt-1 h-full overflow-hidden justify-center leading-none select-none>
|
||||||
<div flex="~" gap-2>
|
<div flex="~" gap-2>
|
||||||
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all text-lg />
|
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all text-lg />
|
||||||
<AccountBotIndicator v-if="account.bot" text-xs />
|
<AccountBotIndicator v-if="account.bot" text-xs />
|
||||||
|
|
|
@ -12,7 +12,7 @@ const { link = true, avatar = true } = defineProps<{
|
||||||
<AccountHoverWrapper :account="account">
|
<AccountHoverWrapper :account="account">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="link ? getAccountRoute(account) : undefined"
|
:to="link ? getAccountRoute(account) : undefined"
|
||||||
:class="link ? 'text-link-rounded ms-0 ps-0' : ''"
|
:class="link ? 'text-link-rounded -ml-1.8rem pl-1.8rem rtl-(ml0 pl-0.5rem -mr-1.8rem pr-1.8rem)' : ''"
|
||||||
min-w-0 flex gap-2 items-center
|
min-w-0 flex gap-2 items-center
|
||||||
>
|
>
|
||||||
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
|
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
|
||||||
|
|
|
@ -7,39 +7,61 @@ const { account } = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
let relationship = $(useRelationship(account))
|
let relationship = $(useRelationship(account))
|
||||||
|
|
||||||
const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
const isSelf = $(useSelfAccount(() => account))
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { client } = $(useMasto())
|
||||||
|
|
||||||
const masto = useMasto()
|
|
||||||
const toggleMute = async () => {
|
const toggleMute = async () => {
|
||||||
// TODO: Add confirmation
|
if (!relationship!.muting && await openConfirmDialog({
|
||||||
|
title: t('confirm.mute_account.title', [account.acct]),
|
||||||
|
confirm: t('confirm.mute_account.confirm'),
|
||||||
|
cancel: t('confirm.mute_account.cancel'),
|
||||||
|
}) !== 'confirm')
|
||||||
|
return
|
||||||
|
|
||||||
relationship!.muting = !relationship!.muting
|
relationship!.muting = !relationship!.muting
|
||||||
relationship = relationship!.muting
|
relationship = relationship!.muting
|
||||||
? await masto.v1.accounts.mute(account.id, {
|
? await client.v1.accounts.mute(account.id, {
|
||||||
// TODO support more options
|
// TODO support more options
|
||||||
})
|
})
|
||||||
: await masto.v1.accounts.unmute(account.id)
|
: await client.v1.accounts.unmute(account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleBlockUser = async () => {
|
const toggleBlockUser = async () => {
|
||||||
// TODO: Add confirmation
|
if (!relationship!.blocking && await openConfirmDialog({
|
||||||
|
title: t('confirm.block_account.title', [account.acct]),
|
||||||
|
confirm: t('confirm.block_account.confirm'),
|
||||||
|
cancel: t('confirm.block_account.cancel'),
|
||||||
|
}) !== 'confirm')
|
||||||
|
return
|
||||||
|
|
||||||
relationship!.blocking = !relationship!.blocking
|
relationship!.blocking = !relationship!.blocking
|
||||||
relationship = await masto.v1.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
|
relationship = await client.v1.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleBlockDomain = async () => {
|
const toggleBlockDomain = async () => {
|
||||||
// TODO: Add confirmation
|
if (!relationship!.domainBlocking && await openConfirmDialog({
|
||||||
|
title: t('confirm.block_domain.title', [getServerName(account)]),
|
||||||
|
confirm: t('confirm.block_domain.confirm'),
|
||||||
|
cancel: t('confirm.block_domain.cancel'),
|
||||||
|
}) !== 'confirm')
|
||||||
|
return
|
||||||
|
|
||||||
relationship!.domainBlocking = !relationship!.domainBlocking
|
relationship!.domainBlocking = !relationship!.domainBlocking
|
||||||
await masto.v1.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
|
await client.v1.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleReblogs = async () => {
|
const toggleReblogs = async () => {
|
||||||
// TODO: Add confirmation
|
if (!relationship!.showingReblogs && await openConfirmDialog({
|
||||||
|
title: t('confirm.show_reblogs.title', [account.acct]),
|
||||||
|
confirm: t('confirm.show_reblogs.confirm'),
|
||||||
|
cancel: t('confirm.show_reblogs.cancel'),
|
||||||
|
}) !== 'confirm')
|
||||||
|
return
|
||||||
|
|
||||||
const showingReblogs = !relationship?.showingReblogs
|
const showingReblogs = !relationship?.showingReblogs
|
||||||
relationship = await masto.v1.accounts.follow(account.id, { reblogs: showingReblogs })
|
relationship = await client.v1.accounts.follow(account.id, { reblogs: showingReblogs })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -80,14 +102,14 @@ const toggleReblogs = async () => {
|
||||||
icon="i-ri:repeat-line"
|
icon="i-ri:repeat-line"
|
||||||
:text="$t('menu.show_reblogs', [`@${account.acct}`])"
|
:text="$t('menu.show_reblogs', [`@${account.acct}`])"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleReblogs"
|
@click="toggleReblogs()"
|
||||||
/>
|
/>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-else
|
v-else
|
||||||
:text="$t('menu.hide_reblogs', [`@${account.acct}`])"
|
:text="$t('menu.hide_reblogs', [`@${account.acct}`])"
|
||||||
icon="i-ri:repeat-line"
|
icon="i-ri:repeat-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleReblogs"
|
@click="toggleReblogs()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
|
@ -95,14 +117,14 @@ const toggleReblogs = async () => {
|
||||||
:text="$t('menu.mute_account', [`@${account.acct}`])"
|
:text="$t('menu.mute_account', [`@${account.acct}`])"
|
||||||
icon="i-ri:volume-up-fill"
|
icon="i-ri:volume-up-fill"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleMute"
|
@click="toggleMute()"
|
||||||
/>
|
/>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-else
|
v-else
|
||||||
:text="$t('menu.unmute_account', [`@${account.acct}`])"
|
:text="$t('menu.unmute_account', [`@${account.acct}`])"
|
||||||
icon="i-ri:volume-mute-line"
|
icon="i-ri:volume-mute-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleMute"
|
@click="toggleMute()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
|
@ -110,14 +132,14 @@ const toggleReblogs = async () => {
|
||||||
:text="$t('menu.block_account', [`@${account.acct}`])"
|
:text="$t('menu.block_account', [`@${account.acct}`])"
|
||||||
icon="i-ri:forbid-2-line"
|
icon="i-ri:forbid-2-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBlockUser"
|
@click="toggleBlockUser()"
|
||||||
/>
|
/>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-else
|
v-else
|
||||||
:text="$t('menu.unblock_account', [`@${account.acct}`])"
|
:text="$t('menu.unblock_account', [`@${account.acct}`])"
|
||||||
icon="i-ri:checkbox-circle-line"
|
icon="i-ri:checkbox-circle-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBlockUser"
|
@click="toggleBlockUser()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-if="getServerName(account) !== currentServer">
|
<template v-if="getServerName(account) !== currentServer">
|
||||||
|
@ -126,14 +148,14 @@ const toggleReblogs = async () => {
|
||||||
:text="$t('menu.block_domain', [getServerName(account)])"
|
:text="$t('menu.block_domain', [getServerName(account)])"
|
||||||
icon="i-ri:shut-down-line"
|
icon="i-ri:shut-down-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBlockDomain"
|
@click="toggleBlockDomain()"
|
||||||
/>
|
/>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-else
|
v-else
|
||||||
:text="$t('menu.unblock_domain', [getServerName(account)])"
|
:text="$t('menu.unblock_domain', [getServerName(account)])"
|
||||||
icon="i-ri:restart-line"
|
icon="i-ri:restart-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBlockDomain"
|
@click="toggleBlockDomain()"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -19,10 +19,8 @@ defineProps<{
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div flex-auto />
|
<div flex-auto />
|
||||||
<div flex items-center>
|
<div flex items-center>
|
||||||
<NuxtLink :to="getAccountRoute(account.moved as any)">
|
<NuxtLink :to="getAccountRoute(account.moved as any)" btn-solid inline-block h-fit>
|
||||||
<button btn-solid h-fit>
|
|
||||||
{{ $t('account.go_to_profile') }}
|
{{ $t('account.go_to_profile') }}
|
||||||
</button>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,10 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Paginator, mastodon } from 'masto'
|
import type { Paginator, mastodon } from 'masto'
|
||||||
|
|
||||||
const { paginator } = defineProps<{
|
const { paginator, account, context } = defineProps<{
|
||||||
paginator: Paginator<mastodon.v1.Account[], mastodon.DefaultPaginationParams>
|
paginator: Paginator<mastodon.v1.Account[], mastodon.DefaultPaginationParams>
|
||||||
|
context?: 'following' | 'followers'
|
||||||
|
account?: mastodon.v1.Account
|
||||||
relationshipContext?: 'followedBy' | 'following'
|
relationshipContext?: 'followedBy' | 'following'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const fallbackContext = $computed(() => {
|
||||||
|
return ['following', 'followers'].includes(context!)
|
||||||
|
})
|
||||||
|
const showOriginSite = $computed(() =>
|
||||||
|
account && account.id !== currentUser.value?.account.id && getServerName(account) !== currentServer.value,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -17,5 +26,18 @@ const { paginator } = defineProps<{
|
||||||
border="b base" py2 px4
|
border="b base" py2 px4
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="fallbackContext && showOriginSite" #done>
|
||||||
|
<div p5 text-secondary text-center flex flex-col items-center gap1>
|
||||||
|
<span italic>{{ $t(`account.view_other_${context}`) }}</span>
|
||||||
|
<NuxtLink
|
||||||
|
:href="account!.url" target="_blank" external
|
||||||
|
flex="~ gap-1" items-center text-primary
|
||||||
|
hover="underline text-primary-active"
|
||||||
|
>
|
||||||
|
<div i-ri:external-link-fill />
|
||||||
|
{{ $t('menu.open_in_original_site') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</CommonPaginator>
|
</CommonPaginator>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -4,6 +4,8 @@ import type { mastodon } from 'masto'
|
||||||
defineProps<{
|
defineProps<{
|
||||||
account: mastodon.v1.Account
|
account: mastodon.v1.Account
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const userSettings = useUserSettings()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -38,7 +40,7 @@ defineProps<{
|
||||||
</template>
|
</template>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="!getWellnessSetting('hideFollowerCount')"
|
v-if="!getPreferences(userSettings, 'hideFollowerCount')"
|
||||||
:to="getAccountFollowersRoute(account)"
|
:to="getAccountFollowersRoute(account)"
|
||||||
replace text-secondary
|
replace text-secondary
|
||||||
exact-active-class="text-primary"
|
exact-active-class="text-primary"
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { CommonRouteTabOption } from '../common/CommonRouteTabs.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const server = $(computedEager(() => route.params.server as string))
|
const server = $(computedEager(() => route.params.server as string))
|
||||||
const account = $(computedEager(() => route.params.account as string))
|
const account = $(computedEager(() => route.params.account as string))
|
||||||
|
|
||||||
const tabs = $computed(() => [
|
const tabs = $computed<CommonRouteTabOption[]>(() => [
|
||||||
{
|
{
|
||||||
name: 'account-index',
|
name: 'account-index',
|
||||||
to: {
|
to: {
|
||||||
|
@ -33,7 +35,7 @@ const tabs = $computed(() => [
|
||||||
display: t('tab.media'),
|
display: t('tab.media'),
|
||||||
icon: 'i-ri:camera-2-line',
|
icon: 'i-ri:camera-2-line',
|
||||||
},
|
},
|
||||||
] as const)
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -20,7 +20,7 @@ function close() {
|
||||||
<div>
|
<div>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<button text-xl hover:text-primary bg-hover-overflow w="1.4em" h="1.4em" @click="close()">
|
<button text-xl hover:text-primary bg-hover-overflow w="1.2em" h="1.2em" @click="close()">
|
||||||
<div i-ri:close-line />
|
<div i-ri:close-line />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -88,17 +88,19 @@ watch(file, (image, _, onCleanup) => {
|
||||||
w-full
|
w-full
|
||||||
h-full
|
h-full
|
||||||
>
|
>
|
||||||
<div absolute bg="black/50" text-white rounded-full text-xl w12 h12 flex justify-center items-center hover="bg-black/40 text-primary">
|
<span absolute bg="black/50" text-white rounded-full text-xl w12 h12 flex justify-center items-center hover="bg-black/40 text-primary">
|
||||||
<div i-ri:upload-line />
|
<span block i-ri:upload-line />
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<div
|
<span
|
||||||
v-if="loading"
|
v-if="loading"
|
||||||
absolute inset-0
|
absolute inset-0
|
||||||
bg="black/30" text-white
|
bg="black/30" text-white
|
||||||
flex justify-center items-center
|
flex justify-center items-center
|
||||||
>
|
>
|
||||||
<div class="i-ri:loader-4-line animate-spin animate-duration-[2.5s]" text-4xl />
|
<span class="animate-spin animate-duration-[2.5s] preserve-3d">
|
||||||
</div>
|
<span block i-ri:loader-4-line text-4xl />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
13
components/common/CommonMask.vue
Normal file
13
components/common/CommonMask.vue
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {
|
||||||
|
zIndex = 100,
|
||||||
|
background = 'transparent',
|
||||||
|
} = $defineProps<{
|
||||||
|
zIndex?: number
|
||||||
|
background?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div fixed top-0 bottom-0 left-0 right-0 :style="{ background, zIndex }" />
|
||||||
|
</template>
|
|
@ -11,6 +11,7 @@ const {
|
||||||
virtualScroller = false,
|
virtualScroller = false,
|
||||||
eventType = 'update',
|
eventType = 'update',
|
||||||
preprocess,
|
preprocess,
|
||||||
|
noEndMessage = false,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
paginator: Paginator<T[], O>
|
paginator: Paginator<T[], O>
|
||||||
keyProp?: keyof T
|
keyProp?: keyof T
|
||||||
|
@ -18,6 +19,7 @@ const {
|
||||||
stream?: Promise<WsEvents>
|
stream?: Promise<WsEvents>
|
||||||
eventType?: 'notification' | 'update'
|
eventType?: 'notification' | 'update'
|
||||||
preprocess?: (items: (U | T)[]) => U[]
|
preprocess?: (items: (U | T)[]) => U[]
|
||||||
|
noEndMessage?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
|
@ -42,7 +44,7 @@ defineSlots<{
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, stream, eventType, preprocess)
|
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, $$(stream), eventType, preprocess)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -84,7 +86,7 @@ const { items, prevItems, update, state, endAnchor, error } = usePaginator(pagin
|
||||||
<slot v-if="state === 'loading'" name="loading">
|
<slot v-if="state === 'loading'" name="loading">
|
||||||
<TimelineSkeleton />
|
<TimelineSkeleton />
|
||||||
</slot>
|
</slot>
|
||||||
<slot v-else-if="state === 'done'" name="done">
|
<slot v-else-if="state === 'done' && !noEndMessage" name="done">
|
||||||
<div p5 text-secondary italic text-center>
|
<div p5 text-secondary italic text-center>
|
||||||
{{ t('common.end_of_list') }}
|
{{ t('common.end_of_list') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
27
components/common/CommonPreviewPrompt.vue
Normal file
27
components/common/CommonPreviewPrompt.vue
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const build = useBuildInfo()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
m-2 p5 bg-rose:10 relative
|
||||||
|
rounded-lg of-hidden
|
||||||
|
flex="~ col gap-3"
|
||||||
|
>
|
||||||
|
<h2 font-bold text-rose>
|
||||||
|
{{ $t('help.build_preview.title') }}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
<i18n-t keypath="help.build_preview.desc1">
|
||||||
|
<NuxtLink :href="`https://github.com/elk-zone/elk/commit/${build.commit}`" target="_blank" text-rose hover:underline>
|
||||||
|
<code>{{ build.commit.slice(0, 7) }}</code>
|
||||||
|
</NuxtLink>
|
||||||
|
</i18n-t>
|
||||||
|
</p>
|
||||||
|
<p>{{ $t('help.build_preview.desc2') }}</p>
|
||||||
|
<p font-bold>
|
||||||
|
{{ $t('help.build_preview.desc3') }}
|
||||||
|
</p>
|
||||||
|
<div i-ri-git-pull-request-line absolute text-10em bottom--10 inset-ie--10 text-rose op10 class="-z-1" />
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,14 +1,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
const { options, command, replace, preventScrollTop = false } = $defineProps<{
|
export interface CommonRouteTabOption {
|
||||||
options: {
|
|
||||||
to: RouteLocationRaw
|
to: RouteLocationRaw
|
||||||
display: string
|
display: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
name?: string
|
name?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
}[]
|
}
|
||||||
|
const { options, command, replace, preventScrollTop = false } = $defineProps<{
|
||||||
|
options: CommonRouteTabOption[]
|
||||||
command?: boolean
|
command?: boolean
|
||||||
replace?: boolean
|
replace?: boolean
|
||||||
preventScrollTop?: boolean
|
preventScrollTop?: boolean
|
||||||
|
|
|
@ -9,6 +9,7 @@ defineProps<{
|
||||||
<template>
|
<template>
|
||||||
<VTooltip
|
<VTooltip
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
auto-hide
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<template #popper>
|
<template #popper>
|
||||||
|
|
|
@ -19,11 +19,12 @@ const historyNum = $computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const sparklineEl = $ref<SVGSVGElement>()
|
const sparklineEl = $ref<SVGSVGElement>()
|
||||||
|
const sparklineFn = typeof sparkline !== 'function' ? (sparkline as any).default : sparkline
|
||||||
|
|
||||||
watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => {
|
watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => {
|
||||||
if (!sparklineEl)
|
if (!sparklineEl)
|
||||||
return
|
return
|
||||||
sparkline(sparklineEl, historyNum)
|
sparklineFn(sparklineEl, historyNum)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
|
||||||
smallScreen: boolean
|
|
||||||
}>()
|
|
||||||
const online = useOnline()
|
const online = useOnline()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
|
is?: string
|
||||||
text?: string
|
text?: string
|
||||||
description?: string
|
description?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
checked?: boolean
|
checked?: boolean
|
||||||
command?: boolean
|
command?: boolean
|
||||||
}>()
|
}>(), {
|
||||||
|
is: 'div',
|
||||||
|
})
|
||||||
const emit = defineEmits(['click'])
|
const emit = defineEmits(['click'])
|
||||||
|
|
||||||
const { hide } = useDropdownContext() || {}
|
const { hide } = useDropdownContext() || {}
|
||||||
|
@ -39,9 +42,12 @@ useCommand({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<component
|
||||||
v-bind="$attrs" ref="el"
|
v-bind="$attrs"
|
||||||
|
:is="is"
|
||||||
|
ref="el"
|
||||||
flex gap-3 items-center cursor-pointer px4 py3
|
flex gap-3 items-center cursor-pointer px4 py3
|
||||||
|
select-none
|
||||||
hover-bg-active
|
hover-bg-active
|
||||||
:aria-label="text"
|
:aria-label="text"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
|
@ -66,5 +72,5 @@ useCommand({
|
||||||
|
|
||||||
<div v-if="checked" i-ri:check-line />
|
<div v-if="checked" i-ri:check-line />
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
</div>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -18,5 +18,6 @@ const highlighted = computed(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<pre class="code-block" v-html="highlighted" />
|
<pre v-if="lang" class="code-block" v-html="highlighted" />
|
||||||
|
<pre v-else class="code-block">{{ raw }}</pre>
|
||||||
</template>
|
</template>
|
||||||
|
|
11
components/content/ContentMentionGroup.vue
Normal file
11
components/content/ContentMentionGroup.vue
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
replying?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p flex="~ gap-1 wrap" items-center text-sm :class="{ 'zen-none': !replying }">
|
||||||
|
<span i-ri-arrow-right-line ml--1 text-secondary-light /><slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
|
@ -4,10 +4,16 @@ import type { Paginator, mastodon } from 'masto'
|
||||||
const { paginator } = defineProps<{
|
const { paginator } = defineProps<{
|
||||||
paginator: Paginator<mastodon.v1.Conversation[], mastodon.DefaultPaginationParams>
|
paginator: Paginator<mastodon.v1.Conversation[], mastodon.DefaultPaginationParams>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function preprocess(items: mastodon.v1.Conversation[]): mastodon.v1.Conversation[] {
|
||||||
|
return items.filter(items => !items.lastStatus?.filtered?.find(
|
||||||
|
filter => filter.filter.filterAction === 'hide' && filter.filter.context.includes('thread'),
|
||||||
|
))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonPaginator :paginator="paginator">
|
<CommonPaginator :paginator="paginator" :preprocess="preprocess">
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<ConversationCard
|
<ConversationCard
|
||||||
:conversation="item"
|
:conversation="item"
|
||||||
|
|
|
@ -23,7 +23,7 @@ const emit = defineEmits<{
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('help.desc_para4') }}
|
{{ $t('help.desc_para4') }}
|
||||||
<NuxtLink font-bold text-primary href="/m.webtoo.ls/@elk" target="_blank">
|
<NuxtLink font-bold text-primary href="https://github.com/elk-zone/elk" target="_blank">
|
||||||
{{ $t('help.desc_para5') }}
|
{{ $t('help.desc_para5') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
{{ $t('help.desc_para6') }}
|
{{ $t('help.desc_para6') }}
|
||||||
|
@ -37,7 +37,9 @@ const emit = defineEmits<{
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<p italic flex justify-center w-full>
|
<p italic flex justify-center w-full>
|
||||||
<span text-xl font-script>The Elk Team</span>
|
<NuxtLink href="https://github.com/sponsors/elk-zone" target="_blank">
|
||||||
|
<span text-xl font-script hover:text-primary transition duration-300>The Elk Team</span>
|
||||||
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button btn-solid mxa tabindex="2" @click="emit('close')">
|
<button btn-solid mxa tabindex="2" @click="emit('close')">
|
||||||
|
|
44
components/list/Account.vue
Normal file
44
components/list/Account.vue
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
const { account, list } = defineProps<{
|
||||||
|
account: mastodon.v1.Account
|
||||||
|
hoverCard?: boolean
|
||||||
|
list: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
cacheAccount(account)
|
||||||
|
|
||||||
|
const client = useMastoClient()
|
||||||
|
|
||||||
|
const isRemoved = ref(false)
|
||||||
|
|
||||||
|
async function edit() {
|
||||||
|
try {
|
||||||
|
isRemoved.value
|
||||||
|
? await client.v1.lists.addAccount(list, { accountIds: [account.id] })
|
||||||
|
: await client.v1.lists.removeAccount(list, { accountIds: [account.id] })
|
||||||
|
isRemoved.value = !isRemoved.value
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex justify-between hover:bg-active transition-100 items-center>
|
||||||
|
<AccountInfo
|
||||||
|
:account="account" hover p1 as="router-link"
|
||||||
|
:hover-card="hoverCard"
|
||||||
|
shrink
|
||||||
|
overflow-hidden
|
||||||
|
:to="getAccountRoute(account)"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<CommonTooltip :content="isRemoved ? $t('list.add_account') : $t('list.remove_account')" :hover="isRemoved ? 'text-green' : 'text-red'">
|
||||||
|
<button :class="isRemoved ? 'i-ri:user-add-line' : 'i-ri:user-unfollow-line'" text-xl @click="edit" />
|
||||||
|
</CommonTooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
49
components/list/Lists.vue
Normal file
49
components/list/Lists.vue
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const { userId } = defineProps<{
|
||||||
|
userId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { client } = $(useMasto())
|
||||||
|
const paginator = client.v1.lists.list()
|
||||||
|
const listsWithUser = ref((await client.v1.accounts.listLists(userId)).map(list => list.id))
|
||||||
|
|
||||||
|
function indexOfUserInList(listId: string) {
|
||||||
|
return listsWithUser.value.indexOf(listId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edit(listId: string) {
|
||||||
|
try {
|
||||||
|
const index = indexOfUserInList(listId)
|
||||||
|
if (index === -1) {
|
||||||
|
await client.v1.lists.addAccount(listId, { accountIds: [userId] })
|
||||||
|
listsWithUser.value.push(listId)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await client.v1.lists.removeAccount(listId, { accountIds: [userId] })
|
||||||
|
listsWithUser.value = listsWithUser.value.filter(id => id !== listId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CommonPaginator no-end-message :paginator="paginator">
|
||||||
|
<template #default="{ item }">
|
||||||
|
<div p4 hover:bg-active block w="100%" flex justify-between items-center gap-4>
|
||||||
|
<p>{{ item.title }}</p>
|
||||||
|
<CommonTooltip
|
||||||
|
:content="indexOfUserInList(item.id) === -1 ? $t('list.add_account') : $t('list.remove_account')"
|
||||||
|
:hover="indexOfUserInList(item.id) === -1 ? 'text-green' : 'text-red'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:class="indexOfUserInList(item.id) === -1 ? 'i-ri:user-add-line' : 'i-ri:user-unfollow-line'"
|
||||||
|
text-xl @click="() => edit(item.id)"
|
||||||
|
/>
|
||||||
|
</CommonTooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CommonPaginator>
|
||||||
|
</template>
|
|
@ -5,6 +5,9 @@ defineProps<{
|
||||||
/** Show the back button on both small and big screens */
|
/** Show the back button on both small and big screens */
|
||||||
back?: boolean
|
back?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const wideLayout = computed(() => route.meta.wideLayout ?? false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -12,12 +15,13 @@ defineProps<{
|
||||||
<div
|
<div
|
||||||
sticky top-0 z10 backdrop-blur
|
sticky top-0 z10 backdrop-blur
|
||||||
pt="[env(safe-area-inset-top,0)]"
|
pt="[env(safe-area-inset-top,0)]"
|
||||||
border="b base" bg="[rgba(var(--c-bg-base-rgb),0.7)]"
|
border="b base" bg="[rgba(var(--rgb-bg-base),0.7)]"
|
||||||
|
class="native:lg:w-[calc(100vw-5rem)] native:xl:w-[calc(135%+(100vw-1200px)/2)]"
|
||||||
>
|
>
|
||||||
<div flex justify-between px5 py2 :class="{ 'xl:hidden': $route.name !== 'tag' }">
|
<div flex justify-between px5 py2 :class="{ 'xl:hidden': $route.name !== 'tag' }" data-tauri-drag-region class="native:xl:flex">
|
||||||
<div flex gap-3 items-center overflow-hidden py2>
|
<div flex gap-3 items-center overflow-hidden py2 class="native-mac:pl-14 native-mac:sm:pl-0">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0 lg:hidden
|
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0 xl:hidden
|
||||||
:aria-label="$t('nav.back')"
|
:aria-label="$t('nav.back')"
|
||||||
@click="$router.go(-1)"
|
@click="$router.go(-1)"
|
||||||
>
|
>
|
||||||
|
@ -31,13 +35,16 @@ defineProps<{
|
||||||
<div flex items-center flex-shrink-0 gap-x-2>
|
<div flex items-center flex-shrink-0 gap-x-2>
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
<PwaBadge lg:hidden />
|
<PwaBadge lg:hidden />
|
||||||
<NavUser v-if="isMastoInitialised" />
|
<NavUser v-if="isHydrated" />
|
||||||
<NavUserSkeleton v-else />
|
<NavUserSkeleton v-else />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
<div :class="{ 'xl:block': $route.name !== 'tag' }" hidden h-6 />
|
<div :class="{ 'xl:block': $route.name !== 'tag' }" hidden h-6 />
|
||||||
|
<PwaInstallPrompt lg:hidden />
|
||||||
|
<div :class="isHydrated && wideLayout ? 'xl:w-full sm:max-w-600px' : 'sm:max-w-600px md:shrink-0'" m-auto>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -18,10 +18,10 @@ const emit = defineEmits<{
|
||||||
</div>
|
</div>
|
||||||
<div flex justify-end gap-2>
|
<div flex justify-end gap-2>
|
||||||
<button btn-text @click="emit('choice', 'cancel')">
|
<button btn-text @click="emit('choice', 'cancel')">
|
||||||
{{ cancel || $t('common.confirm_dialog.cancel') }}
|
{{ cancel || $t('confirm.common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button btn-solid @click="emit('choice', 'confirm')">
|
<button btn-solid @click="emit('choice', 'confirm')">
|
||||||
{{ confirm || $t('common.confirm_dialog.confirm') }}
|
{{ confirm || $t('confirm.common.confirm') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -51,7 +51,7 @@ const handleFavouritedBoostedByClose = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="isMastoInitialised">
|
<template v-if="isHydrated">
|
||||||
<ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125>
|
<ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125>
|
||||||
<UserSignIn />
|
<UserSignIn />
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
|
|
@ -53,6 +53,10 @@ const { modelValue: visible } = defineModel<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
const deactivated = useDeactivated()
|
const deactivated = useDeactivated()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
@ -132,12 +136,6 @@ useEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
inheritAttrs: false,
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<!-- Dialog component -->
|
<!-- Dialog component -->
|
||||||
|
|
|
@ -54,22 +54,15 @@ onUnmounted(() => locked.value = false)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div flex flex-row items-center mxa>
|
<div flex flex-row items-center mxa>
|
||||||
|
<div flex="~ col center" max-h-full max-w-full>
|
||||||
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
|
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div absolute top-0 w-full flex justify-between>
|
<div bg="black/30" dark:bg="white/10" ms-4 mb-6 mt-4 text-white rounded-full flex="~ center shrink-0" overflow-hidden>
|
||||||
<button
|
<div v-if="mediaPreviewList.length > 1" p="y-1 x-3" rounded-r-0 shrink-0>
|
||||||
btn-action-icon bg="black/30" aria-label="action.close" hover:bg="black/40" dark:bg="white/30"
|
|
||||||
dark:hover-bg="white/20" pointer-events-auto shrink-0 @click="emit('close')"
|
|
||||||
>
|
|
||||||
<div i-ri:close-line text-white />
|
|
||||||
</button>
|
|
||||||
<div bg="black/30" dark:bg="white/10" ms-4 my-auto text-white rounded-full flex="~ center" overflow-hidden>
|
|
||||||
<div v-if="mediaPreviewList.length > 1" p="y-1 x-2" rounded-r-0 shrink-0>
|
|
||||||
{{ index + 1 }} / {{ mediaPreviewList.length }}
|
{{ index + 1 }} / {{ mediaPreviewList.length }}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-2" rounded-ie-full line-clamp-1
|
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-3" rounded-ie-full line-clamp-1
|
||||||
ws-pre-wrap break-all :title="current.description" w-full
|
ws-pre-wrap break-all :title="current.description" w-full
|
||||||
>
|
>
|
||||||
{{ current.description }}
|
{{ current.description }}
|
||||||
|
@ -77,4 +70,14 @@ onUnmounted(() => locked.value = false)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div absolute top-0 w-full flex justify-end>
|
||||||
|
<button
|
||||||
|
btn-action-icon bg="black/30" aria-label="action.close" hover:bg="black/40" dark:bg="white/30"
|
||||||
|
dark:hover-bg="white/20" pointer-events-auto shrink-0 @click="emit('close')"
|
||||||
|
>
|
||||||
|
<div i-ri:close-line text-white />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -10,7 +10,7 @@ const moreMenuVisible = ref(false)
|
||||||
class="after-content-empty after:(h-[calc(100%+0.5px)] w-0.1px pointer-events-none)"
|
class="after-content-empty after:(h-[calc(100%+0.5px)] w-0.1px pointer-events-none)"
|
||||||
>
|
>
|
||||||
<!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. -->
|
<!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. -->
|
||||||
<template v-if="isMastoInitialised && currentUser">
|
<template v-if="currentUser">
|
||||||
<NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
|
<NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
|
||||||
<div i-ri:home-5-line />
|
<div i-ri:home-5-line />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
@ -24,7 +24,7 @@ const moreMenuVisible = ref(false)
|
||||||
<div i-ri:at-line />
|
<div i-ri:at-line />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="isMastoInitialised && !currentUser">
|
<template v-else>
|
||||||
<NuxtLink :to="`/${currentServer}/explore`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
|
<NuxtLink :to="`/${currentServer}/explore`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
|
||||||
<div i-ri:hashtag />
|
<div i-ri:hashtag />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
|
@ -4,6 +4,8 @@ let { modelValue } = $defineModel<{
|
||||||
}>()
|
}>()
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
function toggleVisible() {
|
function toggleVisible() {
|
||||||
modelValue = !modelValue
|
modelValue = !modelValue
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
const buildInfo = useRuntimeConfig().public.buildInfo
|
const buildInfo = useRuntimeConfig().public.buildInfo
|
||||||
const timeAgoOptions = useTimeAgoOptions()
|
const timeAgoOptions = useTimeAgoOptions()
|
||||||
|
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
const buildTimeDate = new Date(buildInfo.time)
|
const buildTimeDate = new Date(buildInfo.time)
|
||||||
const buildTimeAgo = useTimeAgo(buildTimeDate, timeAgoOptions)
|
const buildTimeAgo = useTimeAgo(buildTimeDate, timeAgoOptions)
|
||||||
|
|
||||||
|
@ -26,6 +28,16 @@ function toggleDark() {
|
||||||
@click="userSettings.zenMode = !userSettings.zenMode"
|
@click="userSettings.zenMode = !userSettings.zenMode"
|
||||||
/>
|
/>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
|
<CommonTooltip :content="$t('settings.about.sponsor_action')">
|
||||||
|
<NuxtLink
|
||||||
|
flex
|
||||||
|
text-lg
|
||||||
|
i-ri-heart-3-line hover="i-ri-heart-3-fill text-rose"
|
||||||
|
:aria-label="$t('settings.about.sponsor_action')"
|
||||||
|
href="https://github.com/sponsors/elk-zone"
|
||||||
|
target="_blank"
|
||||||
|
/>
|
||||||
|
</CommonTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<i18n-t v-if="isHydrated" keypath="nav.built_at">
|
<i18n-t v-if="isHydrated" keypath="nav.built_at">
|
||||||
|
@ -35,8 +47,15 @@ function toggleDark() {
|
||||||
{{ $t('nav.built_at', [$d(buildTimeDate, 'shortDate')]) }}
|
{{ $t('nav.built_at', [$d(buildTimeDate, 'shortDate')]) }}
|
||||||
</span>
|
</span>
|
||||||
·
|
·
|
||||||
<!-- TODO click version to show changelog -->
|
<NuxtLink
|
||||||
<span v-if="buildInfo.env === 'release'">v{{ buildInfo.version }}</span>
|
v-if="buildInfo.env === 'release'"
|
||||||
|
external
|
||||||
|
:href="`https://github.com/elk-zone/elk/releases/tag/v${buildInfo.version}`"
|
||||||
|
target="_blank"
|
||||||
|
font-mono
|
||||||
|
>
|
||||||
|
v{{ buildInfo.version }}
|
||||||
|
</NuxtLink>
|
||||||
<span v-else>{{ buildInfo.env }}</span>
|
<span v-else>{{ buildInfo.env }}</span>
|
||||||
<template v-if="buildInfo.commit && buildInfo.branch !== 'release'">
|
<template v-if="buildInfo.commit && buildInfo.branch !== 'release'">
|
||||||
·
|
·
|
||||||
|
@ -54,6 +73,12 @@ function toggleDark() {
|
||||||
<NuxtLink cursor-pointer hover:underline to="/settings/about">
|
<NuxtLink cursor-pointer hover:underline to="/settings/about">
|
||||||
{{ $t('settings.about.label') }}
|
{{ $t('settings.about.label') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<template v-if="$config.public.privacyPolicyUrl">
|
||||||
|
·
|
||||||
|
<NuxtLink cursor-pointer hover:underline :to="$config.public.privacyPolicyUrl">
|
||||||
|
{{ $t('nav.privacy') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
·
|
·
|
||||||
<NuxtLink href="/m.webtoo.ls/@elk" target="_blank">
|
<NuxtLink href="/m.webtoo.ls/@elk" target="_blank">
|
||||||
Mastodon
|
Mastodon
|
||||||
|
@ -63,7 +88,7 @@ function toggleDark() {
|
||||||
Discord
|
Discord
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
·
|
·
|
||||||
<NuxtLink href="https://github.com/elk-zone" target="_blank" external>
|
<NuxtLink href="https://github.com/elk-zone/elk" target="_blank" external>
|
||||||
GitHub
|
GitHub
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
37
components/nav/NavLogo.vue
Normal file
37
components/nav/NavLogo.vue
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<template>
|
||||||
|
<span shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip"><svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg" w-full
|
||||||
|
aspect="1/1" sm:h-8 xl:h-10 sm:w-8 xl:w-10 viewBox="0 0 250 250" fill="none"
|
||||||
|
>
|
||||||
|
<mask id="a" width="240" height="234" x="4" y="1" maskUnits="userSpaceOnUse" style="mask-type:alpha">
|
||||||
|
<path
|
||||||
|
fill="#D9D9D9"
|
||||||
|
d="M244 123c0 64.617-38.383 112-103 112-64.617 0-103-30.883-103-95.5C38 111.194-8.729 36.236 8 16 29.46-9.959 88.689 6 125 6c64.617 0 119 52.383 119 117Z"
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)">
|
||||||
|
<path
|
||||||
|
class="body"
|
||||||
|
d="M116.94 88.1c-13.344 1.552-20.436-2.019-24.706 10.71 0 0 14.336 21.655 52.54 21.112-2.135 8.848-1.144 15.368-1.144 23.207 0 26.079-20.589 48.821-65.961 48.821-23.03 0-51.015 4.191-72.367 15.911-15.175 8.305-27.048 20.336-32.302 37.023l5.956 8.461 11.4.155v47.889l-13.91 21.966L-19.556 387h13.192l1.144-51.227c6.558-3.881 21.58-13.971 34.391-29.494 17.386-20.879 30.731-51.227 15.022-88.793l11.744-5.045c12.887 30.814 8.388 57.514-2.898 79.013 21.58-.698 40.11-2.095 55.819-4.734l-3.584-43.698 12.659-1.087L129.98 387h13.116l2.212-94.459c10.447-4.502 34.239-21.034 45.372-78.47 1.372-6.986 2.135-12.885 2.516-17.93 1.754-12.806 2.745-27.243 3.051-43.698l-18.683-5.976h57.42l5.567-12.807c-5.414.233-11.896-2.639-11.896-2.639l1.297-6.209H242l-65.199-34.384c-7.244 2.794-14.87 6.442-20.208 10.866-4.27-3.105-19.063-12.807-39.653-13.195Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="wood"
|
||||||
|
d="M6.217 24.493 18.494 21c5.948 21.577 13.345 33.375 22.648 39.352 8.388 5.099 19.75 5.239 31.799 4.579-3.508-1.164-6.787-2.794-9.837-5.045-6.787-5.045-12.582-13.428-16.929-28.64l12.201-3.649c3.279 11.488 7.092 18.085 12.201 21.888 5.11 3.726 11.286 4.657 18.606 5.433 13.726 1.553 30.884 2.174 52.312 12.264 2.898 1.086 5.872 2.483 8.769 4.036-.381-.776-.762-1.553-1.296-2.406-3.66-5.822-10.828-11.953-24.097-16.92l4.27-12.109c21.581 7.917 30.121 19.171 33.553 28.097 3.965 10.168 1.525 18.124 1.525 18.124-3.05 1.009-6.1 2.406-9.608 3.492-6.634-4.579-12.887-8.033-18.835-10.75-21.962-8.304-43.466-2.638-62.53-.853-14.336 1.32-27.452.698-38.814-6.598-11.21-7.14-21.047-20.8-28.215-46.802Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="wood"
|
||||||
|
d="M90.098 45.294c-2.516-5.744-4.041-12.807-3.355-21.5l12.659.932c-.763 10.555 2.897 17.696 7.015 22.353-5.338-.931-10.447-1.04-16.319-1.785ZM170.167 43.974l8.312-9.702c21.58 19.094 8.159 46.415 8.159 46.415l-11.819-1.32c-.382-6.24-1.144-17.836-6.635-24.371 3.584 1.84 6.635 3.865 9.99 6.908 0-5.666-1.754-12.341-8.007-17.93Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg path.wood {
|
||||||
|
fill: var(--c-primary);
|
||||||
|
}
|
||||||
|
svg path.body {
|
||||||
|
fill: var(--c-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -16,7 +16,7 @@ const { notifications } = useNotifications()
|
||||||
<NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
|
<NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div flex relative>
|
<div flex relative>
|
||||||
<div class="i-ri:notification-4-line" md:text-size-inherit text-xl />
|
<div class="i-ri:notification-4-line" text-xl />
|
||||||
<div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
|
<div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
|
||||||
{{ notifications < 10 ? notifications : '•' }}
|
{{ notifications < 10 ? notifications : '•' }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -29,9 +29,10 @@ const { notifications } = useNotifications()
|
||||||
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
|
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
|
||||||
|
|
||||||
<div shrink hidden sm:block mt-4 />
|
<div shrink hidden sm:block mt-4 />
|
||||||
<NavSideItem :text="$t('nav.explore')" :to="isMastoInitialised ? `/${currentServer}/explore` : '/explore'" icon="i-ri:hashtag" :command="command" />
|
<NavSideItem :text="$t('nav.explore')" :to="isHydrated ? `/${currentServer}/explore` : '/explore'" icon="i-ri:hashtag" :command="command" />
|
||||||
<NavSideItem :text="$t('nav.local')" :to="isMastoInitialised ? `/${currentServer}/public/local` : '/public/local'" icon="i-ri:group-2-line " :command="command" />
|
<NavSideItem :text="$t('nav.local')" :to="isHydrated ? `/${currentServer}/public/local` : '/public/local'" icon="i-ri:group-2-line " :command="command" />
|
||||||
<NavSideItem :text="$t('nav.federated')" :to="isMastoInitialised ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
|
<NavSideItem :text="$t('nav.federated')" :to="isHydrated ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
|
||||||
|
<NavSideItem :text="$t('nav.lists')" :to="`/${currentServer}/lists`" icon="i-ri:list-check" :command="command" />
|
||||||
|
|
||||||
<div shrink hidden sm:block mt-4 />
|
<div shrink hidden sm:block mt-4 />
|
||||||
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />
|
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />
|
||||||
|
|
|
@ -29,7 +29,7 @@ useCommand({
|
||||||
})
|
})
|
||||||
|
|
||||||
let activeClass = $ref('text-primary')
|
let activeClass = $ref('text-primary')
|
||||||
onMastoInit(async () => {
|
onHydrated(async () => {
|
||||||
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
||||||
// we don't have currentServer defined until later
|
// we don't have currentServer defined until later
|
||||||
activeClass = ''
|
activeClass = ''
|
||||||
|
@ -39,8 +39,8 @@ onMastoInit(async () => {
|
||||||
|
|
||||||
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
|
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
|
||||||
// when we know there is no user.
|
// when we know there is no user.
|
||||||
const noUserDisable = computed(() => !isMastoInitialised.value || (props.userOnly && !currentUser.value))
|
const noUserDisable = computed(() => !isHydrated.value || (props.userOnly && !currentUser.value))
|
||||||
const noUserVisual = computed(() => isMastoInitialised.value && props.userOnly && !currentUser.value)
|
const noUserVisual = computed(() => isHydrated.value && props.userOnly && !currentUser.value)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -53,7 +53,7 @@ const noUserVisual = computed(() => isMastoInitialised.value && props.userOnly &
|
||||||
:tabindex="noUserDisable ? -1 : null"
|
:tabindex="noUserDisable ? -1 : null"
|
||||||
@click="$scrollToTop"
|
@click="$scrollToTop"
|
||||||
>
|
>
|
||||||
<CommonTooltip :disabled="!isMediumScreen" :content="text" placement="right">
|
<CommonTooltip :disabled="!isMediumOrLargeScreen" :content="text" placement="right">
|
||||||
<div
|
<div
|
||||||
flex items-center gap4
|
flex items-center gap4
|
||||||
w-fit rounded-3
|
w-fit rounded-3
|
||||||
|
@ -66,7 +66,7 @@ const noUserVisual = computed(() => isMastoInitialised.value && props.userOnly &
|
||||||
<div :class="icon" text-xl />
|
<div :class="icon" text-xl />
|
||||||
</slot>
|
</slot>
|
||||||
<slot>
|
<slot>
|
||||||
<span block sm:hidden xl:block>{{ isHydrated ? text : ' ' }}</span>
|
<span block sm:hidden xl:block select-none>{{ isHydrated ? text : ' ' }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
|
|
|
@ -17,18 +17,19 @@ router.afterEach(() => {
|
||||||
flex items-end gap-4
|
flex items-end gap-4
|
||||||
py2 px-5
|
py2 px-5
|
||||||
text-2xl
|
text-2xl
|
||||||
|
select-none
|
||||||
focus-visible:ring="2 current"
|
focus-visible:ring="2 current"
|
||||||
to="/"
|
to="/"
|
||||||
external
|
external
|
||||||
>
|
>
|
||||||
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip">
|
<NavLogo shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip" />
|
||||||
<div hidden xl:block>
|
<div hidden xl:block text-secondary>
|
||||||
{{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
|
{{ $t('app_name') }} <sup text-sm italic mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div
|
<div
|
||||||
hidden xl:flex items-center me-8 mt-2
|
hidden xl:flex items-center me-8 mt-2
|
||||||
:class="{ 'pointer-events-none op40': !back || back === '/', 'xl:flex': $route.name !== 'tag' }"
|
:class="{ 'pointer-events-none op0': !back || back === '/', 'xl:flex': $route.name !== 'tag' }"
|
||||||
>
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:aria-label="$t('nav.back')"
|
:aria-label="$t('nav.back')"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<VDropdown v-if="isMastoInitialised && currentUser" sm:hidden>
|
<VDropdown v-if="isHydrated && currentUser" sm:hidden>
|
||||||
<div style="-webkit-touch-callout: none;">
|
<div style="-webkit-touch-callout: none;">
|
||||||
<AccountAvatar
|
<AccountAvatar
|
||||||
ref="avatar"
|
ref="avatar"
|
||||||
|
|
|
@ -66,7 +66,10 @@ const isLegacyAccount = computed(() => !currentUser.value?.vapidKey)
|
||||||
:disabled="busy || isLegacyAccount"
|
:disabled="busy || isLegacyAccount"
|
||||||
@click="$emit('subscribe')"
|
@click="$emit('subscribe')"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" :class="busy && animate ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:check-line'" />
|
<span v-if="busy && animate" aria-hidden="true" block animate-spin preserve-3d>
|
||||||
|
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span v-else aria-hidden="true" block i-ri:check-line />
|
||||||
<span>{{ $t('settings.notifications.push_notifications.warning.enable_desktop') }}</span>
|
<span>{{ $t('settings.notifications.push_notifications.warning.enable_desktop') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<slot name="error" />
|
<slot name="error" />
|
||||||
|
|
|
@ -9,7 +9,7 @@ const { paginator, stream } = defineProps<{
|
||||||
stream?: Promise<WsEvents>
|
stream?: Promise<WsEvents>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const virtualScroller = $(useFeatureFlag('experimentalVirtualScroller'))
|
const virtualScroller = false // TODO: fix flickering issue with virtual scroll
|
||||||
|
|
||||||
const groupCapacity = Number.MAX_VALUE // No limit
|
const groupCapacity = Number.MAX_VALUE // No limit
|
||||||
|
|
||||||
|
@ -27,6 +27,10 @@ const groupId = (item: mastodon.v1.Notification): string => {
|
||||||
return JSON.stringify(id)
|
return JSON.stringify(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasHeader(account: mastodon.v1.Account) {
|
||||||
|
return !account.header.endsWith('/original/missing.png')
|
||||||
|
}
|
||||||
|
|
||||||
function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
||||||
const results: NotificationSlot[] = []
|
const results: NotificationSlot[] = []
|
||||||
|
|
||||||
|
@ -44,31 +48,31 @@ function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
||||||
// This normally happens when you transfer an account, if not, show
|
// This normally happens when you transfer an account, if not, show
|
||||||
// a big profile card for each follow
|
// a big profile card for each follow
|
||||||
if (group[0].type === 'follow') {
|
if (group[0].type === 'follow') {
|
||||||
let groups: mastodon.v1.Notification[] = []
|
// Order group by followers count
|
||||||
|
const processedGroup = [...group]
|
||||||
|
processedGroup.sort((a, b) => {
|
||||||
|
const aHasHeader = hasHeader(a.account)
|
||||||
|
const bHasHeader = hasHeader(b.account)
|
||||||
|
if (bHasHeader && !aHasHeader)
|
||||||
|
return 1
|
||||||
|
if (aHasHeader && !bHasHeader)
|
||||||
|
return -1
|
||||||
|
return b.account.followersCount - a.account.followersCount
|
||||||
|
})
|
||||||
|
|
||||||
function newGroup() {
|
if (processedGroup.length > 0 && hasHeader(processedGroup[0].account))
|
||||||
if (groups.length > 0) {
|
results.push(processedGroup.shift()!)
|
||||||
|
|
||||||
|
if (processedGroup.length === 1 && hasHeader(processedGroup[0].account))
|
||||||
|
results.push(processedGroup.shift()!)
|
||||||
|
|
||||||
|
if (processedGroup.length > 0) {
|
||||||
results.push({
|
results.push({
|
||||||
id: `grouped-${id++}`,
|
id: `grouped-${id++}`,
|
||||||
type: 'grouped-follow',
|
type: 'grouped-follow',
|
||||||
items: groups,
|
items: processedGroup,
|
||||||
})
|
})
|
||||||
groups = []
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of group) {
|
|
||||||
const hasHeader = !item.account.header.endsWith('/original/missing.png')
|
|
||||||
if (hasHeader && (item.account.followersCount > 250 || (group.length === 1 && item.account.followersCount > 25))) {
|
|
||||||
newGroup()
|
|
||||||
results.push(item)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
groups.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newGroup()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,6 +116,12 @@ function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeFiltered(items: mastodon.v1.Notification[]): mastodon.v1.Notification[] {
|
||||||
|
return items.filter(item => !item.status?.filtered?.find(
|
||||||
|
filter => filter.filter.filterAction === 'hide' && filter.filter.context.includes('notifications'),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
function preprocess(items: NotificationSlot[]): NotificationSlot[] {
|
function preprocess(items: NotificationSlot[]): NotificationSlot[] {
|
||||||
const flattenedNotifications: mastodon.v1.Notification[] = []
|
const flattenedNotifications: mastodon.v1.Notification[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
@ -131,7 +141,7 @@ function preprocess(items: NotificationSlot[]): NotificationSlot[] {
|
||||||
flattenedNotifications.push(item)
|
flattenedNotifications.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return groupItems(flattenedNotifications)
|
return groupItems(removeFiltered(flattenedNotifications))
|
||||||
}
|
}
|
||||||
|
|
||||||
const { clearNotifications } = useNotifications()
|
const { clearNotifications } = useNotifications()
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { PushSubscriptionError } from '~/composables/push-notifications/types'
|
||||||
|
|
||||||
defineProps<{ show?: boolean }>()
|
defineProps<{ show?: boolean }>()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -49,7 +51,10 @@ const saveSettings = async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscription = await updateSubscription()
|
const subscription = await updateSubscription()
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
// todo: handle error
|
// todo: handle error
|
||||||
|
console.error(err)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
busy = false
|
busy = false
|
||||||
|
@ -72,8 +77,14 @@ const doSubscribe = async () => {
|
||||||
showSubscribeError = true
|
showSubscribeError = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch (err) {
|
||||||
|
if (err instanceof PushSubscriptionError) {
|
||||||
|
subscribeError = t(`settings.notifications.push_notifications.subscription_error.${err.code}`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.error(err)
|
||||||
subscribeError = t('settings.notifications.push_notifications.subscription_error.request_error')
|
subscribeError = t('settings.notifications.push_notifications.subscription_error.request_error')
|
||||||
|
}
|
||||||
showSubscribeError = true
|
showSubscribeError = true
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
@ -91,6 +102,9 @@ const removeSubscription = async () => {
|
||||||
try {
|
try {
|
||||||
await unsubscribe()
|
await unsubscribe()
|
||||||
}
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
finally {
|
finally {
|
||||||
busy = false
|
busy = false
|
||||||
animateRemoveSubscription = false
|
animateRemoveSubscription = false
|
||||||
|
@ -133,7 +147,10 @@ onActivated(() => (busy = false))
|
||||||
:class="busy || !saveEnabled ? 'border-transparent' : null"
|
:class="busy || !saveEnabled ? 'border-transparent' : null"
|
||||||
:disabled="busy || !saveEnabled"
|
:disabled="busy || !saveEnabled"
|
||||||
>
|
>
|
||||||
<span :class="busy && animateSave ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:save-2-fill'" />
|
<span v-if="busy && animateSave" aria-hidden="true" block animate-spin preserve-3d>
|
||||||
|
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span v-else block aria-hidden="true" i-ri:save-2-fill />
|
||||||
{{ $t('settings.notifications.push_notifications.save_settings') }}
|
{{ $t('settings.notifications.push_notifications.save_settings') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
@ -143,7 +160,7 @@ onActivated(() => (busy = false))
|
||||||
:disabled="busy || !saveEnabled"
|
:disabled="busy || !saveEnabled"
|
||||||
@click="undoChanges"
|
@click="undoChanges"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" class="i-material-symbols:undo-rounded" />
|
<span aria-hidden="true" class="block i-material-symbols:undo-rounded" />
|
||||||
{{ $t('settings.notifications.push_notifications.undo_settings') }}
|
{{ $t('settings.notifications.push_notifications.undo_settings') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -155,7 +172,10 @@ onActivated(() => (busy = false))
|
||||||
:class="busy ? 'border-transparent' : null"
|
:class="busy ? 'border-transparent' : null"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" :class="busy && animateRemoveSubscription ? 'i-ri:loader-2-fill animate-spin' : 'i-material-symbols:cancel-rounded'" />
|
<span v-if="busy && animateRemoveSubscription" aria-hidden="true" block animate-spin preserve-3d>
|
||||||
|
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span v-else block aria-hidden="true" i-material-symbols:cancel-rounded />
|
||||||
{{ $t('settings.notifications.push_notifications.unsubscribe') }}
|
{{ $t('settings.notifications.push_notifications.unsubscribe') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -30,9 +30,8 @@ const toggleApply = () => {
|
||||||
<div
|
<div
|
||||||
v-if="removable"
|
v-if="removable"
|
||||||
:aria-label="$t('attachment.remove_label')"
|
:aria-label="$t('attachment.remove_label')"
|
||||||
hover:bg="gray/40" transition-100 p-1 rounded-5 cursor-pointer
|
class="bg-black/75 hover:bg-red/75"
|
||||||
:class="[isHydrated && isSmallScreen ? '' : 'op-0 group-hover:op-100hover:']"
|
text-white px2 py2 rounded-full cursor-pointer
|
||||||
mix-blend-difference
|
|
||||||
@click="$emit('remove')"
|
@click="$emit('remove')"
|
||||||
>
|
>
|
||||||
<div i-ri:close-line text-3 :class="[isHydrated && isSmallScreen ? 'text-6' : 'text-3']" />
|
<div i-ri:close-line text-3 :class="[isHydrated && isSmallScreen ? 'text-6' : 'text-3']" />
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
<script setup>
|
|
||||||
const disabled = computed(() => !isMastoInitialised.value || !currentUser.value)
|
|
||||||
const disabledVisual = computed(() => isMastoInitialised.value && !currentUser.value)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
flex="~ gap2 center"
|
|
||||||
w-9 h-9 py2
|
|
||||||
xl="w-auto h-auto"
|
|
||||||
rounded-3
|
|
||||||
cursor-pointer disabled:pointer-events-none
|
|
||||||
text-primary
|
|
||||||
border-1 border-primary
|
|
||||||
:class="disabledVisual ? 'op25' : 'hover:bg-primary hover:text-inverted'"
|
|
||||||
:disabled="disabled"
|
|
||||||
@click="openPublishDialog()"
|
|
||||||
>
|
|
||||||
<div i-ri:quill-pen-line />
|
|
||||||
<span hidden xl:block>{{ $t('action.compose') }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
|
@ -19,7 +19,7 @@ async function openEmojiPicker() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const promise = import('@emoji-mart/data').then(r => r.default)
|
const promise = import('@emoji-mart/data/sets/14/twitter.json').then(r => r.default)
|
||||||
const { Picker } = await import('emoji-mart')
|
const { Picker } = await import('emoji-mart')
|
||||||
picker = new Picker({
|
picker = new Picker({
|
||||||
data: () => promise,
|
data: () => promise,
|
||||||
|
@ -28,6 +28,7 @@ async function openEmojiPicker() {
|
||||||
? emit('select', native)
|
? emit('select', native)
|
||||||
: emit('selectCustom', { src, alt, 'data-emoji-id': name })
|
: emit('selectCustom', { src, alt, 'data-emoji-id': name })
|
||||||
},
|
},
|
||||||
|
set: 'twitter',
|
||||||
theme: colorMode.value,
|
theme: colorMode.value,
|
||||||
custom: customEmojisData.value,
|
custom: customEmojisData.value,
|
||||||
})
|
})
|
||||||
|
@ -44,7 +45,7 @@ const hideEmojiPicker = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonTooltip content="Add emojis">
|
<CommonTooltip :content="$t('tooltip.add_emojis')">
|
||||||
<VDropdown
|
<VDropdown
|
||||||
auto-boundary-max-size
|
auto-boundary-max-size
|
||||||
@apply-show="openEmojiPicker()"
|
@apply-show="openEmojiPicker()"
|
||||||
|
|
23
components/publish/PublishErrMessage.vue
Normal file
23
components/publish/PublishErrMessage.vue
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
describedBy: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
flex="~ col"
|
||||||
|
gap-1 text-sm
|
||||||
|
pt-1 ps-2 pe-1 pb-2
|
||||||
|
text-red-600 dark:text-red-400
|
||||||
|
border="~ base rounded red-600 dark:red-400"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EditorContent } from '@tiptap/vue-3'
|
import { EditorContent } from '@tiptap/vue-3'
|
||||||
|
import stringLength from 'string-length'
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
import type { Ref } from 'vue'
|
|
||||||
import type { Draft } from '~/types'
|
import type { Draft } from '~/types'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -35,7 +35,7 @@ const {
|
||||||
dropZoneRef,
|
dropZoneRef,
|
||||||
} = $(useUploadMediaAttachment($$(draft)))
|
} = $(useUploadMediaAttachment($$(draft)))
|
||||||
|
|
||||||
let { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft } = $(usePublish(
|
let { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages } = $(usePublish(
|
||||||
{
|
{
|
||||||
draftState,
|
draftState,
|
||||||
...$$({ expanded, isUploading, initialDraft: initial }),
|
...$$({ expanded, isUploading, initialDraft: initial }),
|
||||||
|
@ -62,7 +62,19 @@ const { editor } = useTiptap({
|
||||||
},
|
},
|
||||||
onPaste: handlePaste,
|
onPaste: handlePaste,
|
||||||
})
|
})
|
||||||
const characterCount = $computed(() => htmlToText(editor.value?.getHTML() || '').length)
|
const characterCount = $computed(() => {
|
||||||
|
let length = stringLength(htmlToText(editor.value?.getHTML() || ''))
|
||||||
|
|
||||||
|
if (draft.mentions) {
|
||||||
|
// + 1 is needed as mentions always need a space seperator at the end
|
||||||
|
length += draft.mentions.map((mention) => {
|
||||||
|
const [handle] = mention.split('@')
|
||||||
|
return `@${handle}`
|
||||||
|
}).join(' ').length + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return length
|
||||||
|
})
|
||||||
|
|
||||||
async function handlePaste(evt: ClipboardEvent) {
|
async function handlePaste(evt: ClipboardEvent) {
|
||||||
const files = evt.clipboardData?.files
|
const files = evt.clipboardData?.files
|
||||||
|
@ -90,6 +102,19 @@ async function publish() {
|
||||||
emit('published', status)
|
emit('published', status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useWebShareTarget(async ({ data: { data, action } }: any) => {
|
||||||
|
if (action !== 'compose-with-shared-data')
|
||||||
|
return
|
||||||
|
|
||||||
|
editor.value?.commands.focus('end')
|
||||||
|
|
||||||
|
if (data.text !== undefined)
|
||||||
|
editor.value?.commands.insertContent(data.text)
|
||||||
|
|
||||||
|
if (data.files !== undefined)
|
||||||
|
await uploadAttachments(data.files)
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focusEditor: () => {
|
focusEditor: () => {
|
||||||
editor.value?.commands?.focus?.()
|
editor.value?.commands?.focus?.()
|
||||||
|
@ -98,7 +123,7 @@ defineExpose({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isMastoInitialised && currentUser" flex="~ col gap-4" py3 px2 sm:px4>
|
<div v-if="isHydrated && currentUser" flex="~ col gap-4" py3 px2 sm:px4>
|
||||||
<template v-if="draft.editingStatus">
|
<template v-if="draft.editingStatus">
|
||||||
<div flex="~ col gap-1">
|
<div flex="~ col gap-1">
|
||||||
<div id="state-editing" text-secondary self-center>
|
<div id="state-editing" text-secondary self-center>
|
||||||
|
@ -120,6 +145,12 @@ defineExpose({
|
||||||
border="2 dashed transparent"
|
border="2 dashed transparent"
|
||||||
:class="[isSending ? 'pointer-events-none' : '', isOverDropZone ? '!border-primary' : '']"
|
:class="[isSending ? 'pointer-events-none' : '', isOverDropZone ? '!border-primary' : '']"
|
||||||
>
|
>
|
||||||
|
<ContentMentionGroup v-if="draft.mentions?.length && shouldExpanded" replying>
|
||||||
|
<button v-for="m, i of draft.mentions" :key="m" text-primary hover:color-red @click="draft.mentions?.splice(i, 1)">
|
||||||
|
{{ acctToShortHandle(m) }}
|
||||||
|
</button>
|
||||||
|
</ContentMentionGroup>
|
||||||
|
|
||||||
<div v-if="draft.params.sensitive">
|
<div v-if="draft.params.sensitive">
|
||||||
<input
|
<input
|
||||||
v-model="draft.params.spoilerText"
|
v-model="draft.params.spoilerText"
|
||||||
|
@ -130,6 +161,29 @@ defineExpose({
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PublishErrMessage v-if="failedMessages.length > 0" described-by="publish-failed">
|
||||||
|
<head id="publish-failed" flex justify-between>
|
||||||
|
<div flex items-center gap-x-2 font-bold>
|
||||||
|
<div aria-hidden="true" i-ri:error-warning-fill />
|
||||||
|
<p>{{ $t('state.publish_failed') }}</p>
|
||||||
|
</div>
|
||||||
|
<CommonTooltip placement="bottom" :content="$t('action.clear_publish_failed')">
|
||||||
|
<button
|
||||||
|
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="$t('action.clear_publish_failed')"
|
||||||
|
@click="failedMessages = []"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
</head>
|
||||||
|
<ol ps-2 sm:ps-1>
|
||||||
|
<li v-for="(error, i) in failedMessages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
||||||
|
<strong>{{ i + 1 }}.</strong>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</PublishErrMessage>
|
||||||
|
|
||||||
<div relative flex-1 flex flex-col>
|
<div relative flex-1 flex flex-col>
|
||||||
<EditorContent
|
<EditorContent
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
|
@ -139,18 +193,14 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isUploading" flex gap-1 items-center text-sm p1 text-primary>
|
<div v-if="isUploading" flex gap-1 items-center text-sm p1 text-primary>
|
||||||
<div i-ri:loader-2-fill animate-spin />
|
<div animate-spin preserve-3d>
|
||||||
|
<div i-ri:loader-2-fill />
|
||||||
|
</div>
|
||||||
{{ $t('state.uploading') }}
|
{{ $t('state.uploading') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<PublishErrMessage
|
||||||
v-else-if="failedAttachments.length > 0"
|
v-else-if="failedAttachments.length > 0"
|
||||||
role="alert"
|
:described-by="isExceedingAttachmentLimit ? 'upload-failed uploads-per-post' : 'upload-failed'"
|
||||||
:aria-describedby="isExceedingAttachmentLimit ? 'upload-failed uploads-per-post' : 'upload-failed'"
|
|
||||||
flex="~ col"
|
|
||||||
gap-1 text-sm
|
|
||||||
pt-1 ps-2 pe-1 pb-2
|
|
||||||
text-red-600 dark:text-red-400
|
|
||||||
border="~ base rounded red-600 dark:red-400"
|
|
||||||
>
|
>
|
||||||
<head id="upload-failed" flex justify-between>
|
<head id="upload-failed" flex justify-between>
|
||||||
<div flex items-center gap-x-2 font-bold>
|
<div flex items-center gap-x-2 font-bold>
|
||||||
|
@ -159,10 +209,8 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
<CommonTooltip placement="bottom" :content="$t('action.clear_upload_failed')">
|
<CommonTooltip placement="bottom" :content="$t('action.clear_upload_failed')">
|
||||||
<button
|
<button
|
||||||
flex rounded-4 p1
|
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100
|
||||||
hover:bg-active cursor-pointer transition-100
|
:aria-label="$t('action.clear_upload_failed')" @click="failedAttachments = []"
|
||||||
:aria-label="$t('action.clear_upload_failed')"
|
|
||||||
@click="failedAttachments = []"
|
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
||||||
</button>
|
</button>
|
||||||
|
@ -177,7 +225,7 @@ defineExpose({
|
||||||
<span>{{ error[0] }}</span>
|
<span>{{ error[0] }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</PublishErrMessage>
|
||||||
|
|
||||||
<div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto>
|
<div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto>
|
||||||
<PublishAttachment
|
<PublishAttachment
|
||||||
|
@ -193,7 +241,7 @@ defineExpose({
|
||||||
<div flex gap-4>
|
<div flex gap-4>
|
||||||
<div w-12 h-full sm:block hidden />
|
<div w-12 h-full sm:block hidden />
|
||||||
<div
|
<div
|
||||||
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="between" max-w-full
|
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
|
||||||
border="t base"
|
border="t base"
|
||||||
>
|
>
|
||||||
<PublishEmojiPicker
|
<PublishEmojiPicker
|
||||||
|
@ -259,7 +307,18 @@ defineExpose({
|
||||||
</template>
|
</template>
|
||||||
</PublishVisibilityPicker>
|
</PublishVisibilityPicker>
|
||||||
|
|
||||||
<CommonTooltip id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')" :disabled="!isPublishDisabled">
|
<CommonTooltip v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top" :content="$t('tooltip.publish_failed')">
|
||||||
|
<button
|
||||||
|
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit aria-describedby="publish-failed-tooltip"
|
||||||
|
>
|
||||||
|
<span block>
|
||||||
|
<div block i-carbon:face-dizzy-filled />
|
||||||
|
</span>
|
||||||
|
<span>{{ $t('state.publish_failed') }}</span>
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
|
||||||
|
<CommonTooltip v-else id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')" :disabled="!isPublishDisabled">
|
||||||
<button
|
<button
|
||||||
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
|
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
|
||||||
md:w-fit
|
md:w-fit
|
||||||
|
@ -268,7 +327,12 @@ defineExpose({
|
||||||
aria-describedby="publish-tooltip"
|
aria-describedby="publish-tooltip"
|
||||||
@click="publish"
|
@click="publish"
|
||||||
>
|
>
|
||||||
<div v-if="isSending" i-ri:loader-2-fill animate-spin />
|
<span v-if="isSending" block animate-spin preserve-3d>
|
||||||
|
<div block i-ri:loader-2-fill />
|
||||||
|
</span>
|
||||||
|
<span v-if="failedMessages.length" block>
|
||||||
|
<div block i-carbon:face-dizzy-filled />
|
||||||
|
</span>
|
||||||
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
|
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
|
||||||
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
|
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
|
||||||
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>
|
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>
|
||||||
|
|
|
@ -24,7 +24,7 @@ onMounted(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex="~ col" pt-6 h-screen>
|
<div flex="~ col" pt-6 h-screen>
|
||||||
<div text-right h-8>
|
<div inline-flex justify-end h-8>
|
||||||
<VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end">
|
<VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end">
|
||||||
<button btn-text flex="inline center">
|
<button btn-text flex="inline center">
|
||||||
{{ $t('compose.drafts', nonEmptyDrafts.length, { named: { v: formatNumber(nonEmptyDrafts.length) } }) }} <div aria-hidden="true" i-ri:arrow-down-s-line />
|
{{ $t('compose.drafts', nonEmptyDrafts.length, { named: { v: formatNumber(nonEmptyDrafts.length) } }) }} <div aria-hidden="true" i-ri:arrow-down-s-line />
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
v-if="$pwa?.needRefresh"
|
v-if="$pwa?.needRefresh"
|
||||||
bg="fade" relative rounded
|
bg="primary-fade" relative rounded
|
||||||
flex="~ gap-1 center" px3 py1 text-primary
|
flex="~ gap-1 center" px3 py1 text-primary
|
||||||
@click="$pwa.updateServiceWorker()"
|
@click="$pwa.updateServiceWorker()"
|
||||||
>
|
>
|
||||||
|
|
22
components/pwa/PwaInstallPrompt.client.vue
Normal file
22
components/pwa/PwaInstallPrompt.client.vue
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="$pwa?.showInstallPrompt && !$pwa?.needRefresh"
|
||||||
|
m-2 p5 bg="primary-fade" relative
|
||||||
|
rounded-lg of-hidden
|
||||||
|
flex="~ col gap-3"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<h2 flex="~ gap-2" items-center>
|
||||||
|
{{ $t('pwa.install_title') }}
|
||||||
|
</h2>
|
||||||
|
<div flex="~ gap-1">
|
||||||
|
<button type="button" btn-solid px-4 py-1 text-center text-sm @click="$pwa.install()">
|
||||||
|
{{ $t('pwa.install') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="$pwa.cancelInstall()">
|
||||||
|
{{ $t('pwa.dismiss') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div i-material-symbols:install-desktop-rounded absolute text-6em bottom--2 inset-ie--2 text-primary dark:text-white op10 class="-z-1 rtl-flip" />
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="$pwa?.needRefresh"
|
v-if="$pwa?.needRefresh"
|
||||||
m-2 p5 bg="fade" relative
|
m-2 p5 bg="primary-fade" relative
|
||||||
rounded-lg of-hidden
|
rounded-lg of-hidden
|
||||||
flex="~ col gap-3"
|
flex="~ col gap-3"
|
||||||
>
|
>
|
||||||
|
@ -16,6 +16,6 @@
|
||||||
{{ $t('pwa.dismiss') }}
|
{{ $t('pwa.dismiss') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div i-ri-arrow-down-circle-line absolute text-8em bottom--10 inset-ie--10 text-primary op10 class="-z-1" />
|
<div i-ri-arrow-down-circle-line absolute text-8em bottom--10 inset-ie--10 text-primary dark:text-white op10 class="-z-1" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
22
components/search/SearchEmojiInfo.vue
Normal file
22
components/search/SearchEmojiInfo.vue
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface SearchEmoji {
|
||||||
|
title: string
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
emoji: SearchEmoji
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex="~ gap3" items-center text-base>
|
||||||
|
<img
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
:src="emoji.src"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
<span shrink overflow-hidden leading-none text-base><span text-secondary>:</span>{{ emoji.title }}<span text-secondary>:</span></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -19,9 +19,9 @@ const totalTrend = $computed(() =>
|
||||||
<span>
|
<span>
|
||||||
{{ hashtag.name }}
|
{{ hashtag.name }}
|
||||||
</span>
|
</span>
|
||||||
<CommonTrending :history="hashtag.history" text-xs text-secondary truncate />
|
<CommonTrending v-if="hashtag.history" :history="hashtag.history" text-xs text-secondary truncate />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="totalTrend" absolute left-15 right-0 top-0 bottom-4 op35 flex place-items-center place-content-center ml-auto>
|
<div v-if="totalTrend && hashtag.history" absolute left-15 right-0 top-0 bottom-4 op35 flex place-items-center place-content-center ml-auto>
|
||||||
<CommonTrendingCharts
|
<CommonTrendingCharts
|
||||||
:history="hashtag.history" :width="150" :height="20"
|
:history="hashtag.history" :width="150" :height="20"
|
||||||
text-xs text-secondary h-full w-full
|
text-xs text-secondary h-full w-full
|
||||||
|
|
|
@ -37,17 +37,19 @@ const shift = (delta: number) => index.value = (index.value + delta % results.va
|
||||||
|
|
||||||
const activate = () => {
|
const activate = () => {
|
||||||
const currentIndex = index.value
|
const currentIndex = index.value
|
||||||
index.value = -1
|
|
||||||
|
|
||||||
if (query.value.length === 0)
|
if (query.value.length === 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
(document.activeElement as HTMLElement).blur()
|
// Disable redirection until search page is implemented
|
||||||
|
if (currentIndex === -1) {
|
||||||
// Disable until search page is implemented
|
index.value = 0
|
||||||
if (currentIndex === -1)
|
|
||||||
// router.push(`/search?q=${query.value}`)
|
// router.push(`/search?q=${query.value}`)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
(document.activeElement as HTMLElement).blur()
|
||||||
|
index.value = -1
|
||||||
|
|
||||||
router.push(results.value[currentIndex].to)
|
router.push(results.value[currentIndex].to)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ColorMode } from '~/types'
|
import type { ColorMode } from '~/composables/settings'
|
||||||
|
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
@ -9,24 +9,33 @@ function setColorMode(mode: ColorMode) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex="~ gap4" w-full>
|
<div flex="~ gap4 wrap" w-full>
|
||||||
<button
|
<button
|
||||||
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base
|
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap
|
||||||
:tabindex="colorMode.value === 'dark' ? 0 : -1"
|
:tabindex="colorMode.preference === 'dark' ? 0 : -1"
|
||||||
:class="colorMode.value === 'dark' ? 'pointer-events-none' : 'filter-saturate-0'"
|
:class="colorMode.preference === 'dark' ? 'pointer-events-none' : 'filter-saturate-0'"
|
||||||
@click="setColorMode('dark')"
|
@click="setColorMode('dark')"
|
||||||
>
|
>
|
||||||
<div i-ri:moon-line />
|
<div i-ri:moon-line />
|
||||||
{{ $t('settings.interface.dark_mode') }}
|
{{ $t('settings.interface.dark_mode') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base
|
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap
|
||||||
:tabindex="colorMode.value === 'light' ? 0 : -1"
|
:tabindex="colorMode.preference === 'light' ? 0 : -1"
|
||||||
:class="colorMode.value === 'light' ? 'pointer-events-none' : 'filter-saturate-0'"
|
:class="colorMode.preference === 'light' ? 'pointer-events-none' : 'filter-saturate-0'"
|
||||||
@click="setColorMode('light')"
|
@click="setColorMode('light')"
|
||||||
>
|
>
|
||||||
<div i-ri:sun-line />
|
<div i-ri:sun-line />
|
||||||
{{ $t('settings.interface.light_mode') }}
|
{{ $t('settings.interface.light_mode') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap
|
||||||
|
:tabindex="colorMode.preference === 'system' ? 0 : -1"
|
||||||
|
:class="colorMode.preference === 'system' ? 'pointer-events-none' : 'filter-saturate-0'"
|
||||||
|
@click="setColorMode('system')"
|
||||||
|
>
|
||||||
|
<div i-ri:computer-line />
|
||||||
|
{{ $t('settings.interface.system_mode') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,15 +1,69 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DEFAULT_FONT_SIZE } from '~/constants'
|
import { DEFAULT_FONT_SIZE } from '~/constants'
|
||||||
import type { FontSize } from '~/types'
|
import type { FontSize } from '~/composables/settings'
|
||||||
|
|
||||||
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as FontSize[]
|
const userSettings = useUserSettings()
|
||||||
const fontSize = useFontSizeRef()
|
|
||||||
|
const sizes = (new Array(11)).fill(0).map((x, i) => `${10 + i}px`) as FontSize[]
|
||||||
|
|
||||||
|
const setFontSize = (e: Event) => {
|
||||||
|
if (e.target && 'valueAsNumber' in e.target)
|
||||||
|
userSettings.value.fontSize = sizes[e.target.valueAsNumber as number]
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<select v-model="fontSize">
|
<div flex items-center space-x-4>
|
||||||
<option v-for="size in sizes" :key="size" :value="size" :selected="fontSize === size">
|
<span text-xs text-secondary>Aa</span>
|
||||||
{{ `${$t(`settings.interface.size_label.${size}`)}${size === DEFAULT_FONT_SIZE ? $t('settings.interface.default') : ''}` }}
|
<div flex-1 relative flex items-center>
|
||||||
</option>
|
<input
|
||||||
</select>
|
:value="sizes.indexOf(userSettings.fontSize)"
|
||||||
|
:aria-valuetext="`${userSettings.fontSize}${userSettings.fontSize === DEFAULT_FONT_SIZE ? ` ${$t('settings.interface.default')}` : ''}`"
|
||||||
|
:min="0"
|
||||||
|
:max="sizes.length - 1"
|
||||||
|
:step="1"
|
||||||
|
type="range"
|
||||||
|
focus:outline-none
|
||||||
|
appearance-none bg-transparent
|
||||||
|
w-full cursor-pointer
|
||||||
|
@change="setFontSize"
|
||||||
|
>
|
||||||
|
<div flex items-center justify-between absolute w-full pointer-events-none>
|
||||||
|
<div
|
||||||
|
v-for="i in sizes.length" :key="i"
|
||||||
|
h-3 w-3
|
||||||
|
rounded-full bg-secondary-light
|
||||||
|
relative
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="(sizes.indexOf(userSettings.fontSize)) === i - 1"
|
||||||
|
absolute rounded-full class="-top-1 -left-1"
|
||||||
|
bg-primary h-5 w-5
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span text-xl text-secondary>Aa</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
input[type=range]::-webkit-slider-runnable-track {
|
||||||
|
--at-apply: bg-secondary-light rounded-full h1 op60;
|
||||||
|
}
|
||||||
|
input[type=range]:focus:-webkit-slider-runnable-track {
|
||||||
|
--at-apply: outline-2 outline-red;
|
||||||
|
}
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
--at-apply: w3 h3 bg-primary -mt-1 outline outline-3 outline-primary rounded-full cursor-pointer appearance-none;
|
||||||
|
}
|
||||||
|
input[type=range]::-moz-range-track {
|
||||||
|
--at-apply: bg-secondary-light rounded-full h1 op60;
|
||||||
|
}
|
||||||
|
input[type=range]:focus::-moz-range-track {
|
||||||
|
--at-apply: outline-2 outline-red;
|
||||||
|
}
|
||||||
|
input[type=range]::-moz-range-thumb {
|
||||||
|
--at-apply: w3 h3 bg-primary -mt-1 outline outline-3 outline-primary rounded-full cursor-pointer appearance-none border-none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -9,6 +9,7 @@ const props = defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
external?: true
|
external?: true
|
||||||
large?: true
|
large?: true
|
||||||
|
match?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -39,7 +40,7 @@ useCommand({
|
||||||
:to="to"
|
:to="to"
|
||||||
:external="external"
|
:external="external"
|
||||||
exact-active-class="text-primary"
|
exact-active-class="text-primary"
|
||||||
:class="disabled ? 'op25 pointer-events-none ' : ''"
|
:class="disabled ? 'op25 pointer-events-none ' : match ? 'text-primary' : ''"
|
||||||
block w-full group focus:outline-none
|
block w-full group focus:outline-none
|
||||||
:tabindex="disabled ? -1 : null"
|
:tabindex="disabled ? -1 : null"
|
||||||
@click="to ? $scrollToTop() : undefined"
|
@click="to ? $scrollToTop() : undefined"
|
||||||
|
@ -62,7 +63,7 @@ useCommand({
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div space-y-1>
|
<div flex="~ col gap-0.5">
|
||||||
<p>
|
<p>
|
||||||
<slot>
|
<slot>
|
||||||
<span>{{ text }}</span>
|
<span>{{ text }}</span>
|
||||||
|
|
|
@ -2,13 +2,14 @@
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import type { LocaleObject } from '#i18n'
|
import type { LocaleObject } from '#i18n'
|
||||||
|
|
||||||
const { locale, setLocale } = useI18n()
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> }
|
const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<select :value="locale" @input="e => setLocale((e.target as any).value)">
|
<select v-model="userSettings.language">
|
||||||
<option v-for="item in locales" :key="item.code" :value="item.code" :selected="locale === item.code">
|
<option v-for="item in locales" :key="item.code" :value="item.code" :selected="userSettings.language === item.code">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
31
components/settings/SettingsSponsorsList.vue
Normal file
31
components/settings/SettingsSponsorsList.vue
Normal file
File diff suppressed because one or more lines are too long
29
components/settings/SettingsThemeColors.vue
Normal file
29
components/settings/SettingsThemeColors.vue
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ThemeColors } from '~/composables/settings'
|
||||||
|
|
||||||
|
const themes = await import('~/constants/themes.json').then(r => r.default) as [string, ThemeColors][]
|
||||||
|
const settings = $(useUserSettings())
|
||||||
|
|
||||||
|
const currentTheme = $computed(() => settings.themeColors?.['--theme-color-name'] || themes[0][0])
|
||||||
|
|
||||||
|
function updateTheme(theme: ThemeColors) {
|
||||||
|
settings.themeColors = theme
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex="~ gap4 wrap" p2>
|
||||||
|
<button
|
||||||
|
v-for="[key, theme] in themes" :key="key"
|
||||||
|
:style="{
|
||||||
|
'background': key,
|
||||||
|
'--local-ring-color': key,
|
||||||
|
}"
|
||||||
|
:class="currentTheme === key ? 'ring-2' : 'scale-90'"
|
||||||
|
:title="key"
|
||||||
|
w-8 h-8 rounded-full transition-all
|
||||||
|
ring="$local-ring-color offset-3 offset-$c-bg-base"
|
||||||
|
@click="updateTheme(theme)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -14,6 +14,6 @@ const { account, link = true } = defineProps<{
|
||||||
text-link-rounded
|
text-link-rounded
|
||||||
>
|
>
|
||||||
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all />
|
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all />
|
||||||
<AccountHandle :account="account" />
|
<AccountHandle :account="account" class="zen-none" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -49,7 +49,7 @@ useCommand({
|
||||||
<component
|
<component
|
||||||
:is="as"
|
:is="as"
|
||||||
v-bind="$attrs" ref="el"
|
v-bind="$attrs" ref="el"
|
||||||
w-fit flex gap-1 items-center
|
w-fit flex gap-1 items-center transition-all
|
||||||
rounded group
|
rounded group
|
||||||
:hover=" !disabled ? hover : undefined"
|
:hover=" !disabled ? hover : undefined"
|
||||||
focus:outline-none
|
focus:outline-none
|
||||||
|
|
|
@ -11,6 +11,8 @@ const focusEditor = inject<typeof noop>('focus-editor', noop)
|
||||||
|
|
||||||
const { details, command } = $(props)
|
const { details, command } = $(props)
|
||||||
|
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
@ -31,17 +33,17 @@ const reply = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex justify-between>
|
<div flex justify-between items-center class="status-actions">
|
||||||
<div flex-1>
|
<div flex-1>
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
:content="$t('action.reply')"
|
:content="$t('action.reply')"
|
||||||
:text="status.repliesCount || ''"
|
:text="!getPreferences(userSettings, 'hideReplyCount') && status.repliesCount || ''"
|
||||||
color="text-blue" hover="text-blue" group-hover="bg-blue/10"
|
color="text-blue" hover="text-blue" group-hover="bg-blue/10"
|
||||||
icon="i-ri:chat-1-line"
|
icon="i-ri:chat-1-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="reply"
|
@click="reply"
|
||||||
>
|
>
|
||||||
<template v-if="status.repliesCount" #text>
|
<template v-if="status.repliesCount && !getPreferences(userSettings, 'hideReplyCount')" #text>
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
keypath="action.reply_count"
|
keypath="action.reply_count"
|
||||||
:count="status.repliesCount"
|
:count="status.repliesCount"
|
||||||
|
@ -53,7 +55,7 @@ const reply = () => {
|
||||||
<div flex-1>
|
<div flex-1>
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
:content="$t('action.boost')"
|
:content="$t('action.boost')"
|
||||||
:text="!getWellnessSetting('hideBoostCount') && status.reblogsCount ? status.reblogsCount : ''"
|
:text="!getPreferences(userSettings, 'hideBoostCount') && status.reblogsCount ? status.reblogsCount : ''"
|
||||||
color="text-green" hover="text-green" group-hover="bg-green/10"
|
color="text-green" hover="text-green" group-hover="bg-green/10"
|
||||||
icon="i-ri:repeat-line"
|
icon="i-ri:repeat-line"
|
||||||
active-icon="i-ri:repeat-fill"
|
active-icon="i-ri:repeat-fill"
|
||||||
|
@ -62,7 +64,7 @@ const reply = () => {
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleReblog()"
|
@click="toggleReblog()"
|
||||||
>
|
>
|
||||||
<template v-if="status.reblogsCount && !getWellnessSetting('hideBoostCount')" #text>
|
<template v-if="status.reblogsCount && !getPreferences(userSettings, 'hideBoostCount')" #text>
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
keypath="action.boost_count"
|
keypath="action.boost_count"
|
||||||
:count="status.reblogsCount"
|
:count="status.reblogsCount"
|
||||||
|
@ -74,7 +76,7 @@ const reply = () => {
|
||||||
<div flex-1>
|
<div flex-1>
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
:content="$t('action.favourite')"
|
:content="$t('action.favourite')"
|
||||||
:text="!getWellnessSetting('hideFavoriteCount') && status.favouritesCount ? status.favouritesCount : ''"
|
:text="!getPreferences(userSettings, 'hideFavoriteCount') && status.favouritesCount ? status.favouritesCount : ''"
|
||||||
color="text-rose" hover="text-rose" group-hover="bg-rose/10"
|
color="text-rose" hover="text-rose" group-hover="bg-rose/10"
|
||||||
icon="i-ri:heart-3-line"
|
icon="i-ri:heart-3-line"
|
||||||
active-icon="i-ri:heart-3-fill"
|
active-icon="i-ri:heart-3-fill"
|
||||||
|
@ -83,7 +85,7 @@ const reply = () => {
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleFavourite()"
|
@click="toggleFavourite()"
|
||||||
>
|
>
|
||||||
<template v-if="status.favouritesCount && !getWellnessSetting('hideFavoriteCount')" #text>
|
<template v-if="status.favouritesCount && !getPreferences(userSettings, 'hideFavoriteCount')" #text>
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
keypath="action.favourite_count"
|
keypath="action.favourite_count"
|
||||||
:count="status.favouritesCount"
|
:count="status.favouritesCount"
|
||||||
|
|
|
@ -23,22 +23,11 @@ const clipboard = useClipboard()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
const isAuthor = $computed(() => status.account.id === currentUser.value?.account.id)
|
const isAuthor = $computed(() => status.account.id === currentUser.value?.account.id)
|
||||||
|
|
||||||
const {
|
const { client } = $(useMasto())
|
||||||
toggle: _toggleTranslation,
|
|
||||||
translation,
|
|
||||||
enabled: isTranslationEnabled,
|
|
||||||
} = useTranslation(props.status)
|
|
||||||
|
|
||||||
const toggleTranslation = async () => {
|
|
||||||
isLoading.translation = true
|
|
||||||
await _toggleTranslation()
|
|
||||||
isLoading.translation = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const masto = useMasto()
|
|
||||||
|
|
||||||
const getPermalinkUrl = (status: mastodon.v1.Status) => {
|
const getPermalinkUrl = (status: mastodon.v1.Status) => {
|
||||||
const url = getStatusPermalinkRoute(status)
|
const url = getStatusPermalinkRoute(status)
|
||||||
|
@ -53,6 +42,12 @@ const copyLink = async (status: mastodon.v1.Status) => {
|
||||||
await clipboard.copy(url)
|
await clipboard.copy(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyOriginalLink = async (status: mastodon.v1.Status) => {
|
||||||
|
const url = status.url
|
||||||
|
if (url)
|
||||||
|
await clipboard.copy(url)
|
||||||
|
}
|
||||||
|
|
||||||
const { share, isSupported: isShareSupported } = useShare()
|
const { share, isSupported: isShareSupported } = useShare()
|
||||||
const shareLink = async (status: mastodon.v1.Status) => {
|
const shareLink = async (status: mastodon.v1.Status) => {
|
||||||
const url = getPermalinkUrl(status)
|
const url = getPermalinkUrl(status)
|
||||||
|
@ -62,14 +57,14 @@ const shareLink = async (status: mastodon.v1.Status) => {
|
||||||
|
|
||||||
const deleteStatus = async () => {
|
const deleteStatus = async () => {
|
||||||
if (await openConfirmDialog({
|
if (await openConfirmDialog({
|
||||||
title: t('menu.delete_confirm.title'),
|
title: t('confirm.delete_posts.title'),
|
||||||
confirm: t('menu.delete_confirm.confirm'),
|
confirm: t('confirm.delete_posts.confirm'),
|
||||||
cancel: t('menu.delete_confirm.cancel'),
|
cancel: t('confirm.delete_posts.cancel'),
|
||||||
}) !== 'confirm')
|
}) !== 'confirm')
|
||||||
return
|
return
|
||||||
|
|
||||||
removeCachedStatus(status.id)
|
removeCachedStatus(status.id)
|
||||||
await masto.v1.statuses.remove(status.id)
|
await client.v1.statuses.remove(status.id)
|
||||||
|
|
||||||
if (route.name === 'status')
|
if (route.name === 'status')
|
||||||
router.back()
|
router.back()
|
||||||
|
@ -87,7 +82,7 @@ const deleteAndRedraft = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCachedStatus(status.id)
|
removeCachedStatus(status.id)
|
||||||
await masto.v1.statuses.remove(status.id)
|
await client.v1.statuses.remove(status.id)
|
||||||
await openPublishDialog('dialog', await getDraftFromStatus(status), true)
|
await openPublishDialog('dialog', await getDraftFromStatus(status), true)
|
||||||
|
|
||||||
// Go to the new status, if the page is the old status
|
// Go to the new status, if the page is the old status
|
||||||
|
@ -121,9 +116,9 @@ const showFavoritedAndBoostedBy = () => {
|
||||||
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command">
|
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command">
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
:content="$t('action.more')"
|
:content="$t('action.more')"
|
||||||
color="text-purple"
|
color="text-primary"
|
||||||
hover="text-purple"
|
hover="text-primary"
|
||||||
group-hover="bg-purple/10"
|
group-hover="bg-primary-light"
|
||||||
icon="i-ri:more-line"
|
icon="i-ri:more-line"
|
||||||
my--2
|
my--2
|
||||||
/>
|
/>
|
||||||
|
@ -180,6 +175,13 @@ const showFavoritedAndBoostedBy = () => {
|
||||||
@click="copyLink(status)"
|
@click="copyLink(status)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CommonDropdownItem
|
||||||
|
:text="$t('menu.copy_original_link_to_post')"
|
||||||
|
icon="i-ri:links-fill"
|
||||||
|
:command="command"
|
||||||
|
@click="copyOriginalLink(status)"
|
||||||
|
/>
|
||||||
|
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-if="isShareSupported"
|
v-if="isShareSupported"
|
||||||
:text="$t('menu.share_post')"
|
:text="$t('menu.share_post')"
|
||||||
|
@ -205,15 +207,7 @@ const showFavoritedAndBoostedBy = () => {
|
||||||
/>
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<CommonDropdownItem
|
<template v-if="isHydrated && currentUser">
|
||||||
v-if="isTranslationEnabled && status.language !== languageCode"
|
|
||||||
:text="translation.visible ? $t('menu.show_untranslated') : $t('menu.translate_post')"
|
|
||||||
icon="i-ri:translate"
|
|
||||||
:command="command"
|
|
||||||
@click="toggleTranslation"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="isMastoInitialised && currentUser">
|
|
||||||
<template v-if="isAuthor">
|
<template v-if="isAuthor">
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
:text="status.pinned ? $t('menu.unpin_on_profile') : $t('menu.pin_on_profile')"
|
:text="status.pinned ? $t('menu.unpin_on_profile') : $t('menu.pin_on_profile')"
|
||||||
|
|
|
@ -30,7 +30,7 @@ const aspectRatio = computed(() => {
|
||||||
if (fullSize)
|
if (fullSize)
|
||||||
return rawAspectRatio.value
|
return rawAspectRatio.value
|
||||||
if (rawAspectRatio.value)
|
if (rawAspectRatio.value)
|
||||||
return clamp(rawAspectRatio.value, 0.8, 2.5)
|
return clamp(rawAspectRatio.value, 0.8, 6)
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -65,9 +65,11 @@ const video = ref<HTMLVideoElement | undefined>()
|
||||||
const prefersReducedMotion = usePreferredReducedMotion()
|
const prefersReducedMotion = usePreferredReducedMotion()
|
||||||
const isAudio = $computed(() => attachment.type === 'audio')
|
const isAudio = $computed(() => attachment.type === 'audio')
|
||||||
|
|
||||||
|
const enableAutoplay = usePreferences('enableAutoplay')
|
||||||
|
|
||||||
useIntersectionObserver(video, (entries) => {
|
useIntersectionObserver(video, (entries) => {
|
||||||
const ready = video.value?.dataset.ready === 'true'
|
const ready = video.value?.dataset.ready === 'true'
|
||||||
if (prefersReducedMotion.value === 'reduce') {
|
if (prefersReducedMotion.value === 'reduce' || !enableAutoplay.value) {
|
||||||
if (ready && !video.value?.paused)
|
if (ready && !video.value?.paused)
|
||||||
video.value?.pause()
|
video.value?.pause()
|
||||||
|
|
||||||
|
@ -188,7 +190,7 @@ useIntersectionObserver(video, (entries) => {
|
||||||
{{ $t('status.img_alt.dismiss') }}
|
{{ $t('status.img_alt.dismiss') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p whitespace-pre-wrap>
|
||||||
{{ attachment.description }}
|
{{ attachment.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,13 +3,15 @@ import type { mastodon } from 'masto'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
|
newer,
|
||||||
withAction = true,
|
withAction = true,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
status: mastodon.v1.Status | mastodon.v1.StatusEdit
|
status: mastodon.v1.Status | mastodon.v1.StatusEdit
|
||||||
|
newer?: mastodon.v1.Status
|
||||||
withAction?: boolean
|
withAction?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { translation } = useTranslation(status)
|
const { translation } = useTranslation(status, getLanguageCode())
|
||||||
|
|
||||||
const emojisObject = useEmojisFallback(() => status.emojis)
|
const emojisObject = useEmojisFallback(() => status.emojis)
|
||||||
const vnode = $computed(() => {
|
const vnode = $computed(() => {
|
||||||
|
@ -17,7 +19,11 @@ const vnode = $computed(() => {
|
||||||
return null
|
return null
|
||||||
const vnode = contentToVNode(status.content, {
|
const vnode = contentToVNode(status.content, {
|
||||||
emojis: emojisObject.value,
|
emojis: emojisObject.value,
|
||||||
|
mentions: 'mentions' in status ? status.mentions : undefined,
|
||||||
markdown: true,
|
markdown: true,
|
||||||
|
collapseMentionLink: !!('inReplyToId' in status && status.inReplyToId),
|
||||||
|
status: 'id' in status ? status : undefined,
|
||||||
|
inReplyToStatus: newer,
|
||||||
})
|
})
|
||||||
return vnode
|
return vnode
|
||||||
})
|
})
|
||||||
|
@ -34,7 +40,7 @@ if (vnode?.children) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }">
|
<div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }" relative>
|
||||||
<span
|
<span
|
||||||
v-if="status.content"
|
v-if="status.content"
|
||||||
class="content-rich line-compact" dir="auto"
|
class="content-rich line-compact" dir="auto"
|
||||||
|
@ -45,7 +51,10 @@ if (vnode?.children) {
|
||||||
<div v-else />
|
<div v-else />
|
||||||
<template v-if="translation.visible">
|
<template v-if="translation.visible">
|
||||||
<div my2 h-px border="b base" bg-base />
|
<div my2 h-px border="b base" bg-base />
|
||||||
<ContentRich class="line-compact" :content="translation.text" :emojis="status.emojis" />
|
<ContentRich v-if="translation.success" class="line-compact" :content="translation.text" :emojis="status.emojis" />
|
||||||
|
<div v-else text-red-4>
|
||||||
|
Error: {{ translation.error }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -23,6 +23,8 @@ const props = withDefaults(
|
||||||
{ actions: true },
|
{ actions: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
const status = $computed(() => {
|
const status = $computed(() => {
|
||||||
if (props.status.reblog && !props.status.content)
|
if (props.status.reblog && !props.status.content)
|
||||||
return props.status.reblog
|
return props.status.reblog
|
||||||
|
@ -65,13 +67,6 @@ const createdAt = useFormattedDateTime(status.createdAt)
|
||||||
const timeAgoOptions = useTimeAgoOptions(true)
|
const timeAgoOptions = useTimeAgoOptions(true)
|
||||||
const timeago = useTimeAgo(() => status.createdAt, timeAgoOptions)
|
const timeago = useTimeAgo(() => status.createdAt, timeAgoOptions)
|
||||||
|
|
||||||
// Content Filter logic
|
|
||||||
const filterResult = $computed(() => status.filtered?.length ? status.filtered[0] : null)
|
|
||||||
const filter = $computed(() => filterResult?.filter)
|
|
||||||
|
|
||||||
const filterPhrase = $computed(() => filter?.title)
|
|
||||||
const isFiltered = $computed(() => filterPhrase && (props.context ? filter?.context.includes(props.context) : false))
|
|
||||||
|
|
||||||
const isSelfReply = $computed(() => status.inReplyToAccountId === status.account.id)
|
const isSelfReply = $computed(() => status.inReplyToAccountId === status.account.id)
|
||||||
const collapseRebloggedBy = $computed(() => rebloggedBy?.id === status.account.id)
|
const collapseRebloggedBy = $computed(() => rebloggedBy?.id === status.account.id)
|
||||||
const isDM = $computed(() => status.visibility === 'direct')
|
const isDM = $computed(() => status.visibility === 'direct')
|
||||||
|
@ -82,7 +77,6 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="filter?.filterAction !== 'hide'"
|
|
||||||
:id="`status-${status.id}`"
|
:id="`status-${status.id}`"
|
||||||
ref="el"
|
ref="el"
|
||||||
relative flex="~ col gap1" p="l-3 r-4 b-2"
|
relative flex="~ col gap1" p="l-3 r-4 b-2"
|
||||||
|
@ -101,12 +95,12 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
|
||||||
<template v-if="status.inReplyToAccountId">
|
<template v-if="status.inReplyToAccountId">
|
||||||
<StatusReplyingTo
|
<StatusReplyingTo
|
||||||
v-if="showReplyTo"
|
v-if="showReplyTo"
|
||||||
ml-6 pt-1 pl-5
|
ml-20px pt-1 pl-5
|
||||||
:status="status"
|
:status="status"
|
||||||
:is-self-reply="isSelfReply"
|
:is-self-reply="isSelfReply"
|
||||||
:class="faded ? 'text-secondary-light' : ''"
|
:class="faded ? 'text-secondary-light' : ''"
|
||||||
/>
|
/>
|
||||||
<div flex="~ col gap-1" items-center pos="absolute top-0 left-0" w="20.5" z--1>
|
<div flex="~ col gap-1" items-center pos="absolute top-0 left-0" w="77px" z--1>
|
||||||
<template v-if="showReplyTo">
|
<template v-if="showReplyTo">
|
||||||
<div w="1px" h="0.5" border="x base" mt-3 />
|
<div w="1px" h="0.5" border="x base" mt-3 />
|
||||||
<div w="1px" h="0.5" border="x base" />
|
<div w="1px" h="0.5" border="x base" />
|
||||||
|
@ -124,7 +118,7 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
|
||||||
p="t-1 b-0.5 x-1px"
|
p="t-1 b-0.5 x-1px"
|
||||||
relative text-secondary ws-nowrap
|
relative text-secondary ws-nowrap
|
||||||
>
|
>
|
||||||
<div i-ri:repeat-fill me-46px text-green w-16px h-16px />
|
<div i-ri:repeat-fill me-46px text-green w-16px h-16px class="status-boosted" />
|
||||||
<div absolute top-1 ms-24px w-32px h-32px rounded-full>
|
<div absolute top-1 ms-24px w-32px h-32px rounded-full>
|
||||||
<AccountHoverWrapper :account="rebloggedBy">
|
<AccountHoverWrapper :account="rebloggedBy">
|
||||||
<NuxtLink :to="getAccountRoute(rebloggedBy)">
|
<NuxtLink :to="getAccountRoute(rebloggedBy)">
|
||||||
|
@ -162,7 +156,7 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
|
||||||
<StatusAccountDetails :account="status.account" />
|
<StatusAccountDetails :account="status.account" />
|
||||||
</AccountHoverWrapper>
|
</AccountHoverWrapper>
|
||||||
<div flex-auto />
|
<div flex-auto />
|
||||||
<div v-show="!userSettings.zenMode" text-sm text-secondary flex="~ row nowrap" hover:underline>
|
<div v-show="!userSettings.zenMode" text-sm text-secondary flex="~ row nowrap" hover:underline whitespace-nowrap>
|
||||||
<AccountBotIndicator v-if="status.account.bot" me-2 />
|
<AccountBotIndicator v-if="status.account.bot" me-2 />
|
||||||
<div flex="~ gap1" items-center>
|
<div flex="~ gap1" items-center>
|
||||||
<StatusVisibilityIndicator v-if="status.visibility !== 'public'" :status="status" />
|
<StatusVisibilityIndicator v-if="status.visibility !== 'public'" :status="status" />
|
||||||
|
@ -182,14 +176,9 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<StatusContent :status="status" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
|
<StatusContent :status="status" :newer="newer" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
|
||||||
<StatusActions v-if="actions !== false" v-show="!userSettings.zenMode" :status="status" />
|
<StatusActions v-if="actions !== false" v-show="!userSettings.zenMode" :status="status" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="isFiltered" gap-2 p-4 :class="{ 'border-t border-base': newer }">
|
|
||||||
<p text-center text-secondary text-sm>
|
|
||||||
{{ filterPhrase && `${$t('status.filter_removed_phrase')}: ${filterPhrase}` }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { mastodon } from 'masto'
|
||||||
|
|
||||||
const { status, context } = defineProps<{
|
const { status, context } = defineProps<{
|
||||||
status: mastodon.v1.Status
|
status: mastodon.v1.Status
|
||||||
|
newer?: mastodon.v1.Status
|
||||||
context?: mastodon.v2.FilterContext | 'details'
|
context?: mastodon.v2.FilterContext | 'details'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
@ -25,15 +26,16 @@ const isFiltered = $computed(() => filterPhrase && (context && context !== 'deta
|
||||||
'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
|
'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
|
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
|
||||||
<StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered">
|
<StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered" :is-d-m="isDM">
|
||||||
<template v-if="filterPhrase" #spoiler>
|
<template v-if="filterPhrase" #spoiler>
|
||||||
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
|
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="status.spoilerText" #spoiler>
|
<template v-else-if="status.spoilerText" #spoiler>
|
||||||
<p>{{ status.spoilerText }}</p>
|
<p>{{ status.spoilerText }}</p>
|
||||||
</template>
|
</template>
|
||||||
<StatusBody v-if="!status.sensitive || status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
|
<StatusBody v-if="!status.sensitive || status.spoilerText" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
|
||||||
|
<StatusTranslation :status="status" />
|
||||||
<StatusPoll v-if="status.poll" :status="status" />
|
<StatusPoll v-if="status.poll" :status="status" />
|
||||||
<StatusMedia
|
<StatusMedia
|
||||||
v-if="status.mediaAttachments?.length"
|
v-if="status.mediaAttachments?.length"
|
||||||
|
|
|
@ -3,12 +3,15 @@ import type { mastodon } from 'masto'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
status: mastodon.v1.Status
|
status: mastodon.v1.Status
|
||||||
|
newer?: mastodon.v1.Status
|
||||||
command?: boolean
|
command?: boolean
|
||||||
actions?: boolean
|
actions?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
actions: true,
|
actions: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
const status = $computed(() => {
|
const status = $computed(() => {
|
||||||
if (props.status.reblog && props.status.reblog)
|
if (props.status.reblog && props.status.reblog)
|
||||||
return props.status.reblog
|
return props.status.reblog
|
||||||
|
@ -20,7 +23,7 @@ const createdAt = useFormattedDateTime(status.createdAt)
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
useHeadFixed({
|
useHeadFixed({
|
||||||
title: () => `${status.account.displayName || status.account.acct} ${t('common.in')} ${t('app_name')}: "${removeHTMLTags(status.content) || ''}"`,
|
title: () => `${getDisplayName(status.account)} ${t('common.in')} ${t('app_name')}: "${removeHTMLTags(status.content) || ''}"`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isDM = $computed(() => status.visibility === 'direct')
|
const isDM = $computed(() => status.visibility === 'direct')
|
||||||
|
@ -34,7 +37,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
<AccountInfo :account="status.account" />
|
<AccountInfo :account="status.account" />
|
||||||
</AccountHoverWrapper>
|
</AccountHoverWrapper>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<StatusContent :status="status" context="details" />
|
<StatusContent :status="status" :newer="newer" context="details" />
|
||||||
<div flex="~ gap-1" items-center text-secondary text-sm>
|
<div flex="~ gap-1" items-center text-secondary text-sm>
|
||||||
<div flex>
|
<div flex>
|
||||||
<div>{{ createdAt }}</div>
|
<div>{{ createdAt }}</div>
|
||||||
|
@ -59,7 +62,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
{{ status.application?.name }}
|
{{ status.application?.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div border="t base" pt-2>
|
<div border="t base" py-2>
|
||||||
<StatusActions v-if="actions" :status="status" details :command="command" />
|
<StatusActions v-if="actions" :status="status" details :command="command" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,8 +3,10 @@ import { favouritedBoostedByStatusId } from '~/composables/dialog'
|
||||||
|
|
||||||
const type = ref<'favourited-by' | 'boosted-by'>('favourited-by')
|
const type = ref<'favourited-by' | 'boosted-by'>('favourited-by')
|
||||||
|
|
||||||
|
const { client } = $(useMasto())
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
return useMasto().v1.statuses[type.value === 'favourited-by' ? 'listFavouritedBy' : 'listRebloggedBy'](favouritedBoostedByStatusId.value!)
|
return client.v1.statuses[type.value === 'favourited-by' ? 'listFavouritedBy' : 'listRebloggedBy'](favouritedBoostedByStatusId.value!)
|
||||||
}
|
}
|
||||||
|
|
||||||
const paginator = $computed(() => load())
|
const paginator = $computed(() => load())
|
||||||
|
|
|
@ -15,7 +15,8 @@ const expiredTimeAgo = useTimeAgo(poll.expiresAt!, timeAgoOptions)
|
||||||
const expiredTimeFormatted = useFormattedDateTime(poll.expiresAt!)
|
const expiredTimeFormatted = useFormattedDateTime(poll.expiresAt!)
|
||||||
const { formatPercentage } = useHumanReadableNumber()
|
const { formatPercentage } = useHumanReadableNumber()
|
||||||
|
|
||||||
const masto = useMasto()
|
const { client } = $(useMasto())
|
||||||
|
|
||||||
async function vote(e: Event) {
|
async function vote(e: Event) {
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const choices = formData.getAll('choices') as string[]
|
const choices = formData.getAll('choices') as string[]
|
||||||
|
@ -27,28 +28,37 @@ async function vote(e: Event) {
|
||||||
}
|
}
|
||||||
poll.voted = true
|
poll.voted = true
|
||||||
poll.votesCount++
|
poll.votesCount++
|
||||||
|
|
||||||
|
if (!poll.votersCount && poll.votesCount)
|
||||||
|
poll.votesCount = poll.votesCount + 1
|
||||||
|
else
|
||||||
poll.votersCount = (poll.votersCount || 0) + 1
|
poll.votersCount = (poll.votersCount || 0) + 1
|
||||||
|
|
||||||
cacheStatus({ ...status, poll }, undefined, true)
|
cacheStatus({ ...status, poll }, undefined, true)
|
||||||
|
|
||||||
await masto.v1.polls.vote(poll.id, { choices })
|
await client.v1.polls.vote(poll.id, { choices })
|
||||||
}
|
}
|
||||||
|
|
||||||
const votersCount = $computed(() => poll.votersCount ?? 0)
|
const votersCount = $computed(() => poll.votersCount ?? poll.votesCount ?? 0)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex flex-col w-full items-stretch gap-3 dir="auto">
|
<div flex flex-col w-full items-stretch gap-2 py3 dir="auto" class="poll-wrapper">
|
||||||
<form v-if="!poll.voted && !poll.expired" flex flex-col gap-4 accent-primary @click.stop="noop" @submit.prevent="vote">
|
<form v-if="!poll.voted && !poll.expired" flex="~ col gap3" accent-primary @click.stop="noop" @submit.prevent="vote">
|
||||||
<label v-for="(option, index) of poll.options" :key="index" flex items-center gap-2 px-2>
|
<label v-for="(option, index) of poll.options" :key="index" flex="~ gap2" items-center>
|
||||||
<input name="choices" :value="index" :type="poll.multiple ? 'checkbox' : 'radio'">
|
<input name="choices" :value="index" :type="poll.multiple ? 'checkbox' : 'radio'" cursor-pointer>
|
||||||
{{ option.title }}
|
{{ option.title }}
|
||||||
</label>
|
</label>
|
||||||
<button btn-solid>
|
<button btn-solid mt-1>
|
||||||
{{ $t('action.vote') }}
|
{{ $t('action.vote') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="(option, index) of poll.options" :key="index" py-1 relative :style="{ '--bar-width': toPercentage((option.votesCount || 0) / poll.votesCount) }">
|
<div
|
||||||
|
v-for="(option, index) of poll.options"
|
||||||
|
:key="index" py-1 relative
|
||||||
|
:style="{ '--bar-width': toPercentage((option.votesCount || 0) / poll.votesCount) }"
|
||||||
|
>
|
||||||
<div flex justify-between pb-2 w-full>
|
<div flex justify-between pb-2 w-full>
|
||||||
<span inline-flex align-items>
|
<span inline-flex align-items>
|
||||||
{{ option.title }}
|
{{ option.title }}
|
||||||
|
@ -61,7 +71,7 @@ const votersCount = $computed(() => poll.votersCount ?? 0)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div text-sm flex="~ inline" gap-x-1>
|
<div text-sm flex="~ inline" gap-x-1 text-secondary>
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
keypath="status.poll.count"
|
keypath="status.poll.count"
|
||||||
:count="poll.votesCount"
|
:count="poll.votesCount"
|
||||||
|
|
|
@ -9,74 +9,13 @@ const props = defineProps<{
|
||||||
root?: boolean
|
root?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// mastodon's default max og image width
|
|
||||||
const ogImageWidth = 400
|
|
||||||
|
|
||||||
const alt = $computed(() => `${props.card.title} - ${props.card.title}`)
|
|
||||||
const isSquare = $computed(() => (
|
|
||||||
props.smallPictureOnly
|
|
||||||
|| props.card.width === props.card.height
|
|
||||||
|| Number(props.card.width || 0) < ogImageWidth
|
|
||||||
|| Number(props.card.height || 0) < ogImageWidth / 2
|
|
||||||
))
|
|
||||||
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname)
|
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname)
|
||||||
|
|
||||||
const gitHubCards = $(useFeatureFlag('experimentalGitHubCards'))
|
const gitHubCards = $(usePreferences('experimentalGitHubCards'))
|
||||||
|
|
||||||
// TODO: handle card.type: 'photo' | 'video' | 'rich';
|
|
||||||
const cardTypeIconMap: Record<mastodon.v1.PreviewCardType, string> = {
|
|
||||||
link: 'i-ri:profile-line',
|
|
||||||
photo: 'i-ri:image-line',
|
|
||||||
video: 'i-ri:play-line',
|
|
||||||
rich: 'i-ri:profile-line',
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<StatusPreviewGitHub v-if="gitHubCards && providerName === 'GitHub'" :card="card" />
|
<LazyStatusPreviewGitHub v-if="gitHubCards && providerName === 'GitHub'" :card="card" />
|
||||||
<NuxtLink
|
<LazyStatusPreviewStackBlitz v-else-if="gitHubCards && providerName === 'stackblitz.com'" :card="card" :small-picture-only="smallPictureOnly" :root="root" />
|
||||||
v-else
|
<StatusPreviewCardNormal v-else :card="card" :small-picture-only="smallPictureOnly" :root="root" />
|
||||||
block
|
|
||||||
of-hidden
|
|
||||||
:to="card.url"
|
|
||||||
bg-card
|
|
||||||
hover:bg-active
|
|
||||||
:class="{
|
|
||||||
'flex': isSquare,
|
|
||||||
'p-4': root,
|
|
||||||
'rounded-lg': !root,
|
|
||||||
}"
|
|
||||||
target="_blank"
|
|
||||||
external
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="card.image"
|
|
||||||
flex flex-col
|
|
||||||
display-block of-hidden
|
|
||||||
:class="{
|
|
||||||
'sm:(min-w-32 w-32 h-32) min-w-22 w-22 h-22': isSquare,
|
|
||||||
'w-full aspect-[1.91]': !isSquare,
|
|
||||||
'rounded-lg': root,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<CommonBlurhash
|
|
||||||
:blurhash="card.blurhash"
|
|
||||||
:src="card.image"
|
|
||||||
:width="card.width"
|
|
||||||
:height="card.height"
|
|
||||||
:alt="alt"
|
|
||||||
w-full h-full object-cover
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
min-w-22 w-22 h-22 sm="min-w-32 w-32 h-32" bg="slate-500/10" flex justify-center items-center
|
|
||||||
:class="[
|
|
||||||
root ? 'rounded-lg' : '',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div :class="cardTypeIconMap[card.type]" w="30%" h="30%" text-secondary />
|
|
||||||
</div>
|
|
||||||
<StatusPreviewCardInfo :root="root" :card="card" :provider="providerName" />
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -12,12 +12,12 @@ defineProps<{
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
p4 max-h-2xl
|
max-h-2xl
|
||||||
flex flex-col
|
flex flex-col
|
||||||
|
my-auto
|
||||||
:class="[
|
:class="[
|
||||||
root ? 'flex-gap-1' : 'justify-center sm:justify-start',
|
root ? 'flex-gap-1' : 'justify-center sm:justify-start',
|
||||||
]"
|
]"
|
||||||
my-auto
|
|
||||||
>
|
>
|
||||||
<p text-secondary ws-pre-wrap break-all line-clamp-1>
|
<p text-secondary ws-pre-wrap break-all line-clamp-1>
|
||||||
{{ provider }}
|
{{ provider }}
|
||||||
|
|
78
components/status/StatusPreviewCardNormal.vue
Normal file
78
components/status/StatusPreviewCardNormal.vue
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
card: mastodon.v1.PreviewCard
|
||||||
|
/** For the preview image, only the small image mode is displayed */
|
||||||
|
smallPictureOnly?: boolean
|
||||||
|
/** When it is root card in the list, not appear as a child card */
|
||||||
|
root?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// mastodon's default max og image width
|
||||||
|
const ogImageWidth = 400
|
||||||
|
|
||||||
|
const alt = $computed(() => `${props.card.title} - ${props.card.title}`)
|
||||||
|
const isSquare = $computed(() => (
|
||||||
|
props.smallPictureOnly
|
||||||
|
|| props.card.width === props.card.height
|
||||||
|
|| Number(props.card.width || 0) < ogImageWidth
|
||||||
|
|| Number(props.card.height || 0) < ogImageWidth / 2
|
||||||
|
))
|
||||||
|
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname)
|
||||||
|
|
||||||
|
// TODO: handle card.type: 'photo' | 'video' | 'rich';
|
||||||
|
const cardTypeIconMap: Record<mastodon.v1.PreviewCardType, string> = {
|
||||||
|
link: 'i-ri:profile-line',
|
||||||
|
photo: 'i-ri:image-line',
|
||||||
|
video: 'i-ri:play-line',
|
||||||
|
rich: 'i-ri:profile-line',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
block
|
||||||
|
of-hidden
|
||||||
|
:to="card.url"
|
||||||
|
bg-card
|
||||||
|
hover:bg-active
|
||||||
|
:class="{
|
||||||
|
'flex': isSquare,
|
||||||
|
'p-4': root,
|
||||||
|
'rounded-lg': !root,
|
||||||
|
}"
|
||||||
|
target="_blank"
|
||||||
|
external
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="card.image"
|
||||||
|
flex flex-col
|
||||||
|
display-block of-hidden
|
||||||
|
:class="{
|
||||||
|
'sm:(min-w-32 w-32 h-32) min-w-24 w-24 h-24': isSquare,
|
||||||
|
'w-full aspect-[1.91]': !isSquare,
|
||||||
|
'rounded-lg': root,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<CommonBlurhash
|
||||||
|
:blurhash="card.blurhash"
|
||||||
|
:src="card.image"
|
||||||
|
:width="card.width"
|
||||||
|
:height="card.height"
|
||||||
|
:alt="alt"
|
||||||
|
w-full h-full object-cover
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
min-w-24 w-24 h-24 sm="min-w-32 w-32 h-32" bg="slate-500/10" flex justify-center items-center
|
||||||
|
:class="[
|
||||||
|
root ? 'rounded-lg' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div :class="cardTypeIconMap[card.type]" w="30%" h="30%" text-secondary />
|
||||||
|
</div>
|
||||||
|
<StatusPreviewCardInfo :p="isSquare ? 'x-4' : '4'" :root="root" :card="card" :provider="providerName" />
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
|
@ -114,9 +114,9 @@ const meta = $computed(() => {
|
||||||
<span text-secondary leading-tight>{{ meta.details }}</span>
|
<span text-secondary leading-tight>{{ meta.details }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div shrink-0 w-18 sm:w-30>
|
||||||
<NuxtLink :href="meta.titleUrl" target="_blank" external>
|
<NuxtLink :href="meta.titleUrl" target="_blank" external>
|
||||||
<img w-30 aspect-square width="20" height="20" rounded-2 :src="meta.avatar">
|
<img w-full aspect-square width="112" height="112" rounded-2 :src="meta.avatar">
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
96
components/status/StatusPreviewStackBlitz.vue
Normal file
96
components/status/StatusPreviewStackBlitz.vue
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
card: mastodon.v1.PreviewCard
|
||||||
|
/** For the preview image, only the small image mode is displayed */
|
||||||
|
smallPictureOnly?: boolean
|
||||||
|
/** When it is root card in the list, not appear as a child card */
|
||||||
|
root?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface Meta {
|
||||||
|
code?: string
|
||||||
|
file?: string
|
||||||
|
lines?: string
|
||||||
|
project?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protect against long code snippets
|
||||||
|
const maxLines = 20
|
||||||
|
|
||||||
|
const meta = $computed(() => {
|
||||||
|
const { description } = props.card
|
||||||
|
const meta = description.match(/.+\n\nCode Snippet from (.+), lines ([\w-]+)\n\n(.+)/s)
|
||||||
|
const file = meta?.[1]
|
||||||
|
const lines = meta?.[2].replaceAll('N', '')
|
||||||
|
const code = meta?.[3].split('\n').slice(0, maxLines).join('\n')
|
||||||
|
const project = props.card.title?.replace(' - StackBlitz', '')
|
||||||
|
const info = $ref<Meta>({
|
||||||
|
file,
|
||||||
|
lines,
|
||||||
|
code,
|
||||||
|
project,
|
||||||
|
})
|
||||||
|
return info
|
||||||
|
})
|
||||||
|
|
||||||
|
const vnodeCode = $computed(() => {
|
||||||
|
if (!meta.code)
|
||||||
|
return null
|
||||||
|
const vnode = contentToVNode(`<p>\`\`\`${meta.file?.split('.')?.[1] ?? ''}\n${meta.code}\n\`\`\`\</p>`, {
|
||||||
|
markdown: true,
|
||||||
|
})
|
||||||
|
return vnode
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="meta.code"
|
||||||
|
flex flex-col gap-1
|
||||||
|
display-block of-hidden
|
||||||
|
w-full
|
||||||
|
rounded-lg
|
||||||
|
overflow-hidden
|
||||||
|
pb-2
|
||||||
|
>
|
||||||
|
<div whitespace-pre-wrap break-words>
|
||||||
|
<span v-if="vnodeCode" class="content-rich line-compact" dir="auto">
|
||||||
|
<component :is="vnodeCode" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
flex
|
||||||
|
justify-between
|
||||||
|
display-block of-hidden
|
||||||
|
bg-card
|
||||||
|
w-full
|
||||||
|
p-3
|
||||||
|
pb-4
|
||||||
|
>
|
||||||
|
<div flex flex-col>
|
||||||
|
<p flex gap-1>
|
||||||
|
<span>{{ $t('custom_cards.stackblitz.snippet_from', [meta.file]) }}</span><span text-secondary>{{ `- ${$t('custom_cards.stackblitz.lines', [meta.lines])}` }}</span>
|
||||||
|
</p>
|
||||||
|
<div flex font-bold gap-2>
|
||||||
|
<span text-primary>{{ meta.project }}</span><span flex text-secondary><span flex items-center><svg h-5 width="22.27" height="32" viewBox="0 0 256 368"><path fill="currentColor" d="M109.586 217.013H0L200.34 0l-53.926 150.233H256L55.645 367.246l53.927-150.233z" /></svg></span><span>StackBlitz</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink external target="_blank" btn-solid pt-0 pb-1 px-2 h-fit :to="card.url">
|
||||||
|
{{ $t('custom_cards.stackblitz.open') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusPreviewCardNormal v-else :card="card" :small-picture-only="smallPictureOnly" :root="root" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content-rich p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.code-block {
|
||||||
|
margin-top: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{ enabled?: boolean; filter?: boolean }>()
|
const props = defineProps<{ enabled?: boolean; filter?: boolean; isDM?: boolean }>()
|
||||||
|
|
||||||
const showContent = ref(!props.enabled)
|
const showContent = ref(!props.enabled)
|
||||||
const toggleContent = useToggle(showContent)
|
const toggleContent = useToggle(showContent)
|
||||||
|
@ -14,8 +14,8 @@ watchEffect(() => {
|
||||||
<div class="content-rich" p="x-4 b-2.5" text-center text-secondary w-full border="~ base" border-0 border-b-dotted border-b-3 mt-2>
|
<div class="content-rich" p="x-4 b-2.5" text-center text-secondary w-full border="~ base" border-0 border-b-dotted border-b-3 mt-2>
|
||||||
<slot name="spoiler" />
|
<slot name="spoiler" />
|
||||||
</div>
|
</div>
|
||||||
<div flex="~ gap-1 center" w-full mt="-4.5">
|
<div flex="~ gap-1 center" w-full :mb="isDM && !showContent ? '4' : ''" mt="-4.5">
|
||||||
<button btn-text px-2 py-1 bg-base flex="~ center gap-2" :class="showContent ? '' : 'filter-saturate-0 hover:filter-saturate-100'" @click="toggleContent()">
|
<button btn-text px-2 py-1 :bg="isDM ? 'transparent' : 'base'" flex="~ center gap-2" :class="showContent ? '' : 'filter-saturate-0 hover:filter-saturate-100'" @click="toggleContent()">
|
||||||
<div v-if="showContent" i-ri:eye-line />
|
<div v-if="showContent" i-ri:eye-line />
|
||||||
<div v-else i-ri:eye-close-line />
|
<div v-else i-ri:eye-close-line />
|
||||||
{{ showContent ? $t('status.spoiler_show_less') : $t(filter ? 'status.filter_show_anyway' : 'status.spoiler_show_more') }}
|
{{ showContent ? $t('status.spoiler_show_less') : $t(filter ? 'status.filter_show_anyway' : 'status.spoiler_show_more') }}
|
||||||
|
|
44
components/status/StatusTranslation.vue
Normal file
44
components/status/StatusTranslation.vue
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
const { status } = defineProps<{
|
||||||
|
status: mastodon.v1.Status
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
toggle: _toggleTranslation,
|
||||||
|
translation,
|
||||||
|
enabled: isTranslationEnabled,
|
||||||
|
} = useTranslation(status, getLanguageCode())
|
||||||
|
const preferenceHideTranslation = usePreferences('hideTranslation')
|
||||||
|
|
||||||
|
const showButton = computed(() => !preferenceHideTranslation.value && isTranslationEnabled && status.language !== getLanguageCode())
|
||||||
|
|
||||||
|
let translating = $ref(false)
|
||||||
|
const toggleTranslation = async () => {
|
||||||
|
translating = true
|
||||||
|
try {
|
||||||
|
await _toggleTranslation()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
translating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
v-if="showButton" p-0 flex="~ center" gap-2 text-sm
|
||||||
|
:disabled="translating" disabled-bg-transparent btn-text class="disabled-text-$c-text-btn-disabled-deeper" @click="toggleTranslation"
|
||||||
|
>
|
||||||
|
<span v-if="translating" block animate-spin preserve-3d>
|
||||||
|
<span block i-ri:loader-2-fill />
|
||||||
|
</span>
|
||||||
|
<div v-else i-ri:translate />
|
||||||
|
{{ translation.visible ? $t('menu.show_untranslated') : $t('menu.translate_post') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
|
@ -6,7 +6,7 @@ const { status } = defineProps<{
|
||||||
status: mastodon.v1.Status
|
status: mastodon.v1.Status
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const paginator = useMasto().v1.statuses.listHistory(status.id)
|
const paginator = useMastoClient().v1.statuses.listHistory(status.id)
|
||||||
|
|
||||||
const showHistory = (edit: mastodon.v1.StatusEdit) => {
|
const showHistory = (edit: mastodon.v1.StatusEdit) => {
|
||||||
openEditHistoryDialog(edit)
|
openEditHistoryDialog(edit)
|
||||||
|
|
|
@ -9,16 +9,28 @@ const emit = defineEmits<{
|
||||||
(event: 'change'): void
|
(event: 'change'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const masto = useMasto()
|
const { client } = $(useMasto())
|
||||||
|
|
||||||
const toggleFollowTag = async () => {
|
const toggleFollowTag = async () => {
|
||||||
if (tag.following)
|
// We save the state so be can do an optimistic UI update, but fallback to the previous state if the API call fails
|
||||||
await masto.v1.tags.unfollow(tag.name)
|
const previousFollowingState = tag.following
|
||||||
|
|
||||||
|
// eslint-disable-next-line vue/no-mutating-props
|
||||||
|
tag.following = !tag.following
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (previousFollowingState)
|
||||||
|
await client.v1.tags.unfollow(tag.name)
|
||||||
else
|
else
|
||||||
await masto.v1.tags.follow(tag.name)
|
await client.v1.tags.follow(tag.name)
|
||||||
|
|
||||||
emit('change')
|
emit('change')
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
// eslint-disable-next-line vue/no-mutating-props
|
||||||
|
tag.following = previousFollowingState
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue