├── .dockerignore ├── .env.example ├── .env.mock ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── renovate.json5 └── workflows │ ├── ci.yml │ ├── docker.yml │ ├── release.yml │ └── semantic-pull-request.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .stackblitz └── codeflow.json ├── .stackblitzrc ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── app.vue ├── components │ ├── account │ │ ├── AccountAvatar.vue │ │ ├── AccountBigAvatar.vue │ │ ├── AccountBigCard.vue │ │ ├── AccountBigCardSkeleton.vue │ │ ├── AccountBotIndicator.vue │ │ ├── AccountCard.vue │ │ ├── AccountDisplayName.vue │ │ ├── AccountFollowButton.vue │ │ ├── AccountFollowRequestButton.vue │ │ ├── AccountHandle.vue │ │ ├── AccountHeader.vue │ │ ├── AccountHoverCard.vue │ │ ├── AccountHoverWrapper.vue │ │ ├── AccountInfo.vue │ │ ├── AccountInlineInfo.vue │ │ ├── AccountLockIndicator.vue │ │ ├── AccountMoreButton.vue │ │ ├── AccountMoved.vue │ │ ├── AccountPaginator.vue │ │ ├── AccountPostsFollowers.vue │ │ ├── AccountRolesIndicator.vue │ │ ├── AccountTabs.vue │ │ └── TagHoverWrapper.vue │ ├── aria │ │ ├── AriaAnnouncer.vue │ │ ├── AriaLog.vue │ │ └── AriaStatus.vue │ ├── command │ │ ├── CommandItem.vue │ │ ├── CommandKey.vue │ │ └── CommandPanel.vue │ ├── common │ │ ├── AnimateNumber.vue │ │ ├── CommonAlert.vue │ │ ├── CommonBlurhash.vue │ │ ├── CommonCheckbox.vue │ │ ├── CommonCropImage.vue │ │ ├── CommonErrorMessage.vue │ │ ├── CommonInputImage.vue │ │ ├── CommonMask.vue │ │ ├── CommonNotFound.vue │ │ ├── CommonPaginator.vue │ │ ├── CommonPreviewPrompt.vue │ │ ├── CommonRadio.vue │ │ ├── CommonRouteTabs.vue │ │ ├── CommonScrollIntoView.vue │ │ ├── CommonTabs.vue │ │ ├── CommonTooltip.vue │ │ ├── CommonTrending.vue │ │ ├── CommonTrendingCharts.vue │ │ ├── LocalizedNumber.vue │ │ ├── OfflineChecker.vue │ │ └── dropdown │ │ │ ├── Dropdown.vue │ │ │ └── DropdownItem.vue │ ├── content │ │ ├── ContentCode.vue │ │ ├── ContentMentionGroup.vue │ │ └── ContentRich.setup.ts │ ├── conversation │ │ ├── ConversationCard.vue │ │ └── ConversationPaginator.vue │ ├── emoji │ │ └── Emoji.vue │ ├── help │ │ └── HelpPreview.vue │ ├── list │ │ ├── Account.vue │ │ ├── AccountSearchResult.vue │ │ ├── ListEntry.vue │ │ └── Lists.vue │ ├── magickeys │ │ └── MagickeysKeyboardShortcuts.vue │ ├── main │ │ └── MainContent.vue │ ├── modal │ │ ├── DurationPicker.vue │ │ ├── ModalConfirm.vue │ │ ├── ModalContainer.vue │ │ ├── ModalDialog.vue │ │ ├── ModalError.vue │ │ ├── ModalMediaPreview.vue │ │ └── ModalMediaPreviewCarousel.vue │ ├── nav │ │ ├── NavBottom.vue │ │ ├── NavBottomMoreMenu.vue │ │ ├── NavFooter.vue │ │ ├── NavLogo.vue │ │ ├── NavSide.vue │ │ ├── NavSideItem.vue │ │ ├── NavTitle.vue │ │ ├── NavUser.vue │ │ ├── NavUserSkeleton.vue │ │ └── button │ │ │ ├── Bookmark.vue │ │ │ ├── Compose.vue │ │ │ ├── Explore.vue │ │ │ ├── Favorite.vue │ │ │ ├── Federated.vue │ │ │ ├── Hashtag.vue │ │ │ ├── Home.vue │ │ │ ├── List.vue │ │ │ ├── Local.vue │ │ │ ├── Mention.vue │ │ │ ├── MoreMenu.vue │ │ │ ├── Notification.vue │ │ │ └── Search.vue │ ├── notification │ │ ├── NotificationCard.vue │ │ ├── NotificationEnablePushNotification.client.vue │ │ ├── NotificationGroupedFollow.vue │ │ ├── NotificationGroupedLikes.vue │ │ ├── NotificationPaginator.vue │ │ ├── NotificationPreferences.client.vue │ │ └── NotificationSubscribePushNotificationError.vue │ ├── publish │ │ ├── PublishAttachment.vue │ │ ├── PublishCharacterCounter.vue │ │ ├── PublishEditorTools.vue │ │ ├── PublishEmojiPicker.client.vue │ │ ├── PublishLanguagePicker.vue │ │ ├── PublishThreadTools.vue │ │ ├── PublishVisibilityPicker.vue │ │ ├── PublishWidget.vue │ │ ├── PublishWidgetFull.client.vue │ │ └── PublishWidgetList.vue │ ├── pwa │ │ ├── PwaBadge.client.vue │ │ ├── PwaInstallPrompt.client.vue │ │ └── PwaPrompt.client.vue │ ├── report │ │ └── ReportModal.vue │ ├── search │ │ ├── SearchAccountInfo.vue │ │ ├── SearchEmojiInfo.vue │ │ ├── SearchHashtagInfo.vue │ │ ├── SearchResult.vue │ │ ├── SearchResultSkeleton.vue │ │ └── SearchWidget.vue │ ├── settings │ │ ├── SettingsBottomNav.vue │ │ ├── SettingsColorMode.vue │ │ ├── SettingsFontSize.vue │ │ ├── SettingsItem.vue │ │ ├── SettingsLanguage.vue │ │ ├── SettingsProfileMetadata.vue │ │ ├── SettingsSponsorsList.vue │ │ ├── SettingsThemeColors.vue │ │ ├── SettingsToggleItem.vue │ │ └── SettingsTranslations.vue │ ├── status │ │ ├── StatusAccountDetails.vue │ │ ├── StatusActionButton.vue │ │ ├── StatusActions.vue │ │ ├── StatusActionsMore.vue │ │ ├── StatusAttachment.vue │ │ ├── StatusBody.vue │ │ ├── StatusCard.vue │ │ ├── StatusCardSkeleton.vue │ │ ├── StatusContent.vue │ │ ├── StatusDetails.vue │ │ ├── StatusEmbeddedMedia.vue │ │ ├── StatusFavouritedBoostedBy.vue │ │ ├── StatusLink.vue │ │ ├── StatusMedia.vue │ │ ├── StatusNotFound.vue │ │ ├── StatusPoll.vue │ │ ├── StatusPreviewCard.vue │ │ ├── StatusPreviewCardInfo.vue │ │ ├── StatusPreviewCardMoreFromAuthor.vue │ │ ├── StatusPreviewCardNormal.vue │ │ ├── StatusPreviewCardSkeleton.vue │ │ ├── StatusPreviewGitHub.vue │ │ ├── StatusPreviewStackBlitz.vue │ │ ├── StatusReplyingTo.vue │ │ ├── StatusSpoiler.vue │ │ ├── StatusTranslation.vue │ │ ├── StatusVisibilityIndicator.vue │ │ └── edit │ │ │ ├── StatusEditHistory.vue │ │ │ ├── StatusEditHistorySkeleton.vue │ │ │ ├── StatusEditIndicator.vue │ │ │ └── StatusEditPreview.vue │ ├── tag │ │ ├── TagActionButton.vue │ │ ├── TagCard.vue │ │ ├── TagCardPaginator.vue │ │ └── TagCardSkeleton.vue │ ├── timeline │ │ ├── TimelineBlocks.vue │ │ ├── TimelineBookmarks.vue │ │ ├── TimelineConversations.vue │ │ ├── TimelineDomainBlocks.vue │ │ ├── TimelineFavourites.vue │ │ ├── TimelineHome.vue │ │ ├── TimelineMutes.vue │ │ ├── TimelineNotifications.vue │ │ ├── TimelinePaginator.vue │ │ ├── TimelinePinned.vue │ │ ├── TimelinePublic.vue │ │ ├── TimelinePublicLocal.vue │ │ └── TimelineSkeleton.vue │ ├── tiptap │ │ ├── TiptapCodeBlock.vue │ │ ├── TiptapEmojiList.vue │ │ ├── TiptapHashtagList.vue │ │ └── TiptapMentionList.vue │ └── user │ │ ├── UserDropdown.vue │ │ ├── UserPicker.vue │ │ ├── UserSignIn.vue │ │ ├── UserSignInEntry.vue │ │ └── UserSwitcher.vue ├── composables │ ├── about.ts │ ├── aria.ts │ ├── cache.ts │ ├── command.ts │ ├── content-parse.ts │ ├── content-render.ts │ ├── dialog.ts │ ├── emojis.ts │ ├── i18n.ts │ ├── idb │ │ └── index.ts │ ├── injections.ts │ ├── langugage.ts │ ├── magickeys.ts │ ├── mask.ts │ ├── masto │ │ ├── account.ts │ │ ├── icons.ts │ │ ├── masto.ts │ │ ├── notification.ts │ │ ├── publish.ts │ │ ├── relationship.ts │ │ ├── routes.ts │ │ ├── search.ts │ │ ├── status.ts │ │ ├── statusDrafts.ts │ │ └── translate.ts │ ├── misc.ts │ ├── notification.ts │ ├── paginator.ts │ ├── push-notifications │ │ ├── createPushSubscription.ts │ │ ├── types.ts │ │ └── usePushManager.ts │ ├── screen.ts │ ├── settings │ │ ├── definition.ts │ │ ├── index.ts │ │ ├── metadata.ts │ │ └── storage.ts │ ├── setups.ts │ ├── shiki.ts │ ├── sign-in.ts │ ├── thread.ts │ ├── timeline.ts │ ├── tiptap.ts │ ├── tiptap │ │ ├── custom-emoji.ts │ │ ├── emoji.ts │ │ ├── shiki-parser.ts │ │ ├── shiki.ts │ │ └── suggestion.ts │ ├── users.ts │ ├── vue.ts │ └── web-share-target.ts ├── constants │ ├── index.ts │ ├── options.ts │ ├── symbols.ts │ └── themes.json ├── error.vue ├── layouts │ ├── default.vue │ └── none.vue ├── middleware │ ├── 1.permalink.global.ts │ ├── 2.single-instance.global.ts │ └── auth.ts ├── pages │ ├── [...permalink].vue │ ├── [[server]] │ │ ├── @[account] │ │ │ ├── [status].vue │ │ │ ├── index.vue │ │ │ └── index │ │ │ │ ├── followers.vue │ │ │ │ ├── following.vue │ │ │ │ ├── index.vue │ │ │ │ ├── media.vue │ │ │ │ └── with_replies.vue │ │ ├── explore.vue │ │ ├── explore │ │ │ ├── index.vue │ │ │ ├── links.vue │ │ │ ├── tags.vue │ │ │ └── users.vue │ │ ├── index.vue │ │ ├── list │ │ │ └── [list] │ │ │ │ ├── index.vue │ │ │ │ └── index │ │ │ │ ├── accounts.vue │ │ │ │ └── index.vue │ │ ├── lists.vue │ │ ├── lists │ │ │ └── index.vue │ │ ├── public │ │ │ ├── index.vue │ │ │ └── local.vue │ │ ├── search.vue │ │ ├── status │ │ │ └── [status].vue │ │ └── tags │ │ │ └── [tag].vue │ ├── blocks.vue │ ├── bookmarks.vue │ ├── compose.vue │ ├── conversations.vue │ ├── domain_blocks.vue │ ├── favourites.vue │ ├── hashtags.vue │ ├── hashtags │ │ └── index.vue │ ├── home.vue │ ├── index.vue │ ├── intent │ │ └── post.vue │ ├── mutes.vue │ ├── notifications.vue │ ├── notifications │ │ ├── [filter].vue │ │ └── index.vue │ ├── pinned.vue │ ├── settings.vue │ ├── settings │ │ ├── about │ │ │ └── index.vue │ │ ├── index.vue │ │ ├── interface │ │ │ └── index.vue │ │ ├── language │ │ │ └── index.vue │ │ ├── notifications │ │ │ ├── index.vue │ │ │ ├── notifications.vue │ │ │ └── push-notifications.vue │ │ ├── preferences │ │ │ └── index.vue │ │ ├── profile │ │ │ ├── appearance.vue │ │ │ ├── featured-tags.vue │ │ │ └── index.vue │ │ └── users │ │ │ └── index.vue │ └── share-target.vue ├── plugins │ ├── 0.setup-users.ts │ ├── 1.scroll-to-top.ts │ ├── color-mode.ts │ ├── floating-vue.ts │ ├── hydration.client.ts │ ├── magic-keys.client.ts │ ├── page-lifecycle.client.ts │ ├── path.ts │ ├── setup-global-effects.client.ts │ ├── setup-head-script.server.ts │ ├── setup-i18n.ts │ └── social.server.ts ├── styles │ ├── default-theme.css │ ├── dropdown.css │ ├── global.css │ ├── scrollbars.css │ ├── tiptap.css │ └── vars.css └── utils │ ├── elk-idb.ts │ ├── i18n.ts │ └── language.ts ├── config ├── emojis.ts ├── env.ts ├── i18n.config.ts ├── i18n.ts └── pwa.ts ├── docker-compose.yaml ├── docs ├── .env.example ├── .gitignore ├── README.md ├── app.config.ts ├── app.vue ├── components │ └── global │ │ ├── ClipboardIcon.vue │ │ ├── IconMastodon.vue │ │ ├── Logo.vue │ │ ├── ToggleIcon.vue │ │ └── TranslationState.vue ├── content │ ├── 0.index.md │ ├── 1.guide │ │ ├── 1.index.md │ │ ├── 2.features.md │ │ ├── 3.contributing.md │ │ └── 4.sponsoring.md │ ├── 2.deployment │ │ └── 1.netlify.md │ ├── 80.pwa.md │ └── 99.privacy.md ├── netlify.toml ├── nuxt.config.ts ├── package.json ├── public │ ├── apple-touch-icon.png │ ├── elk-screenshot.png │ ├── favicon.ico │ ├── fonts │ │ └── DM-sans-v11.ttf │ ├── images │ │ ├── nuxtlabs.svg │ │ ├── selfhosting-guide │ │ │ ├── cf-api-token-settings.png │ │ │ └── github-fork.png │ │ └── stackblitz.svg │ ├── logo.svg │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ ├── screenshot.png │ └── site.webmanifest ├── tokens.config.ts ├── tsconfig.json └── types.ts ├── elk.svg ├── emoji-mart-traslation.d.ts ├── eslint.config.js ├── https-dev-config ├── local-https-server.mjs ├── localhost.crt └── localhost.key ├── images ├── nuxtlabs.svg └── stackblitz.svg ├── locales ├── ar-EG.json ├── ar.json ├── ca-ES.json ├── ca-valencia.json ├── ca.json ├── ckb.json ├── cs-CZ.json ├── cy.json ├── de-DE.json ├── el-GR.json ├── en-CA.json ├── en-GB.json ├── en-US.json ├── en.json ├── es-419.json ├── es-ES.json ├── es.json ├── eu-ES.json ├── fa-IR.json ├── fi.json ├── fr-FR.json ├── gl-ES.json ├── hu-HU.json ├── id-ID.json ├── it-IT.json ├── ja-JP.json ├── ko-KR.json ├── nl-NL.json ├── pl-PL.json ├── pt-BR.json ├── pt-PT.json ├── pt.json ├── ru-RU.json ├── th-TH.json ├── tl-PH.json ├── tr-TR.json ├── uk-UA.json ├── vi-VN.json ├── zh-CN.json └── zh-TW.json ├── mocks ├── class.ts ├── prosemirror.ts ├── semver.ts └── tiptap.ts ├── modules ├── build-env.ts ├── emoji-mart-translation.ts ├── purge-comments.ts ├── pwa │ ├── config.ts │ ├── i18n.ts │ ├── index.ts │ ├── runtime │ │ ├── pwa-plugin.client.ts │ │ └── types.d.ts │ └── types.ts └── tauri │ ├── index.ts │ └── runtime │ ├── build-info.ts │ ├── logging.client.ts │ ├── nitro.client.ts │ ├── storage-config.ts │ └── storage.ts ├── netlify.toml ├── nuxt.config.ts ├── package.json ├── page-lifecycle.d.ts ├── patches ├── .gitkeep └── pinceau.patch ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public-dev ├── apple-touch-icon.png ├── favicon.ico ├── logo.svg ├── maskable-icon.png ├── pwa-192x192.png ├── pwa-512x512.png └── pwa-64x64.png ├── public-staging ├── apple-touch-icon.png ├── favicon.ico ├── logo.svg ├── maskable-icon.png ├── pwa-192x192.png ├── pwa-512x512.png ├── pwa-64x64.png └── robots.txt ├── public ├── _redirects ├── apple-touch-icon.png ├── avatars │ ├── antfu-100x100.png │ ├── antfu-60x60.png │ ├── danielroe-100x100.png │ ├── danielroe-60x60.png │ ├── patak-dev-100x100.png │ ├── patak-dev-60x60.png │ ├── shuuji3-100x100.png │ ├── shuuji3-60x60.png │ ├── sxzz-100x100.png │ ├── sxzz-60x60.png │ ├── userquin-100x100.png │ └── userquin-60x60.png ├── elk-og.png ├── favicon.ico ├── fonts │ ├── DM-mono-v10.ttf │ ├── DM-sans-v11.ttf │ ├── DM-serif-display-v10.ttf │ └── homemade-apple-v18.ttf ├── logo.svg ├── maskable-icon.png ├── pwa-192x192.png ├── pwa-512x512.png ├── pwa-64x64.png ├── robots.txt ├── screenshots │ ├── dark-1.webp │ └── light-1.webp ├── shortcuts │ ├── compose-96x96.png │ ├── compose.png │ ├── home-96x96.png │ ├── home.png │ ├── local-96x96.png │ ├── local.png │ ├── notifications-96x96.png │ ├── notifications.png │ ├── settings-96x96.png │ └── settings.png └── sw.js ├── scripts ├── avatars.ts ├── cleanup-translations.ts ├── generate-pwa-icons.ts ├── generate-themes.ts ├── prepare-translation-status.ts ├── prepare.ts └── release.ts ├── server ├── api │ ├── [server] │ │ ├── clear.ts │ │ ├── login.ts │ │ └── oauth │ │ │ └── [origin].ts │ └── list-servers.ts ├── cache-driver.ts └── utils │ └── shared.ts ├── service-worker ├── elk-sw.ts ├── notification.ts ├── share-target.ts ├── tsconfig.json ├── types.ts └── web-push-notifications.ts ├── shared └── types │ ├── index.ts │ ├── translation-status.ts │ └── utils.ts ├── shims.d.ts ├── tests ├── nuxt │ ├── __snapshots__ │ │ ├── content-rich.test.ts.snap │ │ └── html-parse.test.ts.snap │ ├── content-rich.test.ts │ ├── html-parse.test.ts │ └── html-to-text.test.ts ├── setup.ts └── unit │ ├── language.test.ts │ ├── permalinks.test.ts │ └── reorder-timeline.test.ts ├── tsconfig.json ├── unocss.config.ts ├── vitest.config.ts └── vue-compiler-options.d.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | # Modified from .gitignore 2 | node_modules 3 | *.log 4 | dist 5 | .output 6 | .nuxt 7 | #.env # Not ignoring this file because it can contain build-related settings. 8 | .DS_Store 9 | .idea/ 10 | .vite-inspect 11 | .netlify/ 12 | .eslintcache 13 | 14 | public/emojis 15 | 16 | *~ 17 | *swp 18 | *swo 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NUXT_PUBLIC_TRANSLATE_API= 2 | NUXT_PUBLIC_DEFAULT_SERVER= 3 | NUXT_PUBLIC_SINGLE_INSTANCE= 4 | NUXT_PUBLIC_PRIVACY_POLICY_URL= 5 | 6 | # Production only 7 | NUXT_CLOUDFLARE_ACCOUNT_ID= 8 | NUXT_CLOUDFLARE_NAMESPACE_ID= 9 | NUXT_CLOUDFLARE_API_TOKEN= 10 | 11 | # 'cloudflare' | 'vercel' | 'fs' 12 | NUXT_STORAGE_DRIVER= 13 | NUXT_STORAGE_FS_BASE= 14 | 15 | NUXT_ADMIN_KEY= 16 | 17 | NUXT_PUBLIC_DISABLE_VERSION_CHECK= 18 | 19 | NUXT_GITHUB_CLIENT_ID= 20 | NUXT_GITHUB_CLIENT_SECRET= 21 | NUXT_GITHUB_INVITE_TOKEN= 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [elk-zone] 2 | open_collective: elk 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug report 3 | about: Report an issue 4 | labels: ['s: pending triage', 'c: bug'] 5 | --- 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discord Chat 4 | url: https://chat.elk.zone 5 | about: Ask questions and discuss with other users in real time. 6 | - name: Questions & Discussions 7 | url: https://github.com/elk-zone/elk/discussions 8 | about: Use GitHub discussions for message-board style questions and discussions. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 New feature proposal 3 | about: Propose a new feature 4 | labels: 's: pending triage' 5 | --- 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | permissions: {} 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | workflow_dispatch: {} 13 | merge_group: {} 14 | 15 | jobs: 16 | ci: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | # workaround for npm registry key change 22 | # ref. `pnpm@10.1.0` / `pnpm@9.15.4` cannot be installed due to key id mismatch · Issue #612 · nodejs/corepack 23 | # - https://github.com/nodejs/corepack/issues/612#issuecomment-2629496091 24 | - run: npm i -g corepack@latest && corepack enable 25 | - uses: actions/setup-node@v4.4.0 26 | with: 27 | node-version-file: .nvmrc 28 | 29 | - name: 📦 Install dependencies 30 | run: pnpm install --frozen-lockfile 31 | 32 | - name: 🚧 Set up project 33 | run: pnpm nuxi prepare 34 | 35 | - name: 🧪 Test project 36 | run: pnpm test:ci 37 | timeout-minutes: 10 38 | 39 | - name: 📝 Lint 40 | run: pnpm lint 41 | 42 | - name: 💪 Type check 43 | run: pnpm test:typecheck 44 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: build & push docker container 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - '*' 8 | pull_request: 9 | branches: 10 | - main 11 | jobs: 12 | docker: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Docker meta 21 | id: metal 22 | uses: docker/metadata-action@v5 23 | with: 24 | images: | 25 | ghcr.io/${{ github.repository }} 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | - name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@v3 30 | - name: Login to GitHub Container Registry 31 | if: github.event_name != 'pull_request' 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.actor }} 36 | password: ${{ github.token }} 37 | - name: Build and push 38 | uses: docker/build-push-action@v6 39 | with: 40 | context: . 41 | platforms: linux/amd64,linux/arm64 42 | push: ${{ github.event_name != 'pull_request' }} 43 | tags: ${{ steps.metal.outputs.tags }} 44 | labels: ${{ steps.metal.outputs.labels }} 45 | cache-from: type=gha 46 | cache-to: type=gha,mode=max 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version-file: .nvmrc 23 | 24 | - run: npx changelogithub 25 | env: 26 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 27 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Semantic Pull Request 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | main: 14 | permissions: 15 | pull-requests: read # to analyze PRs (amannn/action-semantic-pull-request) 16 | statuses: write # to mark status of analyzed PR (amannn/action-semantic-pull-request) 17 | 18 | runs-on: ubuntu-latest 19 | name: Semantic Pull Request 20 | steps: 21 | - name: Validate PR title 22 | uses: amannn/action-semantic-pull-request@v5.5.3 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | dist 4 | .output 5 | .pnpm-store 6 | .nuxt 7 | .env 8 | .DS_Store 9 | .idea/ 10 | .vite-inspect 11 | .netlify/ 12 | .eslintcache 13 | elk-translation-status.json 14 | 15 | public/emojis 16 | 17 | *~ 18 | *swp 19 | *swo 20 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | shell-emulator=true 3 | ignore-workspace-root-check=true 4 | package-manager-strict=false 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.stackblitz/codeflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot": { 3 | "issues": { 4 | "trigger": "all-issues" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.stackblitzrc: -------------------------------------------------------------------------------- 1 | { 2 | "installDependencies": true, 3 | "startCommand": "npm run dev:mocked" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "antfu.iconify", 4 | "antfu.unocss", 5 | "antfu.goto-alias", 6 | "csstools.postcss", 7 | "dbaeumer.vscode-eslint", 8 | "vue.volar", 9 | "lokalise.i18n-ally" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present, Elk contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | setupPageHeader() 3 | provideGlobalCommands() 4 | 5 | const route = useRoute() 6 | 7 | if (import.meta.server && !route.path.startsWith('/settings')) { 8 | const url = useRequestURL() 9 | 10 | useHead({ 11 | meta: [ 12 | { property: 'og:url', content: `${url.origin}${route.path}` }, 13 | ], 14 | }) 15 | } 16 | 17 | // We want to trigger rerendering the page when account changes 18 | const key = computed(() => `${currentUser.value?.server ?? currentServer.value}:${currentUser.value?.account.id || ''}`) 19 | </script> 20 | 21 | <template> 22 | <NuxtLoadingIndicator color="repeating-linear-gradient(to right,var(--c-primary) 0%,var(--c-primary-active) 100%)" /> 23 | <NuxtLayout :key="key"> 24 | <NuxtPage /> 25 | </NuxtLayout> 26 | <AriaAnnouncer /> 27 | 28 | <!-- Avatar Mask --> 29 | <svg absolute op0 width="0" height="0"> 30 | <defs> 31 | <clipPath id="avatar-mask" clipPathUnits="objectBoundingBox"> 32 | <path d="M 0,0.5 C 0,0 0,0 0.5,0 S 1,0 1,0.5 1,1 0.5,1 0,1 0,0.5" /> 33 | </clipPath> 34 | </defs> 35 | </svg> 36 | </template> 37 | -------------------------------------------------------------------------------- /app/components/account/AccountAvatar.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { account } = defineProps<{ 5 | account: mastodon.v1.Account 6 | square?: boolean 7 | }>() 8 | 9 | const loaded = ref(false) 10 | const error = ref(false) 11 | 12 | const preferredMotion = usePreferredReducedMotion() 13 | const accountAvatarSrc = computed(() => { 14 | return preferredMotion.value === 'reduce' ? (account?.avatarStatic ?? account.avatar) : account.avatar 15 | }) 16 | </script> 17 | 18 | <template> 19 | <img 20 | :key="account.avatar" 21 | width="400" 22 | height="400" 23 | select-none 24 | :src="(error || !loaded) ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : accountAvatarSrc" 25 | :alt="$t('account.avatar_description', [account.username])" 26 | loading="lazy" 27 | class="account-avatar object-cover" 28 | :class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')" 29 | :style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }" 30 | v-bind="$attrs" 31 | @load="loaded = true" 32 | @error="error = true" 33 | > 34 | </template> 35 | -------------------------------------------------------------------------------- /app/components/account/AccountBigAvatar.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | // Avatar with a background base achieving a 3px border to be used in status cards 5 | // The border is used for Avatar on Avatar for reblogs and connecting replies 6 | 7 | defineProps<{ 8 | account: mastodon.v1.Account 9 | square?: boolean 10 | }>() 11 | </script> 12 | 13 | <template> 14 | <div :key="account.avatar" v-bind="$attrs" :style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }" :class="{ 'rounded-full': !square }" bg-base w-54px h-54px flex items-center justify-center> 15 | <AccountAvatar :account="account" w-48px h-48px :square="square" /> 16 | </div> 17 | </template> 18 | -------------------------------------------------------------------------------- /app/components/account/AccountBigCardSkeleton.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div> 3 | <!-- Banner --> 4 | <div px2 pt2> 5 | <div rounded of-hidden aspect="3.19" class="flex skeleton-loading-bg" /> 6 | <div px-4 pb-4 flex="~ col gap-2"> 7 | <!-- User info --> 8 | <div flex sm:flex-row flex-col flex-gap-2> 9 | <div flex items-center justify-between> 10 | <div w-17 h-17 rounded-full border-4 border-bg-base z-2 mt--2 ms--1 of-hidden bg-base> 11 | <div class="flex skeleton-loading-bg" w-full h-full /> 12 | </div> 13 | <div block sm:hidden class="skeleton-loading-bg" h-8 w-30 rounded-full /> 14 | </div> 15 | <div sm:mt-2 flex="~ col 1 gap-2"> 16 | <div flex class="skeleton-loading-bg" h-5 w-20 rounded /> 17 | <div flex class="skeleton-loading-bg" h-4 w-40 rounded /> 18 | </div> 19 | </div> 20 | <!-- Note --> 21 | <div flex class="skeleton-loading-bg" h-4 my3 w="3/5" rounded /> 22 | <!-- Follow info --> 23 | <div flex justify-between items-center> 24 | <div flex class="skeleton-loading-bg" h-4 w="sm:1/2 full" rounded /> 25 | <div sm:flex hidden class="skeleton-loading-bg" h-8 w-30 rounded-full /> 26 | </div> 27 | </div> 28 | </div> 29 | </div> 30 | </template> 31 | -------------------------------------------------------------------------------- /app/components/account/AccountBotIndicator.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | showLabel?: boolean 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <div 9 | flex="~ gap1" items-center 10 | :class="{ 'border border-base rounded-md px-1': showLabel }" 11 | text-secondary-light 12 | > 13 | <slot name="prepend" /> 14 | <CommonTooltip :content="$t('account.bot')" :disabled="showLabel"> 15 | <div i-mdi:robot-outline /> 16 | </CommonTooltip> 17 | <div v-if="showLabel"> 18 | {{ $t('account.bot') }} 19 | </div> 20 | </div> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/components/account/AccountCard.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { account } = defineProps<{ 5 | account: mastodon.v1.Account 6 | hoverCard?: boolean 7 | relationshipContext?: 'followedBy' | 'following' 8 | }>() 9 | 10 | cacheAccount(account) 11 | </script> 12 | 13 | <template> 14 | <div flex justify-between hover:bg-active transition-100> 15 | <AccountInfo 16 | :account="account" hover p1 as="router-link" 17 | :hover-card="hoverCard" 18 | shrink 19 | overflow-hidden 20 | :to="getAccountRoute(account)" 21 | /> 22 | <slot> 23 | <div h-full p1 shrink-0> 24 | <AccountFollowButton :account="account" :context="relationshipContext" /> 25 | </div> 26 | </slot> 27 | </div> 28 | </template> 29 | -------------------------------------------------------------------------------- /app/components/account/AccountDisplayName.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { hideEmojis = false } = defineProps<{ 5 | account: mastodon.v1.Account 6 | hideEmojis?: boolean 7 | }>() 8 | </script> 9 | 10 | <template> 11 | <ContentRich 12 | :content="getDisplayName(account, { rich: true })" 13 | :emojis="account.emojis" 14 | :hide-emojis="hideEmojis" 15 | :markdown="false" 16 | /> 17 | </template> 18 | -------------------------------------------------------------------------------- /app/components/account/AccountHandle.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { account } = defineProps<{ 5 | account: mastodon.v1.Account 6 | }>() 7 | 8 | const serverName = computed(() => getServerName(account)) 9 | </script> 10 | 11 | <template> 12 | <p line-clamp-1 whitespace-pre-wrap break-all text-secondary-light leading-tight dir="ltr"> 13 | <!-- fix: #274 only line-clamp-1 can be used here, using text-ellipsis is not valid --> 14 | <span text-secondary>{{ getShortHandle(account) }}</span> 15 | <span v-if="serverName" text-secondary-light>@{{ serverName }}</span> 16 | </p> 17 | </template> 18 | -------------------------------------------------------------------------------- /app/components/account/AccountHoverCard.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { account } = defineProps<{ 5 | account: mastodon.v1.Account 6 | }>() 7 | 8 | const relationship = useRelationship(account) 9 | </script> 10 | 11 | <template> 12 | <div v-show="relationship" flex="~ col gap2" rounded min-w-90 max-w-120 z-100 overflow-hidden p-4> 13 | <div flex="~ gap2" items-center> 14 | <NuxtLink :to="getAccountRoute(account)" flex-auto rounded-full hover:bg-active transition-100 pe5 me-a> 15 | <AccountInfo :account="account" :hover-card="false" /> 16 | </NuxtLink> 17 | <AccountFollowButton text-sm :account="account" :relationship="relationship" /> 18 | </div> 19 | <div v-if="account.note" max-h-100 overflow-y-auto> 20 | <ContentRich text-4 text-secondary :content="account.note" :emojis="account.emojis" /> 21 | </div> 22 | <AccountPostsFollowers text-sm :account="account" :is-hover-card="true" /> 23 | </div> 24 | </template> 25 | -------------------------------------------------------------------------------- /app/components/account/AccountInlineInfo.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { link = true, avatar = true } = defineProps<{ 5 | account: mastodon.v1.Account 6 | link?: boolean 7 | avatar?: boolean 8 | }>() 9 | 10 | const userSettings = useUserSettings() 11 | </script> 12 | 13 | <script lang="ts"> 14 | export default { 15 | inheritAttrs: false, 16 | } 17 | </script> 18 | 19 | <template> 20 | <AccountHoverWrapper :account="account"> 21 | <NuxtLink 22 | :to="link ? getAccountRoute(account) : undefined" 23 | :class="link ? 'text-link-rounded -ml-1.5rem pl-1.5rem rtl-(ml0 pl-0.5rem -mr-1.5rem pr-1.5rem)' : ''" 24 | v-bind="$attrs" 25 | min-w-0 flex gap-2 items-center 26 | > 27 | <AccountAvatar v-if="avatar" :account="account" w-5 h-5 /> 28 | <AccountDisplayName :account="account" :hide-emojis="getPreferences(userSettings, 'hideUsernameEmojis')" line-clamp-1 ws-pre-wrap break-all /> 29 | </NuxtLink> 30 | </AccountHoverWrapper> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/components/account/AccountLockIndicator.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | showLabel?: boolean 4 | }>() 5 | 6 | const { t } = useI18n() 7 | </script> 8 | 9 | <template> 10 | <div 11 | flex="~ gap1" items-center 12 | :class="{ 'border border-base rounded-md px-1': showLabel }" 13 | text-secondary-light 14 | > 15 | <slot name="prepend" /> 16 | <CommonTooltip content="Lock" :disabled="showLabel"> 17 | <div i-ri:lock-line /> 18 | </CommonTooltip> 19 | <div v-if="showLabel"> 20 | {{ t('account.lock') }} 21 | </div> 22 | </div> 23 | </template> 24 | -------------------------------------------------------------------------------- /app/components/account/AccountMoved.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineProps<{ 5 | account: mastodon.v1.Account 6 | }>() 7 | </script> 8 | 9 | <template> 10 | <div flex="~ col gap-2" p4> 11 | <div flex="~ gap-1" justify-center> 12 | <AccountInlineInfo :account="account" :link="false" /> 13 | {{ $t('account.moved_title') }} 14 | </div> 15 | 16 | <div flex> 17 | <NuxtLink :to="getAccountRoute(account.moved!)"> 18 | <AccountInfo :account="account.moved!" /> 19 | </NuxtLink> 20 | <div flex-auto /> 21 | <div flex items-center> 22 | <NuxtLink :to="getAccountRoute(account.moved as any)" btn-solid inline-block h-fit> 23 | {{ $t('account.go_to_profile') }} 24 | </NuxtLink> 25 | </div> 26 | </div> 27 | </div> 28 | </template> 29 | -------------------------------------------------------------------------------- /app/components/account/AccountRolesIndicator.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineProps<{ 5 | account: mastodon.v1.Account 6 | limit?: number 7 | }>() 8 | </script> 9 | 10 | <template> 11 | <div 12 | flex="~ gap1" items-center 13 | class="border border-base rounded-md px-1" 14 | text-secondary-light 15 | > 16 | <slot name="prepend" /> 17 | <div v-for="role in account.roles?.slice(0, limit)" :key="role.id" flex> 18 | <div :style="`color: ${role.color}; border-color: ${role.color}`"> 19 | {{ role.name }} 20 | </div> 21 | </div> 22 | </div> 23 | <div 24 | v-if="limit && account.roles?.length > limit" 25 | flex="~ gap1" items-center 26 | class="border border-base rounded-md px-1" 27 | text-secondary-light 28 | > 29 | +{{ account.roles?.length - limit }} 30 | </div> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/components/account/AccountTabs.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { CommonRouteTabOption } from '#shared/types' 3 | 4 | const { t } = useI18n() 5 | const route = useRoute() 6 | 7 | const server = computed(() => route.params.server as string) 8 | const account = computed(() => route.params.account as string) 9 | 10 | const tabs = computed<CommonRouteTabOption[]>(() => [ 11 | { 12 | name: 'account-index', 13 | to: { 14 | name: 'account-index', 15 | params: { server: server.value, account: account.value }, 16 | }, 17 | display: t('tab.posts'), 18 | icon: 'i-ri:file-list-2-line', 19 | }, 20 | { 21 | name: 'account-replies', 22 | to: { 23 | name: 'account-replies', 24 | params: { server: server.value, account: account.value }, 25 | }, 26 | display: t('tab.posts_with_replies'), 27 | icon: 'i-ri:chat-1-line', 28 | }, 29 | { 30 | name: 'account-media', 31 | to: { 32 | name: 'account-media', 33 | params: { server: server.value, account: account.value }, 34 | }, 35 | display: t('tab.media'), 36 | icon: 'i-ri:camera-2-line', 37 | }, 38 | ]) 39 | </script> 40 | 41 | <template> 42 | <CommonRouteTabs force replace :options="tabs" prevent-scroll-top command border="base b" /> 43 | </template> 44 | -------------------------------------------------------------------------------- /app/components/account/TagHoverWrapper.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineOptions({ 5 | inheritAttrs: false, 6 | }) 7 | 8 | const { tagName } = defineProps<{ 9 | tagName?: string 10 | disabled?: boolean 11 | }>() 12 | 13 | const tag = ref<mastodon.v1.Tag>() 14 | const tagHover = ref() 15 | const hovered = useElementHover(tagHover) 16 | 17 | watch(hovered, (newHovered) => { 18 | if (newHovered && tagName) { 19 | fetchTag(tagName).then((t) => { 20 | tag.value = t 21 | }) 22 | } 23 | }) 24 | 25 | const userSettings = useUserSettings() 26 | </script> 27 | 28 | <template> 29 | <span ref="tagHover"> 30 | <VMenu 31 | v-if="!disabled && !getPreferences(userSettings, 'hideTagHoverCard')" 32 | placement="bottom-start" 33 | :delay="{ show: 500, hide: 100 }" 34 | v-bind="$attrs" 35 | :close-on-content-click="false" 36 | no-auto-focus 37 | > 38 | <slot /> 39 | <template #popper> 40 | <TagCardSkeleton v-if="!tag" /> 41 | <TagCard v-else :tag="tag" /> 42 | </template> 43 | </VMenu> 44 | <slot v-else /> 45 | </span> 46 | </template> 47 | -------------------------------------------------------------------------------- /app/components/aria/AriaLog.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { AriaLive } from '~/composables/aria' 3 | 4 | const { 5 | ariaLive = 'polite', 6 | heading = 'h2', 7 | messageKey = (message: any) => message, 8 | } = defineProps<{ 9 | ariaLive?: AriaLive 10 | heading?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' 11 | title: string 12 | messageKey?: (message: any) => any 13 | }>() 14 | 15 | const { announceLogs, appendLogs, clearLogs, logs } = useAriaLog() 16 | 17 | defineExpose({ 18 | announceLogs, 19 | appendLogs, 20 | clearLogs, 21 | }) 22 | </script> 23 | 24 | <template> 25 | <slot /> 26 | <div sr-only role="log" :aria-live="ariaLive"> 27 | <component :is="heading"> 28 | {{ title }} 29 | </component> 30 | <ul> 31 | <li v-for="log in logs" :key="messageKey(log)"> 32 | <slot name="log" :log="log"> 33 | {{ log }} 34 | </slot> 35 | </li> 36 | </ul> 37 | </div> 38 | </template> 39 | -------------------------------------------------------------------------------- /app/components/aria/AriaStatus.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { AriaLive } from '~/composables/aria' 3 | 4 | const { ariaLive = 'polite' } = defineProps<{ 5 | ariaLive?: AriaLive 6 | }>() 7 | 8 | const { announceStatus, clearStatus, status } = useAriaStatus() 9 | 10 | defineExpose({ 11 | announceStatus, 12 | clearStatus, 13 | }) 14 | </script> 15 | 16 | <template> 17 | <slot /> 18 | <p sr-only role="status" :aria-live="ariaLive"> 19 | <slot name="status" :status="status"> 20 | {{ status }} 21 | </slot> 22 | </p> 23 | </template> 24 | -------------------------------------------------------------------------------- /app/components/command/CommandKey.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { name } = defineProps<{ 3 | name: string 4 | }>() 5 | 6 | const isMac = useIsMac() 7 | 8 | const keys = computed(() => name.toLowerCase().split('+')) 9 | </script> 10 | 11 | <template> 12 | <div class="flex items-center px-1"> 13 | <template v-for="(key, index) in keys" :key="key"> 14 | <div v-if="index > 0" class="inline-block px-.5"> 15 | + 16 | </div> 17 | <div 18 | class="p-1 grid place-items-center rounded-lg shadow-sm" 19 | text="xs secondary" 20 | border="1 base" 21 | > 22 | <div v-if="key === 'enter'" i-material-symbols:keyboard-return-rounded /> 23 | <div v-else-if="key === 'meta' && isMac" i-material-symbols:keyboard-command-key /> 24 | <div v-else-if="key === 'meta' && !isMac" i-material-symbols:window-sharp /> 25 | <div v-else-if="key === 'alt' && isMac" i-material-symbols:keyboard-option-key-rounded /> 26 | <div v-else-if="key === 'arrowup'" i-ri:arrow-up-line /> 27 | <div v-else-if="key === 'arrowdown'" i-ri:arrow-down-line /> 28 | <div v-else-if="key === 'arrowleft'" i-ri:arrow-left-line /> 29 | <div v-else-if="key === 'arrowright'" i-ri:arrow-right-line /> 30 | <template v-else-if="key === 'escape'"> 31 | ESC 32 | </template> 33 | <div v-else :class="{ 'px-.5': key.length === 1 }"> 34 | {{ key[0].toUpperCase() + key.slice(1) }} 35 | </div> 36 | </div> 37 | </template> 38 | </div> 39 | </template> 40 | -------------------------------------------------------------------------------- /app/components/common/AnimateNumber.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | increased?: boolean 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <div of-hidden h="1.25rem"> 9 | <div flex="~ col" transition-transform duration-300 :class="increased ? 'translate-y--1/2' : 'translate-y-0'"> 10 | <slot /> 11 | <slot name="next" /> 12 | </div> 13 | </div> 14 | </template> 15 | -------------------------------------------------------------------------------- /app/components/common/CommonAlert.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const emit = defineEmits<{ 3 | (event: 'close'): void 4 | }>() 5 | const visible = defineModel<boolean>() 6 | 7 | function close() { 8 | emit('close') 9 | visible.value = false 10 | } 11 | </script> 12 | 13 | <template> 14 | <div 15 | flex="~ gap-2" justify-between items-center 16 | border="b base" text-sm text-secondary px4 py2 sm:py4 17 | > 18 | <div> 19 | <slot /> 20 | </div> 21 | <button text-xl hover:text-primary bg-hover-overflow w="1.2em" h="1.2em" @click="close()"> 22 | <div i-ri:close-line /> 23 | </button> 24 | </div> 25 | </template> 26 | -------------------------------------------------------------------------------- /app/components/common/CommonBlurhash.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineOptions({ 3 | inheritAttrs: false, 4 | }) 5 | 6 | const { blurhash = '', shouldLoadImage = true } = defineProps<{ 7 | blurhash?: string 8 | src: string 9 | srcset?: string 10 | shouldLoadImage?: boolean 11 | }>() 12 | </script> 13 | 14 | <template> 15 | <UnLazyImage v-bind="$attrs" :blurhash="blurhash" :src="src" :src-set="srcset" :lazy-load="shouldLoadImage" auto-sizes /> 16 | </template> 17 | -------------------------------------------------------------------------------- /app/components/common/CommonCheckbox.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | label?: string 4 | hover?: boolean 5 | iconChecked?: string 6 | iconUnchecked?: string 7 | checkedIconColor?: string 8 | prependCheckbox?: boolean 9 | }>() 10 | const modelValue = defineModel<boolean | null>() 11 | </script> 12 | 13 | <template> 14 | <label 15 | class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1" 16 | :class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null" 17 | v-bind="$attrs" 18 | @click.prevent="modelValue = !modelValue" 19 | > 20 | <span v-if="label && !prependCheckbox" flex-1 ms-2 pointer-events-none>{{ label }}</span> 21 | <span 22 | :class="[ 23 | modelValue ? (iconChecked ?? 'i-ri:checkbox-line') : (iconUnchecked ?? 'i-ri:checkbox-blank-line'), 24 | modelValue && checkedIconColor, 25 | ]" 26 | text-lg 27 | aria-hidden="true" 28 | /> 29 | <input 30 | v-model="modelValue" 31 | type="checkbox" 32 | sr-only 33 | > 34 | <span v-if="label && prependCheckbox" flex-1 ms-2 pointer-events-none>{{ label }}</span> 35 | </label> 36 | </template> 37 | 38 | <style> 39 | .common-checkbox:focus-within { 40 | outline: none; 41 | border-bottom: 1px solid var(--c-text-base); 42 | } 43 | </style> 44 | -------------------------------------------------------------------------------- /app/components/common/CommonErrorMessage.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ describedBy: string }>() 3 | </script> 4 | 5 | <template> 6 | <div 7 | role="alert" 8 | aria-live="polite" 9 | :aria-describedby="describedBy" 10 | flex="~ col" 11 | gap-1 text-sm 12 | pt-1 ps-2 pe-1 pb-2 13 | text-red-600 dark:text-red-400 14 | border="~ base rounded red-600 dark:red-400" 15 | v-bind="$attrs" 16 | > 17 | <slot /> 18 | </div> 19 | </template> 20 | -------------------------------------------------------------------------------- /app/components/common/CommonMask.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { 3 | zIndex = 100, 4 | background = 'transparent', 5 | } = defineProps<{ 6 | zIndex?: number 7 | background?: string 8 | }>() 9 | </script> 10 | 11 | <template> 12 | <div fixed top-0 bottom-0 left-0 right-0 :style="{ background, zIndex }" /> 13 | </template> 14 | -------------------------------------------------------------------------------- /app/components/common/CommonNotFound.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div flex="~ col" items-center> 3 | <div i-ri:forbid-line text-10 mt10 mb2 /> 4 | <div text-lg> 5 | <slot>{{ $t('common.not_found') }}</slot> 6 | </div> 7 | </div> 8 | </template> 9 | -------------------------------------------------------------------------------- /app/components/common/CommonPreviewPrompt.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const build = useBuildInfo() 3 | </script> 4 | 5 | <template> 6 | <div 7 | m-2 p5 bg-rose:10 relative 8 | rounded-lg of-hidden 9 | flex="~ col gap-3" 10 | > 11 | <h2 font-bold text-rose> 12 | {{ $t('help.build_preview.title') }} 13 | </h2> 14 | <p> 15 | <i18n-t keypath="help.build_preview.desc1"> 16 | <NuxtLink :href="`https://github.com/elk-zone/elk/commit/${build.commit}`" target="_blank" text-rose hover:underline> 17 | <code>{{ build.shortCommit }}</code> 18 | </NuxtLink> 19 | </i18n-t> 20 | </p> 21 | <p>{{ $t('help.build_preview.desc2') }}</p> 22 | <p font-bold> 23 | {{ $t('help.build_preview.desc3') }} 24 | </p> 25 | <div i-ri-git-pull-request-line absolute text-10em bottom--10 inset-ie--10 text-rose op10 class="-z-1" /> 26 | </div> 27 | </template> 28 | -------------------------------------------------------------------------------- /app/components/common/CommonRadio.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | label: string 4 | value: any 5 | hover?: boolean 6 | }>() 7 | const modelValue = defineModel() 8 | </script> 9 | 10 | <template> 11 | <label 12 | class="common-radio flex items-center cursor-pointer py-1 text-md w-full gap-y-1" 13 | :class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null" 14 | @click.prevent="modelValue = value" 15 | > 16 | <span flex-1 ms-2 pointer-events-none>{{ label }}</span> 17 | <span 18 | :class="modelValue === value ? 'i-ri:radio-button-line' : 'i-ri:checkbox-blank-circle-line'" 19 | aria-hidden="true" 20 | /> 21 | <input 22 | v-model="modelValue" 23 | type="radio" 24 | :value="value" 25 | sr-only 26 | > 27 | </label> 28 | </template> 29 | 30 | <style> 31 | .common-radio:focus-within { 32 | outline: none; 33 | border-bottom: 1px solid var(--c-text-base); 34 | } 35 | </style> 36 | -------------------------------------------------------------------------------- /app/components/common/CommonScrollIntoView.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { as = 'div', active } = defineProps<{ 3 | as: any 4 | active: boolean 5 | }>() 6 | 7 | const el = ref() 8 | 9 | watch(() => active, (active) => { 10 | const _el = unrefElement(el) 11 | 12 | if (active && _el) 13 | _el.scrollIntoView({ block: 'nearest', inline: 'start' }) 14 | }) 15 | </script> 16 | 17 | <template> 18 | <component :is="as" ref="el"> 19 | <slot /> 20 | </component> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/components/common/CommonTooltip.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { Popper as VTooltipType } from 'floating-vue' 3 | 4 | export interface Props extends Partial<typeof VTooltipType> { 5 | content?: string 6 | } 7 | 8 | defineProps<Props>() 9 | </script> 10 | 11 | <template> 12 | <VTooltip 13 | v-if="isHydrated" 14 | v-bind="$attrs" 15 | auto-hide 16 | no-auto-focus 17 | > 18 | <slot /> 19 | <template #popper> 20 | <div text-3> 21 | <slot name="popper"> 22 | {{ content }} 23 | </slot> 24 | </div> 25 | </template> 26 | </VTooltip> 27 | </template> 28 | -------------------------------------------------------------------------------- /app/components/common/CommonTrending.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { 5 | history, 6 | maxDay = 2, 7 | } = defineProps<{ 8 | history: mastodon.v1.TagHistory[] 9 | maxDay?: number 10 | }>() 11 | 12 | const ongoingHot = computed(() => history.slice(0, maxDay)) 13 | 14 | const people = computed(() => 15 | ongoingHot.value.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0), 16 | ) 17 | </script> 18 | 19 | <template> 20 | <p> 21 | {{ $t('command.n_people_in_the_past_n_days', [people, maxDay]) }} 22 | </p> 23 | </template> 24 | -------------------------------------------------------------------------------- /app/components/common/CommonTrendingCharts.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | import sparkline from '@fnando/sparkline' 4 | 5 | const { 6 | history, 7 | width = 60, 8 | height = 40, 9 | } = defineProps<{ 10 | history?: mastodon.v1.TagHistory[] 11 | width?: number 12 | height?: number 13 | }>() 14 | 15 | const historyNum = computed(() => { 16 | if (!history) 17 | return [1, 1, 1, 1, 1, 1, 1] 18 | return [...history].reverse().map(item => Number(item.accounts) || 0) 19 | }) 20 | 21 | const sparklineEl = ref<SVGSVGElement>() 22 | const sparklineFn = typeof sparkline !== 'function' ? (sparkline as any).default : sparkline 23 | 24 | watch([historyNum, sparklineEl], ([historyNum, sparklineEl]) => { 25 | if (!sparklineEl) 26 | return 27 | sparklineFn(sparklineEl, historyNum) 28 | }) 29 | </script> 30 | 31 | <template> 32 | <svg ref="sparklineEl" class="sparkline" :width="width" :height="height" stroke-width="3" /> 33 | </template> 34 | -------------------------------------------------------------------------------- /app/components/common/LocalizedNumber.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineOptions({ 3 | inheritAttrs: false, 4 | }) 5 | 6 | const { count } = defineProps<{ 7 | count: number 8 | keypath: string 9 | }>() 10 | 11 | const { formatHumanReadableNumber, formatNumber, forSR } = useHumanReadableNumber() 12 | 13 | const useSR = computed(() => forSR(count)) 14 | const rawNumber = computed(() => formatNumber(count)) 15 | const humanReadableNumber = computed(() => formatHumanReadableNumber(count)) 16 | </script> 17 | 18 | <template> 19 | <i18n-t :keypath="keypath" :plural="count" tag="span" class="flex gap-x-1"> 20 | <CommonTooltip v-if="useSR" :content="rawNumber" placement="bottom"> 21 | <span aria-hidden="true" v-bind="$attrs">{{ humanReadableNumber }}</span> 22 | <span sr-only>{{ rawNumber }}</span> 23 | </CommonTooltip> 24 | <span v-else v-bind="$attrs">{{ humanReadableNumber }}</span> 25 | </i18n-t> 26 | </template> 27 | -------------------------------------------------------------------------------- /app/components/common/OfflineChecker.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const online = useOnline() 3 | </script> 4 | 5 | <template> 6 | <div 7 | v-if="!online" 8 | w-full min-h-30px px4 py3 text-primary bg-base 9 | border="t base" flex="~ gap-2 center" 10 | > 11 | <div i-ri:wifi-off-line /> 12 | {{ $t('common.offline_desc') }} 13 | </div> 14 | </template> 15 | -------------------------------------------------------------------------------- /app/components/common/dropdown/Dropdown.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { InjectionKeyDropdownContext } from '~/constants/symbols' 3 | 4 | defineProps<{ 5 | placement?: string 6 | autoBoundaryMaxSize?: boolean 7 | }>() 8 | 9 | const dropdown = ref<any>() 10 | const colorMode = useColorMode() 11 | 12 | function hide() { 13 | return dropdown.value.hide() 14 | } 15 | provide(InjectionKeyDropdownContext, { 16 | hide, 17 | }) 18 | 19 | defineExpose({ 20 | hide, 21 | }) 22 | </script> 23 | 24 | <template> 25 | <VDropdown v-bind="$attrs" ref="dropdown" :class="colorMode.value" :placement="placement || 'auto'" :auto-boundary-max-size="autoBoundaryMaxSize"> 26 | <slot /> 27 | <template #popper="scope"> 28 | <slot name="popper" v-bind="scope" /> 29 | </template> 30 | </VDropdown> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/components/content/ContentCode.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { code, lang } = defineProps<{ 3 | code: string 4 | lang?: string 5 | }>() 6 | 7 | const raw = computed(() => decodeURIComponent(code).replace(/'/g, '\'')) 8 | 9 | const langMap: Record<string, string> = { 10 | js: 'javascript', 11 | ts: 'typescript', 12 | vue: 'html', 13 | } 14 | 15 | const highlighted = computed(() => { 16 | return lang ? highlightCode(raw.value, (langMap[lang] || lang) as any) : raw 17 | }) 18 | </script> 19 | 20 | <template> 21 | <pre v-if="lang" class="code-block" v-html="highlighted" /> 22 | <pre v-else class="code-block">{{ raw }}</pre> 23 | </template> 24 | -------------------------------------------------------------------------------- /app/components/content/ContentMentionGroup.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | replying?: boolean 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <p flex="~ gap-1 wrap" items-center text-sm :class="{ 'zen-none': !replying }"> 9 | <span i-ri-arrow-right-line ml--1 text-secondary-light /><slot /> 10 | </p> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/content/ContentRich.setup.ts: -------------------------------------------------------------------------------- 1 | import type { mastodon } from 'masto' 2 | 3 | defineOptions({ 4 | name: 'ContentRich', 5 | }) 6 | 7 | const { 8 | content, 9 | emojis, 10 | hideEmojis = false, 11 | markdown = true, 12 | } = defineProps<{ 13 | content: string 14 | emojis?: mastodon.v1.CustomEmoji[] 15 | hideEmojis?: boolean 16 | markdown?: boolean 17 | }>() 18 | 19 | const emojisObject = useEmojisFallback(() => emojis) 20 | 21 | export default () => h( 22 | 'span', 23 | { class: 'content-rich', dir: 'auto' }, 24 | contentToVNode(content, { 25 | emojis: emojisObject.value, 26 | hideEmojis, 27 | markdown, 28 | }), 29 | ) 30 | -------------------------------------------------------------------------------- /app/components/conversation/ConversationCard.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { conversation } = defineProps<{ 5 | conversation: mastodon.v1.Conversation 6 | }>() 7 | 8 | const withAccounts = computed(() => 9 | conversation.accounts.filter(account => account.id !== conversation.lastStatus?.account.id), 10 | ) 11 | </script> 12 | 13 | <template> 14 | <article v-if="conversation.lastStatus" flex flex-col gap-2> 15 | <StatusCard v-if="conversation.lastStatus" :status="conversation.lastStatus" :actions="false"> 16 | <template #meta> 17 | <div flex gap-2 text-sm text-secondary font-bold> 18 | <p me-1> 19 | {{ $t('conversation.with') }} 20 | </p> 21 | <AccountAvatar v-for="account in withAccounts" :key="account.id" h-5 w-5 :account="account" /> 22 | </div> 23 | </template> 24 | </StatusCard> 25 | </article> 26 | </template> 27 | -------------------------------------------------------------------------------- /app/components/conversation/ConversationPaginator.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineProps<{ 5 | paginator: mastodon.Paginator<mastodon.v1.Conversation[], mastodon.DefaultPaginationParams> 6 | }>() 7 | 8 | function preprocess(items: mastodon.v1.Conversation[]): mastodon.v1.Conversation[] { 9 | const isAuthored = (conversation: mastodon.v1.Conversation) => conversation.lastStatus ? conversation.lastStatus.account.id === currentUser.value?.account.id : false 10 | return items.filter(item => isAuthored(item) || !item.lastStatus?.filtered?.find( 11 | filter => filter.filter.filterAction === 'hide' && filter.filter.context.includes('thread'), 12 | )) 13 | } 14 | </script> 15 | 16 | <template> 17 | <CommonPaginator :paginator="paginator" :preprocess="preprocess"> 18 | <template #default="{ item }"> 19 | <ConversationCard 20 | :conversation="item" 21 | border="b base" py-1 22 | /> 23 | </template> 24 | </CommonPaginator> 25 | </template> 26 | -------------------------------------------------------------------------------- /app/components/emoji/Emoji.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { alt, dataEmojiId } = defineProps<{ 3 | as: string 4 | alt?: string 5 | dataEmojiId?: string 6 | }>() 7 | 8 | const title = ref<string | undefined>() 9 | 10 | if (alt) { 11 | if (alt.startsWith(':')) { 12 | title.value = alt.replace(/:/g, '') 13 | } 14 | else { 15 | import('node-emoji').then(({ find }) => { 16 | title.value = find(alt)?.key.replace(/_/g, ' ') 17 | }) 18 | } 19 | } 20 | 21 | // if it has a data-emoji-id, use that as the title instead 22 | if (dataEmojiId) 23 | title.value = dataEmojiId 24 | </script> 25 | 26 | <template> 27 | <component :is="as" v-bind="$attrs" :alt="alt" :data-emoji-id="dataEmojiId" :title="title"> 28 | <slot /> 29 | </component> 30 | </template> 31 | -------------------------------------------------------------------------------- /app/components/list/AccountSearchResult.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { SearchResult } from '~/composables/masto/search' 3 | 4 | defineProps<{ 5 | result: SearchResult 6 | active: boolean 7 | }>() 8 | </script> 9 | 10 | <template> 11 | <CommonScrollIntoView 12 | as="div" 13 | :active="active" 14 | py2 block px2 15 | :aria-selected="active" 16 | :class="{ 'bg-active': active }" 17 | > 18 | <AccountInfo 19 | v-if="result.type === 'account'" 20 | :account="result.data" 21 | /> 22 | </CommonScrollIntoView> 23 | </template> 24 | -------------------------------------------------------------------------------- /app/components/modal/DurationPicker.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const model = defineModel<number>() 3 | const isValid = defineModel<boolean>('isValid') 4 | 5 | const days = ref<number | ''>(0) 6 | const hours = ref<number | ''>(1) 7 | const minutes = ref<number | ''>(0) 8 | 9 | watchEffect(() => { 10 | if (days.value === '' || hours.value === '' || minutes.value === '') { 11 | isValid.value = false 12 | return 13 | } 14 | 15 | const duration 16 | = days.value * 24 * 60 * 60 17 | + hours.value * 60 * 60 18 | + minutes.value * 60 19 | 20 | if (duration <= 0) { 21 | isValid.value = false 22 | return 23 | } 24 | 25 | isValid.value = true 26 | model.value = duration 27 | }) 28 | </script> 29 | 30 | <template> 31 | <div flex flex-grow-0 gap-2> 32 | <label flex items-center gap-2> 33 | <input v-model="days" type="number" min="0" max="1999" input-base :class="!isValid ? 'input-error' : null"> 34 | {{ $t('confirm.mute_account.days', days === '' ? 0 : days) }} 35 | </label> 36 | <label flex items-center gap-2> 37 | <input v-model="hours" type="number" min="0" max="24" input-base :class="!isValid ? 'input-error' : null"> 38 | {{ $t('confirm.mute_account.hours', hours === '' ? 0 : hours) }} 39 | </label> 40 | <label flex items-center gap-2> 41 | <input v-model="minutes" type="number" min="0" max="59" step="5" input-base :class="!isValid ? 'input-error' : null"> 42 | {{ $t('confirm.mute_account.minute', minutes === '' ? 0 : minutes) }} 43 | </label> 44 | </div> 45 | </template> 46 | -------------------------------------------------------------------------------- /app/components/modal/ModalError.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { ErrorDialogData } from '#shared/types' 3 | 4 | defineProps<ErrorDialogData>() 5 | </script> 6 | 7 | <template> 8 | <div flex="~ col" gap-6> 9 | <div font-bold text-lg text-center> 10 | {{ title }} 11 | </div> 12 | <div 13 | flex="~ col" 14 | gap-1 text-sm 15 | pt-1 ps-2 pe-1 pb-2 16 | text-red-600 dark:text-red-400 17 | border="~ base rounded red-600 dark:red-400" 18 | > 19 | <ol ps-2 sm:ps-1> 20 | <li v-for="(message, i) in messages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2> 21 | {{ message }} 22 | </li> 23 | </ol> 24 | </div> 25 | <div flex justify-end gap-2> 26 | <button btn-text @click="closeErrorDialog()"> 27 | {{ close }} 28 | </button> 29 | </div> 30 | </div> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/components/nav/NavUser.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { busy, oauth, singleInstanceServer } = useSignIn() 3 | </script> 4 | 5 | <template> 6 | <VDropdown v-if="isHydrated && currentUser" sm:hidden> 7 | <div style="-webkit-touch-callout: none;"> 8 | <AccountAvatar 9 | :account="currentUser.account" 10 | h-8 11 | w-8 12 | :draggable="false" 13 | square 14 | /> 15 | </div> 16 | 17 | <template #popper="{ hide }"> 18 | <UserSwitcher @click="hide()" /> 19 | </template> 20 | </VDropdown> 21 | <template v-else> 22 | <button 23 | v-if="singleInstanceServer" 24 | flex="~ row" 25 | gap-x-1 items-center justify-center btn-solid text-sm px-2 py-1 xl:hidden 26 | :disabled="busy" 27 | @click="oauth()" 28 | > 29 | <span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip"> 30 | <span block i-ri:loader-2-fill aria-hidden="true" /> 31 | </span> 32 | <span v-else aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" /> 33 | <i18n-t keypath="action.sign_in_to"> 34 | <strong>{{ currentServer }}</strong> 35 | </i18n-t> 36 | </button> 37 | <button 38 | v-else 39 | flex="~ row" 40 | gap-x-1 items-center justify-center btn-solid text-sm px-2 py-1 xl:hidden 41 | @click="openSigninDialog()" 42 | > 43 | <span aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" /> 44 | {{ $t('action.sign_in') }} 45 | </button> 46 | </template> 47 | </template> 48 | -------------------------------------------------------------------------------- /app/components/nav/NavUserSkeleton.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div bg-base h-8 w-8 rounded-full /> 3 | </template> 4 | -------------------------------------------------------------------------------- /app/components/nav/button/Bookmark.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink to="/bookmarks" :aria-label="$t('nav.bookmarks')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 9 | <div i-ri:bookmark-line /> 10 | </NuxtLink> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/nav/button/Compose.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink to="/compose" :aria-label="$t('nav.favourites')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 9 | <div i-ri:quill-pen-line /> 10 | </NuxtLink> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/nav/button/Explore.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants' 3 | 4 | defineProps<{ 5 | activeClass: string 6 | }>() 7 | 8 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '') 9 | </script> 10 | 11 | <template> 12 | <NuxtLink :to="`/${currentServer}/explore/${lastAccessedExploreRoute}`" :aria-label="$t('nav.explore')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 13 | <div i-ri:compass-3-line /> 14 | </NuxtLink> 15 | </template> 16 | -------------------------------------------------------------------------------- /app/components/nav/button/Favorite.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink to="/favourites" :aria-label="$t('nav.favourites')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 9 | <div i-ri:heart-line /> 10 | </NuxtLink> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/nav/button/Federated.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink :to="`/${currentServer}/public`" :aria-label="$t('nav.federated')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 9 | <div i-ri:earth-line /> 10 | </NuxtLink> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/nav/button/Hashtag.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink to="/hashtags" :aria-label="$t('nav.hashtags')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 9 | <div i-ri:hashtag /> 10 | </NuxtLink> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/nav/button/Home.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink to="/home" :aria-label="$t('nav.home')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 9 | <div i-ri:home-5-line /> 10 | </NuxtLink> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/nav/button/List.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink 9 | to="/lists" 10 | :aria-label="$t('nav.lists')" 11 | :active-class="activeClass" 12 | flex flex-row items-center place-content-center h-full flex-1 13 | class="coarse-pointer:select-none" @click="$scrollToTop" 14 | > 15 | <div i-ri:list-check /> 16 | </NuxtLink> 17 | </template> 18 | -------------------------------------------------------------------------------- /app/components/nav/button/Local.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink group :to="`/${currentServer}/public/local`" :aria-label="$t('nav.local')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 9 | <div i-ri:group-2-line /> 10 | </NuxtLink> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/nav/button/Mention.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink 9 | to="/conversations" :aria-label="$t('nav.conversations')" 10 | :active-class="activeClass" flex flex-row items-center place-content-center h-full 11 | flex-1 class="coarse-pointer:select-none" @click="$scrollToTop" 12 | > 13 | <div i-ri:at-line /> 14 | </NuxtLink> 15 | </template> 16 | -------------------------------------------------------------------------------- /app/components/nav/button/MoreMenu.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const model = defineModel<boolean>() 3 | </script> 4 | 5 | <template> 6 | <NavBottomMoreMenu 7 | v-slot="{ toggleVisible, show }" v-model="model!" flex flex-row items-center 8 | place-content-center h-full flex-1 cursor-pointer 9 | > 10 | <button 11 | flex items-center place-content-center h-full flex-1 class="select-none" 12 | :class="show ? '!text-primary' : ''" 13 | :aria-label="$t('nav.more_menu')" 14 | @click="toggleVisible" 15 | > 16 | <span :class="show ? 'i-ri:close-fill' : 'i-ri:more-fill'" /> 17 | </button> 18 | </NavBottomMoreMenu> 19 | </template> 20 | -------------------------------------------------------------------------------- /app/components/nav/button/Notification.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants' 3 | 4 | defineProps<{ 5 | activeClass: string 6 | }>() 7 | 8 | const { notifications } = useNotifications() 9 | const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '') 10 | </script> 11 | 12 | <template> 13 | <NuxtLink :to="`/notifications/${lastAccessedNotificationRoute}`" :aria-label="$t('nav.notifications')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 14 | <div flex relative> 15 | <div class="i-ri:notification-4-line" text-xl /> 16 | <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> 17 | {{ notifications < 10 ? notifications : '•' }} 18 | </div> 19 | </div> 20 | </NuxtLink> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/components/nav/button/Search.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | activeClass: string 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <NuxtLink to="/search" :aria-label="$t('nav.search')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"> 9 | <div i-ri:search-line /> 10 | </NuxtLink> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/publish/PublishCharacterCounter.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | max: number 4 | length: number 5 | }>() 6 | </script> 7 | 8 | <template> 9 | <div dir="ltr" pointer-events-none pe-1 pt-2 text-sm tabular-nums text-secondary flex gap="0.5" :class="{ 'text-rose-500': length > max }"> 10 | {{ length ?? 0 }}<span text-secondary-light>/</span><span text-secondary-light>{{ max }}</span> 11 | </div> 12 | </template> 13 | -------------------------------------------------------------------------------- /app/components/publish/PublishVisibilityPicker.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | editing?: boolean 4 | }>() 5 | 6 | const modelValue = defineModel<string>({ 7 | required: true, 8 | }) 9 | 10 | const currentVisibility = computed(() => 11 | statusVisibilities.find(v => v.value === modelValue.value) || statusVisibilities[0], 12 | ) 13 | 14 | function chooseVisibility(visibility: string) { 15 | modelValue.value = visibility 16 | } 17 | </script> 18 | 19 | <template> 20 | <CommonTooltip placement="top" :content="editing ? $t(`visibility.${currentVisibility.value}`) : $t('tooltip.change_content_visibility')"> 21 | <CommonDropdown placement="bottom"> 22 | <slot :visibility="currentVisibility" /> 23 | <template #popper> 24 | <CommonDropdownItem 25 | v-for="visibility in statusVisibilities" 26 | :key="visibility.value" 27 | :icon="visibility.icon" 28 | :text="$t(`visibility.${visibility.value}`)" 29 | :description="$t(`visibility.${visibility.value}_desc`)" 30 | :checked="visibility.value === modelValue" 31 | @click="chooseVisibility(visibility.value)" 32 | /> 33 | </template> 34 | </CommonDropdown> 35 | </CommonTooltip> 36 | </template> 37 | -------------------------------------------------------------------------------- /app/components/publish/PublishWidgetList.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { DraftItem } from '#shared/types' 3 | import type { mastodon } from 'masto' 4 | 5 | const { 6 | draftKey, 7 | initial = getDefaultDraftItem, 8 | expanded = false, 9 | } = defineProps<{ 10 | draftKey: string 11 | initial?: () => DraftItem 12 | placeholder?: string 13 | inReplyToId?: string 14 | inReplyToVisibility?: mastodon.v1.StatusVisibility 15 | expanded?: boolean 16 | dialogLabelledBy?: string 17 | }>() 18 | 19 | const threadComposer = useThreadComposer(draftKey, initial) 20 | const threadItems = computed(() => threadComposer.threadItems.value) 21 | 22 | onDeactivated(() => { 23 | clearEmptyDrafts() 24 | }) 25 | 26 | function isFirstItem(index: number) { 27 | return index === 0 28 | } 29 | </script> 30 | 31 | <template> 32 | <template v-if="isHydrated && currentUser"> 33 | <PublishWidget 34 | v-for="(_, index) in threadItems" :key="`${draftKey}-${index}`" 35 | v-bind="$attrs" 36 | :thread-composer="threadComposer" 37 | :draft-key="draftKey" 38 | :draft-item-index="index" 39 | :expanded="isFirstItem(index) ? expanded : true" 40 | :placeholder="placeholder" 41 | :dialog-labelled-by="dialogLabelledBy" 42 | :in-reply-to-id="isFirstItem(index) ? inReplyToId : undefined" 43 | :in-reply-to-visibility="inReplyToVisibility" 44 | /> 45 | </template> 46 | </template> 47 | -------------------------------------------------------------------------------- /app/components/pwa/PwaBadge.client.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <button 3 | v-if="useNuxtApp().$pwa?.needRefresh" 4 | bg="primary-fade" relative rounded 5 | flex="~ gap-1 center" px3 py1 text-primary 6 | @click="useNuxtApp().$pwa?.updateServiceWorker()" 7 | > 8 | <div i-ri-download-cloud-2-line /> 9 | <h2 flex="~ gap-2" items-center> 10 | {{ $t('pwa.update_available_short') }} 11 | </h2> 12 | </button> 13 | </template> 14 | -------------------------------------------------------------------------------- /app/components/pwa/PwaInstallPrompt.client.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div 3 | v-if="useNuxtApp().$pwa?.showInstallPrompt && !useNuxtApp().$pwa?.needRefresh" 4 | m-2 p5 bg="primary-fade" relative 5 | rounded-lg of-hidden 6 | flex="~ col gap-3" 7 | v-bind="$attrs" 8 | > 9 | <h2 flex="~ gap-2" items-center> 10 | {{ $t('pwa.install_title') }} 11 | </h2> 12 | <div flex="~ gap-1"> 13 | <button type="button" btn-solid px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.install()"> 14 | {{ $t('pwa.install') }} 15 | </button> 16 | <button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.cancelInstall()"> 17 | {{ $t('pwa.dismiss') }} 18 | </button> 19 | </div> 20 | <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" /> 21 | </div> 22 | </template> 23 | -------------------------------------------------------------------------------- /app/components/pwa/PwaPrompt.client.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div 3 | v-if="useNuxtApp().$pwa?.needRefresh" 4 | m-2 p5 bg="primary-fade" relative 5 | rounded-lg of-hidden 6 | flex="~ col gap-3" 7 | > 8 | <h2 flex="~ gap-2" items-center> 9 | {{ $t('pwa.title') }} 10 | </h2> 11 | <div flex="~ gap-1"> 12 | <button type="button" btn-solid px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.updateServiceWorker()"> 13 | {{ $t('pwa.update') }} 14 | </button> 15 | <button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.close()"> 16 | {{ $t('pwa.dismiss') }} 17 | </button> 18 | </div> 19 | <div i-ri-arrow-down-circle-line absolute text-8em bottom--10 inset-ie--10 text-primary dark:text-white op10 class="-z-1" /> 20 | </div> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/components/search/SearchAccountInfo.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineProps<{ 5 | account: mastodon.v1.Account 6 | }>() 7 | </script> 8 | 9 | <!-- TODO: reuse AccountInfo.vue --> 10 | 11 | <template> 12 | <div flex gap-2 items-center> 13 | <AccountAvatar w-10 h-10 :account="account" shrink-0 /> 14 | <div flex="~ col gap1" shrink h-full overflow-hidden leading-none> 15 | <div flex="~" gap-2> 16 | <AccountDisplayName :account="account" line-clamp-1 ws-pre-wrap break-all text-base /> 17 | <AccountLockIndicator v-if="account.locked" text-xs /> 18 | <AccountBotIndicator v-if="account.bot" text-xs /> 19 | </div> 20 | <AccountHandle text-sm :account="account" text-secondary-light /> 21 | </div> 22 | </div> 23 | </template> 24 | -------------------------------------------------------------------------------- /app/components/search/SearchEmojiInfo.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | export interface SearchEmoji { 3 | title: string 4 | src: string 5 | } 6 | 7 | defineProps<{ 8 | emoji: SearchEmoji 9 | }>() 10 | </script> 11 | 12 | <template> 13 | <div flex="~ gap3" items-center text-base> 14 | <img 15 | width="20" 16 | height="20" 17 | :src="emoji.src" 18 | loading="lazy" 19 | > 20 | <span shrink overflow-hidden leading-none text-base><span text-secondary>:</span>{{ emoji.title }}<span text-secondary>:</span></span> 21 | </div> 22 | </template> 23 | -------------------------------------------------------------------------------- /app/components/search/SearchHashtagInfo.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { hashtag } = defineProps<{ 5 | hashtag: mastodon.v1.Tag 6 | }>() 7 | 8 | const totalTrend = computed(() => 9 | hashtag.history?.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0), 10 | ) 11 | </script> 12 | 13 | <template> 14 | <div flex flex-row items-center gap2 relative> 15 | <div w-10 h-10 flex-none rounded-full bg-active flex place-items-center place-content-center> 16 | <div i-ri:hashtag text-secondary text-lg /> 17 | </div> 18 | <div flex flex-col> 19 | <span> 20 | {{ hashtag.name }} 21 | </span> 22 | <CommonTrending v-if="hashtag.history" :history="hashtag.history" text-xs text-secondary truncate /> 23 | </div> 24 | <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> 25 | <CommonTrendingCharts 26 | :history="hashtag.history" :width="150" :height="20" 27 | text-xs text-secondary h-full w-full 28 | /> 29 | </div> 30 | </div> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/components/search/SearchResult.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { SearchResult } from '~/composables/masto/search' 3 | 4 | defineProps<{ 5 | result: SearchResult 6 | active: boolean 7 | }>() 8 | 9 | function onActivate() { 10 | (document.activeElement as HTMLElement).blur() 11 | } 12 | </script> 13 | 14 | <template> 15 | <CommonScrollIntoView 16 | as="RouterLink" 17 | hover:bg-active 18 | :active="active" 19 | :to="result.to" py2 block px2 20 | :aria-selected="active" 21 | :class="{ 'bg-active': active }" 22 | @click="() => onActivate()" 23 | > 24 | <SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.data" /> 25 | <SearchAccountInfo v-else-if="result.type === 'account'" :account="result.data" /> 26 | <StatusCard v-else-if="result.type === 'status'" :status="result.data" :actions="false" :show-reply-to="false" /> 27 | <!-- <div v-else-if="result.type === 'action'" text-center> 28 | {{ result.action!.label }} 29 | </div> --> 30 | </CommonScrollIntoView> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/components/search/SearchResultSkeleton.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div flex flex-col gap-2 px-4 py-3> 3 | <div flex gap-4> 4 | <div> 5 | <div w-12 h-12 rounded-full class="skeleton-loading-bg" /> 6 | </div> 7 | <div flex="~ col 1 gap-2" pb2 min-w-0> 8 | <div flex class="skeleton-loading-bg" h-5 w-20 rounded /> 9 | <div flex class="skeleton-loading-bg" h-4 w-full rounded /> 10 | </div> 11 | </div> 12 | </div> 13 | </template> 14 | -------------------------------------------------------------------------------- /app/components/settings/SettingsColorMode.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { ColorMode } from '~/composables/settings' 3 | 4 | const colorMode = useColorMode() 5 | 6 | function setColorMode(mode: ColorMode) { 7 | colorMode.preference = mode 8 | } 9 | 10 | const modes = [ 11 | { 12 | icon: 'i-ri-moon-line', 13 | label: 'settings.interface.dark_mode', 14 | mode: 'dark', 15 | }, 16 | { 17 | icon: 'i-ri-sun-line', 18 | label: 'settings.interface.light_mode', 19 | mode: 'light', 20 | }, 21 | { 22 | icon: 'i-ri-computer-line', 23 | label: 'settings.interface.system_mode', 24 | mode: 'system', 25 | }, 26 | ] as const 27 | </script> 28 | 29 | <template> 30 | <section space-y-2> 31 | <h2 id="interface-cm" font-medium> 32 | {{ $t('settings.interface.color_mode') }} 33 | </h2> 34 | <div flex="~ gap4 wrap" w-full role="group" aria-labelledby="interface-cm"> 35 | <button 36 | v-for="{ icon, label, mode } in modes" 37 | :key="mode" 38 | type="button" 39 | btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap 40 | :aria-pressed="colorMode.preference === mode ? 'true' : 'false'" 41 | :class="colorMode.preference === mode ? 'pointer-events-none' : 'filter-saturate-0'" 42 | @click="setColorMode(mode)" 43 | > 44 | <span :class="`${icon}`" /> 45 | {{ $t(label) }} 46 | </button> 47 | </div> 48 | </section> 49 | </template> 50 | -------------------------------------------------------------------------------- /app/components/settings/SettingsLanguage.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { LocaleObject } from '@nuxtjs/i18n' 3 | import type { ComputedRef } from 'vue' 4 | 5 | const userSettings = useUserSettings() 6 | 7 | const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> } 8 | </script> 9 | 10 | <template> 11 | <select v-model="userSettings.language"> 12 | <option v-for="item in locales" :key="item.code" :value="item.code" :selected="userSettings.language === item.code"> 13 | {{ item.name }} 14 | </option> 15 | </select> 16 | </template> 17 | -------------------------------------------------------------------------------- /app/components/status/StatusAccountDetails.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { link = true } = defineProps<{ 5 | account: mastodon.v1.Account 6 | link?: boolean 7 | }>() 8 | 9 | const userSettings = useUserSettings() 10 | </script> 11 | 12 | <template> 13 | <NuxtLink 14 | :to="link ? getAccountRoute(account) : undefined" 15 | flex="~ col" min-w-0 md:flex="~ row gap-2" md:items-center 16 | text-link-rounded 17 | > 18 | <AccountDisplayName :account="account" :hide-emojis="getPreferences(userSettings, 'hideUsernameEmojis')" font-bold line-clamp-1 ws-pre-wrap break-all /> 19 | <AccountHandle :account="account" class="zen-none" /> 20 | </NuxtLink> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/components/status/StatusCardSkeleton.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div flex flex-col gap-2 px-4 py-3> 3 | <div flex gap-4> 4 | <div> 5 | <div w-12 h-12 rounded-full class="skeleton-loading-bg" /> 6 | </div> 7 | <div flex="~ col 1 gap-2" pb2 min-w-0> 8 | <div flex class="skeleton-loading-bg" h-5 w-20 rounded /> 9 | <div flex class="skeleton-loading-bg" h-4 w-full rounded /> 10 | <div flex class="skeleton-loading-bg" h-4 w="4/5" rounded /> 11 | <div flex class="skeleton-loading-bg" h-4 w="2/5" rounded /> 12 | </div> 13 | </div> 14 | </div> 15 | </template> 16 | -------------------------------------------------------------------------------- /app/components/status/StatusLink.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { status } = defineProps<{ 5 | status: mastodon.v1.Status 6 | hover?: boolean 7 | }>() 8 | 9 | const el = ref<HTMLElement>() 10 | const router = useRouter() 11 | const statusRoute = computed(() => getStatusRoute(status)) 12 | 13 | function onclick(evt: MouseEvent | KeyboardEvent) { 14 | const path = evt.composedPath() as HTMLElement[] 15 | const el = path.find(el => ['A', 'BUTTON', 'IMG', 'VIDEO'].includes(el.tagName?.toUpperCase())) 16 | const text = window.getSelection()?.toString() 17 | const isCustomEmoji = el?.parentElement?.classList.contains('custom-emoji') 18 | if ((!el && !text) || isCustomEmoji) 19 | go(evt) 20 | } 21 | 22 | function go(evt: MouseEvent | KeyboardEvent) { 23 | if (evt.metaKey || evt.ctrlKey) { 24 | window.open(statusRoute.value.href) 25 | } 26 | else { 27 | cacheStatus(status) 28 | router.push(statusRoute.value) 29 | } 30 | } 31 | </script> 32 | 33 | <template> 34 | <div 35 | :id="`status-${status.id}`" 36 | ref="el" 37 | relative flex="~ col gap1" 38 | p="b-2 is-3 ie-4" 39 | :class="{ 'hover:bg-active': hover }" 40 | tabindex="0" 41 | focus:outline-none focus-visible:ring="2 primary inset" 42 | aria-roledescription="status-card" 43 | :lang="status.language ?? undefined" 44 | @click="onclick" 45 | @keydown.enter="onclick" 46 | > 47 | <slot /> 48 | </div> 49 | </template> 50 | -------------------------------------------------------------------------------- /app/components/status/StatusMedia.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { status, isPreview = false } = defineProps<{ 5 | status: mastodon.v1.Status | mastodon.v1.StatusEdit 6 | fullSize?: boolean 7 | isPreview?: boolean 8 | }>() 9 | 10 | const gridColumnNumber = computed(() => { 11 | const num = status.mediaAttachments.length 12 | if (num <= 1) 13 | return 1 14 | else if (num <= 4) 15 | return 2 16 | else 17 | return 3 18 | }) 19 | </script> 20 | 21 | <template> 22 | <div class="status-media-container"> 23 | <template v-for="attachment of status.mediaAttachments" :key="attachment.id"> 24 | <StatusAttachment 25 | :attachment="attachment" 26 | :attachments="status.mediaAttachments" 27 | :full-size="fullSize" 28 | w-full 29 | h-full 30 | :is-preview="isPreview" 31 | /> 32 | </template> 33 | </div> 34 | </template> 35 | 36 | <style lang="postcss"> 37 | .status-media-container { 38 | --grid-cols: v-bind(gridColumnNumber); 39 | display: grid; 40 | grid-template-columns: repeat(var(--grid-cols, 1), 1fr); 41 | --at-apply: gap-2; 42 | position: relative; 43 | width: 100%; 44 | overflow: hidden; 45 | } 46 | </style> 47 | -------------------------------------------------------------------------------- /app/components/status/StatusNotFound.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { account, status } = defineProps<{ 3 | account: string 4 | status: string 5 | }>() 6 | 7 | const originalUrl = computed(() => { 8 | const [handle, _server] = account.split('@') 9 | const server = _server || currentUser.value?.server 10 | if (!server) 11 | return null 12 | 13 | return `https://${server}/@${handle}/${status}` 14 | }) 15 | </script> 16 | 17 | <template> 18 | <CommonNotFound> 19 | <div flex="~ col center gap2"> 20 | <div>{{ $t('error.status_not_found') }}</div> 21 | 22 | <NuxtLink v-if="originalUrl" :to="originalUrl" external target="_blank"> 23 | <button btn-solid flex="~ center gap-2" text-sm px2 py1> 24 | <div i-ri:arrow-right-up-line /> 25 | {{ $t('status.try_original_site') }} 26 | </button> 27 | </NuxtLink> 28 | </div> 29 | </CommonNotFound> 30 | </template> 31 | -------------------------------------------------------------------------------- /app/components/status/StatusPreviewCard.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { card } = defineProps<{ 5 | card: mastodon.v1.PreviewCard 6 | /** For the preview image, only the small image mode is displayed */ 7 | smallPictureOnly?: boolean 8 | /** When it is root card in the list, not appear as a child card */ 9 | root?: boolean 10 | }>() 11 | 12 | const providerName = card.providerName 13 | 14 | const gitHubCards = usePreferences('experimentalGitHubCards') 15 | </script> 16 | 17 | <template> 18 | <LazyStatusPreviewGitHub v-if="gitHubCards && providerName === 'GitHub'" :card="card" /> 19 | <LazyStatusPreviewStackBlitz v-else-if="gitHubCards && providerName === 'StackBlitz'" :card="card" :small-picture-only="smallPictureOnly" :root="root" /> 20 | <StatusPreviewCardNormal v-else :card="card" :small-picture-only="smallPictureOnly" :root="root" /> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/components/status/StatusPreviewCardInfo.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineProps<{ 5 | card: mastodon.v1.PreviewCard 6 | /** When it is root card in the list, not appear as a child card */ 7 | root?: boolean 8 | /** For the preview image, only the small image mode is displayed */ 9 | provider?: string 10 | }>() 11 | </script> 12 | 13 | <template> 14 | <div 15 | max-h-2xl 16 | flex flex-col 17 | my-auto 18 | :class="[ 19 | root ? 'flex-gap-1' : 'justify-center sm:justify-start', 20 | ]" 21 | > 22 | <p text-secondary break-all line-clamp-1> 23 | {{ provider }} 24 | </p> 25 | <strong 26 | v-if="card.title" font-normal sm:font-medium line-clamp-1 27 | break-all 28 | >{{ card.title }}</strong> 29 | <p 30 | v-if="card.description" 31 | line-clamp-1 break-all sm:break-words text-secondary :class="[root ? 'sm:line-clamp-2' : '']" 32 | > 33 | {{ card.description }} 34 | </p> 35 | </div> 36 | </template> 37 | -------------------------------------------------------------------------------- /app/components/status/StatusPreviewCardMoreFromAuthor.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineProps<{ 5 | account: mastodon.v1.Account 6 | }>() 7 | </script> 8 | 9 | <template> 10 | <div 11 | max-h-2xl 12 | flex gap-2 13 | my-auto 14 | p-4 py-2 15 | light:bg-gray-3 dark:bg-gray-8 16 | > 17 | <span z-0>More from</span> 18 | <AccountInlineInfo :account="account" hover:bg-inherit ps-0 ms-0 /> 19 | </div> 20 | </template> 21 | -------------------------------------------------------------------------------- /app/components/status/StatusPreviewCardSkeleton.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | /** For the preview image, only the small image mode is displayed */ 4 | square?: boolean 5 | /** When it is root card in the list, not appear as a child card */ 6 | root?: boolean 7 | }>() 8 | </script> 9 | 10 | <template> 11 | <div 12 | of-hidden 13 | :class="{ 14 | 'flex': square, 15 | 'p-4': root, 16 | 'rounded-lg border border-base': !root, 17 | }" 18 | > 19 | <div 20 | flex flex-col 21 | display-block of-hidden 22 | border="base" 23 | :class="{ 24 | 'sm:(min-w-32 w-32 h-32) min-w-22 w-22 h-22 border-r': square, 25 | 'w-full aspect-[1.91] border-b': !square, 26 | 'rounded-lg': root, 27 | }" 28 | > 29 | <div w-full h-full class="skeleton-loading-bg" /> 30 | </div> 31 | <div 32 | px3 max-h-2xl 33 | flex-1 flex flex-col flex-gap-2 sm:flex-gap-3 34 | :class="[ 35 | root ? 'py2.5 sm:py3' : 'py3 justify-center sm:justify-start', 36 | ]" 37 | > 38 | <div flex class="skeleton-loading-bg" h-4 w-30 rounded :class="root ? '' : 'hidden sm:block'" /> 39 | <div flex class="skeleton-loading-bg" h-5 w="4/5" rounded /> 40 | <div flex="~ col gap-2"> 41 | <div flex class="skeleton-loading-bg" h-4 w-full rounded /> 42 | <div sm:flex hidden class="skeleton-loading-bg" h-4 w="2/5" rounded /> 43 | </div> 44 | </div> 45 | </div> 46 | </template> 47 | -------------------------------------------------------------------------------- /app/components/status/StatusTranslation.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { status } = defineProps<{ 5 | status: mastodon.v1.Status 6 | }>() 7 | 8 | const { 9 | toggle: _toggleTranslation, 10 | translation, 11 | enabled: isTranslationEnabled, 12 | } = await useTranslation(status, getLanguageCode()) 13 | const preferenceHideTranslation = usePreferences('hideTranslation') 14 | 15 | const showButton = computed(() => 16 | !preferenceHideTranslation.value 17 | && isTranslationEnabled 18 | && status.content.trim().length, 19 | ) 20 | 21 | const translating = ref(false) 22 | async function toggleTranslation() { 23 | translating.value = true 24 | try { 25 | await _toggleTranslation() 26 | } 27 | finally { 28 | translating.value = false 29 | } 30 | } 31 | </script> 32 | 33 | <template> 34 | <div v-if="showButton"> 35 | <button 36 | p-0 flex="~ center" gap-2 text-sm 37 | :disabled="translating" disabled-bg-transparent btn-text class="disabled-text-$c-text-btn-disabled-deeper" @click="toggleTranslation" 38 | > 39 | <span v-if="translating" block animate-spin preserve-3d> 40 | <span block i-ri:loader-2-fill /> 41 | </span> 42 | <div v-else i-ri:translate /> 43 | {{ translation.visible ? $t('menu.show_untranslated') : $t('menu.translate_post') }} 44 | </button> 45 | </div> 46 | </template> 47 | 48 | <style scoped></style> 49 | -------------------------------------------------------------------------------- /app/components/status/StatusVisibilityIndicator.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { status } = defineProps<{ 5 | status: mastodon.v1.Status 6 | }>() 7 | 8 | const visibility = computed(() => statusVisibilities.find(v => v.value === status.visibility)!) 9 | </script> 10 | 11 | <template> 12 | <CommonTooltip :content="$t(`visibility.${visibility.value}`)" placement="bottom"> 13 | <div :class="visibility.icon" :aria-label="$t(`visibility.${visibility.value}`)" /> 14 | </CommonTooltip> 15 | </template> 16 | -------------------------------------------------------------------------------- /app/components/status/edit/StatusEditHistorySkeleton.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="skeleton-loading-bg" h-5 w-full rounded my2 /> 3 | </template> 4 | -------------------------------------------------------------------------------- /app/components/status/edit/StatusEditIndicator.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { status } = defineProps<{ 5 | status: mastodon.v1.Status 6 | inline: boolean 7 | }>() 8 | 9 | const editedAt = computed(() => status.editedAt) 10 | const formatted = useFormattedDateTime(editedAt) 11 | </script> 12 | 13 | <template> 14 | <template v-if="editedAt"> 15 | <CommonTooltip v-if="inline" :content="$t('status.edited', [formatted])"> 16 |   17 | <time 18 | :title="editedAt" 19 | :datetime="editedAt" 20 | font-bold underline decoration-dashed 21 | text-secondary 22 | > * </time> 23 | </CommonTooltip> 24 | 25 | <CommonDropdown v-else> 26 | <slot /> 27 | 28 | <template #popper> 29 | <div text-sm p2> 30 | <div text-center mb1> 31 | {{ $t('status.edited', [formatted]) }} 32 | </div> 33 | <StatusEditHistory :status="status" /> 34 | </div> 35 | </template> 36 | </CommonDropdown> 37 | </template> 38 | </template> 39 | -------------------------------------------------------------------------------- /app/components/status/edit/StatusEditPreview.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineProps<{ 5 | edit: mastodon.v1.StatusEdit 6 | }>() 7 | </script> 8 | 9 | <template> 10 | <div px3 py-4 flex="~ col"> 11 | <div text-center flex="~ row gap-1 wrap"> 12 | <AccountInlineInfo :account="edit.account" /> 13 | <span> 14 | {{ $t('status_history.edited', [useFormattedDateTime(edit.createdAt).value]) }} 15 | </span> 16 | </div> 17 | 18 | <div h1px bg="gray/20" my2 /> 19 | 20 | <StatusSpoiler :enabled="edit.sensitive"> 21 | <template #spoiler> 22 | {{ edit.spoilerText }} 23 | </template> 24 | <StatusBody :status="edit" /> 25 | <StatusMedia v-if="edit.mediaAttachments.length" :status="edit" /> 26 | </StatusSpoiler> 27 | </div> 28 | </template> 29 | -------------------------------------------------------------------------------- /app/components/tag/TagCardPaginator.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | defineProps<{ 5 | paginator: mastodon.Paginator<mastodon.v1.Tag[], mastodon.DefaultPaginationParams> 6 | }>() 7 | </script> 8 | 9 | <template> 10 | <CommonPaginator :paginator="paginator" key-prop="name"> 11 | <template #default="{ item }"> 12 | <TagCard :tag="item" border="b base" /> 13 | </template> 14 | <template #loading> 15 | <TagCardSkeleton border="b base" /> 16 | <TagCardSkeleton border="b base" /> 17 | <TagCardSkeleton border="b base" op50 /> 18 | <TagCardSkeleton border="b base" op50 /> 19 | <TagCardSkeleton border="b base" op25 /> 20 | </template> 21 | </CommonPaginator> 22 | </template> 23 | -------------------------------------------------------------------------------- /app/components/tag/TagCardSkeleton.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div p4 flex justify-between gap-4> 3 | <div flex="~ col 1 gap-2"> 4 | <div flex class="skeleton-loading-bg" h-5 w-30 rounded /> 5 | <div flex class="skeleton-loading-bg" h-4 w-45 rounded /> 6 | </div> 7 | <div flex items-center> 8 | <div flex class="skeleton-loading-bg" h-9 w-15 rounded /> 9 | </div> 10 | </div> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineBlocks.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const paginator = useMastoClient().v1.blocks.list() 3 | </script> 4 | 5 | <template> 6 | <AccountPaginator :paginator="paginator" /> 7 | </template> 8 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineBookmarks.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const paginator = useMastoClient().v1.bookmarks.list() 3 | </script> 4 | 5 | <template> 6 | <TimelinePaginator end-message="common.no_bookmarks" :paginator="paginator" /> 7 | </template> 8 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineConversations.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const paginator = useMastoClient().v1.conversations.list() 3 | </script> 4 | 5 | <template> 6 | <ConversationPaginator :paginator="paginator" /> 7 | </template> 8 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineDomainBlocks.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { client } = useMasto() 3 | const paginator = client.value.v1.domainBlocks.list() 4 | 5 | async function unblock(domain: string) { 6 | await client.value.v1.domainBlocks.remove({ domain }) 7 | } 8 | </script> 9 | 10 | <template> 11 | <CommonPaginator :paginator="paginator"> 12 | <template #default="{ item }"> 13 | <CommonDropdownItem class="!cursor-auto"> 14 | {{ item }} 15 | <template #actions> 16 | <div i-ri:lock-unlock-line text-primary cursor-pointer @click="unblock(item)" /> 17 | </template> 18 | </CommonDropdownItem> 19 | </template> 20 | </CommonPaginator> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineFavourites.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const paginator = useMastoClient().v1.favourites.list() 3 | </script> 4 | 5 | <template> 6 | <TimelinePaginator end-message="common.no_favourites" :paginator="paginator" /> 7 | </template> 8 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineHome.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const { isSupported, effectiveType } = useNetwork() 5 | const isSlow = computed(() => isSupported.value && effectiveType.value && ['slow-2g', '2g', '3g'].includes(effectiveType.value)) 6 | const limit = computed(() => isSlow.value ? 10 : 30) 7 | 8 | const paginator = useMastoClient().v1.timelines.home.list({ limit: limit.value }) 9 | const stream = useStreaming(client => client.user.subscribe()) 10 | function reorderAndFilter(items: mastodon.v1.Status[]) { 11 | return reorderedTimeline(items, 'home') 12 | } 13 | 14 | let followedTags: mastodon.v1.Tag[] | undefined 15 | if (currentUser.value !== undefined) { 16 | followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 })) 17 | } 18 | </script> 19 | 20 | <template> 21 | <div> 22 | <PublishWidgetList draft-key="home" /> 23 | <div h="1px" w-auto bg-border mb-3 /> 24 | <TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="home" /> 25 | </div> 26 | </template> 27 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineMutes.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const paginator = useMastoClient().v1.mutes.list() 3 | </script> 4 | 5 | <template> 6 | <AccountPaginator :paginator="paginator" /> 7 | </template> 8 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineNotifications.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | import { STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants' 4 | 5 | const { filter } = defineProps<{ 6 | filter?: mastodon.v1.NotificationType 7 | }>() 8 | 9 | const route = useRoute() 10 | const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '') 11 | 12 | const options = { limit: 30, types: filter ? [filter] : [] } 13 | 14 | // Default limit is 20 notifications, and servers are normally caped to 30 15 | const paginator = useMastoClient().v1.notifications.list(options) 16 | const stream = useStreaming(client => client.user.notification.subscribe()) 17 | 18 | lastAccessedNotificationRoute.value = route.path.replace(/\/notifications\/?/, '') 19 | 20 | const { clearNotifications } = useNotifications() 21 | onActivated(() => { 22 | clearNotifications() 23 | lastAccessedNotificationRoute.value = route.path.replace(/\/notifications\/?/, '') 24 | }) 25 | </script> 26 | 27 | <template> 28 | <NotificationPaginator v-bind="{ paginator, stream }" /> 29 | </template> 30 | -------------------------------------------------------------------------------- /app/components/timeline/TimelinePinned.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const paginator = useMastoClient().v1.accounts.$select(currentUser.value!.account.id).statuses.list({ pinned: true }) 3 | </script> 4 | 5 | <template> 6 | <TimelinePaginator :paginator="paginator" /> 7 | </template> 8 | -------------------------------------------------------------------------------- /app/components/timeline/TimelinePublic.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const paginator = useMastoClient().v1.timelines.public.list({ limit: 30 }) 5 | const stream = useStreaming(client => client.public.subscribe()) 6 | function reorderAndFilter(items: mastodon.v1.Status[]) { 7 | return reorderedTimeline(items, 'public') 8 | } 9 | 10 | let followedTags: mastodon.v1.Tag[] | undefined 11 | if (currentUser.value !== undefined) { 12 | followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 })) 13 | } 14 | </script> 15 | 16 | <template> 17 | <div> 18 | <TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="public" /> 19 | </div> 20 | </template> 21 | -------------------------------------------------------------------------------- /app/components/timeline/TimelinePublicLocal.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const paginator = useMastoClient().v1.timelines.public.list({ limit: 30, local: true }) 5 | const stream = useStreaming(client => client.public.local.subscribe()) 6 | function reorderAndFilter(items: mastodon.v1.Status[]) { 7 | return reorderedTimeline(items, 'public') 8 | } 9 | 10 | let followedTags: mastodon.v1.Tag[] | undefined 11 | if (currentUser.value !== undefined) { 12 | followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 })) 13 | } 14 | </script> 15 | 16 | <template> 17 | <div> 18 | <TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="public" /> 19 | </div> 20 | </template> 21 | -------------------------------------------------------------------------------- /app/components/timeline/TimelineSkeleton.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div> 3 | <StatusCardSkeleton border="b base" op50 /> 4 | <StatusCardSkeleton border="b base" op35 /> 5 | <StatusCardSkeleton border="b base" op25 /> 6 | <StatusCardSkeleton border="b base" op10 /> 7 | </div> 8 | </template> 9 | -------------------------------------------------------------------------------- /app/components/tiptap/TiptapCodeBlock.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { NodeViewContent, nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3' 3 | 4 | const { node, updateAttributes } = defineProps(nodeViewProps) 5 | 6 | const languages = [ 7 | 'c', 8 | 'cpp', 9 | 'csharp', 10 | 'css', 11 | 'dart', 12 | 'go', 13 | 'html', 14 | 'java', 15 | 'javascript', 16 | 'jsx', 17 | 'kotlin', 18 | 'python', 19 | 'rust', 20 | 'svelte', 21 | 'swift', 22 | 'tsx', 23 | 'typescript', 24 | 'vue', 25 | ] 26 | 27 | const selectedLanguage = computed({ 28 | get() { 29 | return node.attrs.language 30 | }, 31 | set(language) { 32 | updateAttributes({ language }) 33 | }, 34 | }) 35 | </script> 36 | 37 | <template> 38 | <NodeViewWrapper> 39 | <div relative my2> 40 | <select 41 | v-model="selectedLanguage" 42 | contenteditable="false" 43 | absolute top-1 right-1 rounded px2 op0 hover:op100 focus:op100 transition 44 | outline-none border="~ base" 45 | > 46 | <option :value="null"> 47 | plain 48 | </option> 49 | <option v-for="(language, index) in languages" :key="index" :value="language"> 50 | {{ language }} 51 | </option> 52 | </select> 53 | <pre class="code-block"><code><NodeViewContent /></code></pre> 54 | </div> 55 | </NodeViewWrapper> 56 | </template> 57 | -------------------------------------------------------------------------------- /app/components/user/UserDropdown.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const mask = useMask() 3 | </script> 4 | 5 | <template> 6 | <VDropdown :distance="0" placement="top-start" strategy="fixed" @apply-show="mask.show()" @apply-hide="mask.hide()"> 7 | <button btn-action-icon :aria-label="$t('action.switch_account')"> 8 | <div :class="{ 'hidden xl:block': currentUser }" i-ri:more-2-line /> 9 | <AccountAvatar v-if="currentUser" xl:hidden :account="currentUser.account" w-9 h-9 square /> 10 | </button> 11 | <template #popper="{ hide }"> 12 | <UserSwitcher @click="hide" /> 13 | </template> 14 | </VDropdown> 15 | </template> 16 | -------------------------------------------------------------------------------- /app/components/user/UserPicker.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { UserLogin } from '#shared/types' 3 | 4 | const all = useUsers() 5 | const router = useRouter() 6 | 7 | function clickUser(user: UserLogin) { 8 | if (user.account.acct === currentUser.value?.account.acct) 9 | router.push(getAccountRoute(user.account)) 10 | else 11 | switchUser(user) 12 | } 13 | </script> 14 | 15 | <template> 16 | <div flex justify-start items-end px-2 gap-5> 17 | <div flex="~ wrap-reverse" gap-5> 18 | <template v-for="user of all" :key="user.id"> 19 | <CommonTooltip :distance="8" :delay="{ show: 300, hide: 100 }"> 20 | <button 21 | flex rounded 22 | cursor-pointer 23 | :aria-label="$t('action.switch_account')" 24 | :class="user.account.acct === currentUser?.account.acct ? '' : 'op25 grayscale'" 25 | hover="filter-none op100" 26 | @click="clickUser(user)" 27 | > 28 | <AccountAvatar w-13 h-13 :account="user.account" square /> 29 | </button> 30 | 31 | <template #popper> 32 | <div text-center> 33 | <span text-4> 34 | <AccountDisplayName :account="user.account" /> 35 | </span> 36 | <AccountHandle :account="user.account" /> 37 | </div> 38 | </template> 39 | </CommonTooltip> 40 | </template> 41 | </div> 42 | <div flex items-center justify-center w-13 h-13> 43 | <UserDropdown /> 44 | </div> 45 | </div> 46 | </template> 47 | -------------------------------------------------------------------------------- /app/components/user/UserSignInEntry.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { busy, oauth, singleInstanceServer } = useSignIn() 3 | </script> 4 | 5 | <template> 6 | <div p8 lg:flex="~ col gap2" hidden> 7 | <p v-if="isHydrated" text-sm> 8 | <i18n-t keypath="user.sign_in_notice_title"> 9 | <strong>{{ currentServer }}</strong> 10 | </i18n-t> 11 | </p> 12 | <p text-sm text-secondary> 13 | {{ $t(singleInstanceServer ? 'user.single_instance_sign_in_desc' : 'user.sign_in_desc') }} 14 | </p> 15 | <button 16 | v-if="singleInstanceServer" 17 | flex="~ row" gap-x-2 items-center justify-center btn-solid text-center rounded-3 18 | :disabled="busy" 19 | @click="oauth()" 20 | > 21 | <span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip"> 22 | <span block i-ri:loader-2-fill aria-hidden="true" /> 23 | </span> 24 | <span v-else aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" /> 25 | {{ $t('action.sign_in') }} 26 | </button> 27 | <button v-else btn-solid rounded-3 text-center mt-2 select-none @click="openSigninDialog()"> 28 | {{ $t('action.sign_in') }} 29 | </button> 30 | </div> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/composables/aria.ts: -------------------------------------------------------------------------------- 1 | export type AriaLive = 'off' | 'polite' | 'assertive' 2 | export type AriaAnnounceType = 'announce' | 'mute' | 'unmute' 3 | 4 | const ariaAnnouncer = useEventBus<AriaAnnounceType, string | undefined>(Symbol('aria-announcer')) 5 | 6 | export function useAriaAnnouncer() { 7 | const announce = (message: string) => { 8 | ariaAnnouncer.emit('announce', message) 9 | } 10 | 11 | const mute = () => { 12 | ariaAnnouncer.emit('mute') 13 | } 14 | 15 | const unmute = () => { 16 | ariaAnnouncer.emit('unmute') 17 | } 18 | 19 | return { announce, ariaAnnouncer, mute, unmute } 20 | } 21 | 22 | export function useAriaLog() { 23 | const logs = ref<any[]>([]) 24 | 25 | const announceLogs = (messages: any[]) => { 26 | logs.value = messages 27 | } 28 | 29 | const appendLogs = (messages: any[]) => { 30 | logs.value = logs.value.concat(messages) 31 | } 32 | 33 | const clearLogs = () => { 34 | logs.value = [] 35 | } 36 | 37 | return { 38 | announceLogs, 39 | appendLogs, 40 | clearLogs, 41 | logs, 42 | } 43 | } 44 | 45 | export function useAriaStatus() { 46 | const status = ref<any>('') 47 | 48 | const announceStatus = (message: any) => { 49 | status.value = message 50 | } 51 | 52 | const clearStatus = () => { 53 | status.value = '' 54 | } 55 | 56 | return { 57 | announceStatus, 58 | clearStatus, 59 | status, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/composables/injections.ts: -------------------------------------------------------------------------------- 1 | import { InjectionKeyDropdownContext } from '~/constants/symbols' 2 | 3 | export function useDropdownContext() { 4 | return inject(InjectionKeyDropdownContext, undefined) 5 | } 6 | -------------------------------------------------------------------------------- /app/composables/langugage.ts: -------------------------------------------------------------------------------- 1 | import ISO6391 from 'iso-639-1' 2 | 3 | export const languagesNameList: { 4 | code: string 5 | nativeName: string 6 | name: string 7 | }[] = ISO6391.getAllCodes().map(code => ({ 8 | code, 9 | nativeName: ISO6391.getNativeName(code), 10 | name: ISO6391.getName(code), 11 | })) 12 | -------------------------------------------------------------------------------- /app/composables/magickeys.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef } from 'vue' 2 | 3 | // TODO: consider to allow combinations similar to useMagicKeys using proxy? 4 | // e.g. `const magicSequence = useMagicSequence()` 5 | // `magicSequence['Shift+Ctrl+A']` 6 | // `const { Ctrl_A_B } = useMagicSequence()` 7 | 8 | /** 9 | * source: inspired by https://github.com/vueuse/vueuse/issues/427#issuecomment-815619446 10 | * @param keys ordered list of keys making up the sequence 11 | */ 12 | export function useMagicSequence(keys: string[]): ComputedRef<boolean> { 13 | const magicKeys = useMagicKeys() 14 | 15 | const success = ref(false) 16 | const i = ref(0) 17 | let down = false 18 | 19 | watch( 20 | () => magicKeys.current, 21 | () => { 22 | if (magicKeys[keys[i.value]].value && !down) { 23 | down = true 24 | i.value += 1 25 | } 26 | else if (i.value > 0 && !magicKeys[keys[i.value - 1]].value && down) { 27 | down = false 28 | } 29 | else { 30 | i.value = 0 31 | down = false 32 | success.value = false 33 | } 34 | if (i.value >= keys.length && !down) { 35 | i.value = 0 36 | down = false 37 | success.value = true 38 | } 39 | }, 40 | { 41 | deep: true, 42 | }, 43 | ) 44 | 45 | return computed(() => success.value) 46 | } 47 | -------------------------------------------------------------------------------- /app/composables/mask.ts: -------------------------------------------------------------------------------- 1 | import { h, render } from 'vue' 2 | import CommonMask from '~/components/common/CommonMask.vue' 3 | 4 | export interface UseMaskOptions { 5 | getContainer?: () => HTMLElement 6 | background?: string 7 | zIndex?: number 8 | } 9 | 10 | export function useMask(options: UseMaskOptions = {}) { 11 | const { 12 | background = 'transparent', 13 | getContainer = () => document.body, 14 | zIndex = 100, 15 | } = options 16 | const wrapperEl = (import.meta.server ? null : document.createElement('div')) as HTMLDivElement 17 | 18 | function show() { 19 | const container = getContainer() 20 | container?.appendChild(wrapperEl) 21 | const MaskComp = h(CommonMask, { background, zIndex }) 22 | render(MaskComp, wrapperEl) 23 | } 24 | 25 | function hide() { 26 | render(null, wrapperEl) 27 | wrapperEl.parentNode?.removeChild(wrapperEl) 28 | } 29 | 30 | return { 31 | show, 32 | hide, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/composables/misc.ts: -------------------------------------------------------------------------------- 1 | import type { mastodon } from 'masto' 2 | 3 | export const UserLinkRE = /^(?:https:\/)?\/([^/]+)\/@([^/]+)$/ 4 | export const TagLinkRE = /^https?:\/\/([^/]+)\/tags\/([^/]+)\/?$/ 5 | export const HTMLTagRE = /<[^>]+>/g 6 | 7 | export function getDataUrlFromArr(arr: Uint8ClampedArray, w: number, h: number) { 8 | if (typeof w === 'undefined' || typeof h === 'undefined') 9 | w = h = Math.sqrt(arr.length / 4) 10 | 11 | const canvas = document.createElement('canvas') 12 | const ctx = canvas.getContext('2d')! 13 | 14 | canvas.width = w 15 | canvas.height = h 16 | 17 | const imgData = ctx.createImageData(w, h) 18 | imgData.data.set(arr) 19 | ctx.putImageData(imgData, 0, 0) 20 | 21 | return canvas.toDataURL() 22 | } 23 | 24 | export function emojisArrayToObject(emojis: mastodon.v1.CustomEmoji[]) { 25 | return Object.fromEntries(emojis.map(i => [i.shortcode, i])) 26 | } 27 | 28 | export function noop() {} 29 | 30 | export function useIsMac() { 31 | const headers = useRequestHeaders(['user-agent']) 32 | return computed(() => headers['user-agent']?.includes('Macintosh') 33 | ?? navigator?.userAgent?.includes('Mac') ?? false) 34 | } 35 | 36 | export function isEmptyObject(object: object) { 37 | return Object.keys(object).length === 0 38 | } 39 | 40 | export function removeHTMLTags(str: string) { 41 | return str.replaceAll(HTMLTagRE, '') 42 | } 43 | -------------------------------------------------------------------------------- /app/composables/notification.ts: -------------------------------------------------------------------------------- 1 | import type { mastodon } from 'masto' 2 | import { NOTIFICATION_FILTER_TYPES } from '~/constants' 3 | 4 | /** 5 | * Typeguard to check if an object is a valid notification filter 6 | * @param obj the object to be checked 7 | * @returns boolean and assigns type to object if true 8 | */ 9 | export function isNotificationFilter(obj: unknown): obj is mastodon.v1.NotificationType { 10 | return !!obj && NOTIFICATION_FILTER_TYPES.includes(obj as unknown as mastodon.v1.NotificationType) 11 | } 12 | 13 | /** 14 | * Typeguard to check if an object is a valid notification 15 | * @param obj the object to be checked 16 | * @returns boolean and assigns type to object if true 17 | */ 18 | export function isNotification(obj: unknown): obj is mastodon.v1.NotificationType { 19 | return !!obj && ['mention', ...NOTIFICATION_FILTER_TYPES].includes(obj as unknown as mastodon.v1.NotificationType) 20 | } 21 | -------------------------------------------------------------------------------- /app/composables/push-notifications/types.ts: -------------------------------------------------------------------------------- 1 | import type { UserLogin } from '#shared/types' 2 | 3 | import type { mastodon } from 'masto' 4 | 5 | export type SubscriptionResult = 'subscribed' | 'notification-denied' | 'not-supported' | 'invalid-vapid-key' | 'no-user' 6 | export interface PushManagerSubscriptionInfo { 7 | registration: ServiceWorkerRegistration 8 | subscription: PushSubscription | null 9 | } 10 | 11 | export interface RequiredUserLogin extends Required<Omit<UserLogin, 'account' | 'pushSubscription'>> { 12 | pushSubscription?: mastodon.v1.WebPushSubscription 13 | } 14 | 15 | export interface CreatePushNotification { 16 | alerts?: Partial<mastodon.v1.WebPushSubscriptionAlerts> | null 17 | policy?: mastodon.v1.WebPushSubscriptionPolicy 18 | } 19 | 20 | export type PushNotificationRequest = Record<string, boolean> 21 | export type PushNotificationPolicy = Record<string, mastodon.v1.WebPushSubscriptionPolicy> 22 | 23 | export interface CustomEmojisInfo { 24 | lastUpdate: number 25 | emojis: mastodon.v1.CustomEmoji[] 26 | } 27 | 28 | export type PushSubscriptionErrorCode = 'too_many_registrations' | 'vapid_not_supported' | 'invalid_vapid_key' 29 | 30 | export class PushSubscriptionError extends Error { 31 | code: PushSubscriptionErrorCode 32 | constructor(code: PushSubscriptionErrorCode, message?: string) { 33 | super(message) 34 | this.code = code 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/composables/screen.ts: -------------------------------------------------------------------------------- 1 | import { breakpointsTailwind } from '@vueuse/core' 2 | 3 | export const breakpoints = useBreakpoints(breakpointsTailwind) 4 | 5 | export const isSmallScreen = breakpoints.smallerOrEqual('sm') 6 | export const isMediumOrLargeScreen = breakpoints.between('sm', 'xl') 7 | export const isExtraLargeScreen = breakpoints.smallerOrEqual('xl') 8 | -------------------------------------------------------------------------------- /app/composables/settings/index.ts: -------------------------------------------------------------------------------- 1 | export * from './definition' 2 | export * from './storage' 3 | -------------------------------------------------------------------------------- /app/composables/settings/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'ultrahtml' 2 | import { decode } from 'tiny-decode' 3 | import { parse, TEXT_NODE } from 'ultrahtml' 4 | 5 | export const maxAccountFieldCount = computed(() => isGlitchEdition.value ? 16 : 4) 6 | 7 | export function convertMetadata(metadata: string) { 8 | try { 9 | const tree = parse(metadata) 10 | return (tree.children as Node[]).map(n => convertToText(n)).join('').trim() 11 | } 12 | catch (err) { 13 | console.error(err) 14 | return '' 15 | } 16 | } 17 | 18 | function convertToText(input: Node): string { 19 | let text = '' 20 | 21 | if (input.type === TEXT_NODE) 22 | return decode(input.value) 23 | 24 | if ('children' in input) 25 | text = (input.children as Node[]).map(n => convertToText(n)).join('') 26 | 27 | return text 28 | } 29 | -------------------------------------------------------------------------------- /app/composables/tiptap/shiki-parser.ts: -------------------------------------------------------------------------------- 1 | import type { Parser } from 'prosemirror-highlight/shiki' 2 | import type { BuiltinLanguage } from 'shiki' 3 | import { createParser } from 'prosemirror-highlight/shiki' 4 | 5 | let parser: Parser | undefined 6 | 7 | export const shikiParser: Parser = (options) => { 8 | const lang = options.language ?? 'text' 9 | 10 | // Register the language if it's not yet registered 11 | const { highlighter, promise } = useHighlighter(lang as BuiltinLanguage) 12 | 13 | // If the highlighter or the language is not available, return a promise that 14 | // will resolve when it's ready. When the promise resolves, the editor will 15 | // re-parse the code block. 16 | if (!highlighter) 17 | return promise ?? [] 18 | 19 | if (!parser) 20 | parser = createParser(highlighter) 21 | 22 | return parser(options) 23 | } 24 | -------------------------------------------------------------------------------- /app/composables/tiptap/shiki.ts: -------------------------------------------------------------------------------- 1 | import CodeBlock from '@tiptap/extension-code-block' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | 4 | import { createHighlightPlugin } from 'prosemirror-highlight' 5 | import TiptapCodeBlock from '~/components/tiptap/TiptapCodeBlock.vue' 6 | import { shikiParser } from './shiki-parser' 7 | 8 | export const TiptapPluginCodeBlockShiki = CodeBlock.extend({ 9 | addOptions() { 10 | return { 11 | ...this.parent?.(), 12 | defaultLanguage: null, 13 | } 14 | }, 15 | 16 | addProseMirrorPlugins() { 17 | return [ 18 | createHighlightPlugin({ parser: shikiParser, nodeTypes: ['codeBlock'] }), 19 | ] 20 | }, 21 | 22 | addNodeView() { 23 | return VueNodeViewRenderer(TiptapCodeBlock) 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /app/composables/web-share-target.ts: -------------------------------------------------------------------------------- 1 | export function useWebShareTarget(listener?: (message: MessageEvent) => void) { 2 | if (import.meta.server) 3 | return 4 | 5 | onBeforeMount(() => { 6 | // PWA must be installed to use share target 7 | if (useNuxtApp().$pwa?.isInstalled && 'serviceWorker' in navigator) { 8 | if (listener) 9 | navigator.serviceWorker.addEventListener('message', listener) 10 | 11 | navigator.serviceWorker.getRegistration() 12 | .then((registration) => { 13 | if (registration && registration.active) { 14 | // we need to signal the service worker that we are ready to receive data 15 | registration.active.postMessage({ action: 'ready-to-receive' }) 16 | } 17 | }) 18 | .catch(err => console.error('Could not get registration', err)) 19 | 20 | if (listener) 21 | onBeforeUnmount(() => navigator.serviceWorker.removeEventListener('message', listener)) 22 | } 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /app/constants/options.ts: -------------------------------------------------------------------------------- 1 | export const oldFontSizeMap = { 2 | xs: '13px', 3 | sm: '14px', 4 | md: '15px', 5 | lg: '16px', 6 | xl: '17px', 7 | } 8 | -------------------------------------------------------------------------------- /app/constants/symbols.ts: -------------------------------------------------------------------------------- 1 | import type { InjectionKey } from 'vue' 2 | 3 | export const InjectionKeyDropdownContext: InjectionKey<{ 4 | hide: () => void 5 | }> = Symbol('dropdown-context') 6 | -------------------------------------------------------------------------------- /app/layouts/none.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <slot /> 3 | </template> 4 | -------------------------------------------------------------------------------- /app/middleware/2.single-instance.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to) => { 2 | if (import.meta.server || !useRuntimeConfig().public.singleInstance) 3 | return 4 | 5 | if (to.params.server) { 6 | const newTo = { ...to } 7 | delete newTo.params.server 8 | return newTo 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /app/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import type { RouteLocationNormalized } from 'vue-router' 2 | 3 | export default defineNuxtRouteMiddleware((to) => { 4 | if (import.meta.server) 5 | return 6 | 7 | if (to.path === '/signin/callback') 8 | return 9 | 10 | if (isHydrated.value) 11 | return handleAuth(to) 12 | 13 | onHydrated(() => handleAuth(to)) 14 | }) 15 | 16 | function handleAuth(to: RouteLocationNormalized) { 17 | if (to.path === '/') { 18 | // Installed PWA shortcut to notifications 19 | if (to.query['notifications-pwa-shortcut'] !== undefined) { 20 | if (currentUser.value) 21 | return navigateTo('/notifications') 22 | else 23 | return navigateTo(`/${currentServer.value}/public/local`) 24 | } 25 | 26 | // Installed PWA shortcut to local 27 | if (to.query['local-pwa-shortcut'] !== undefined) 28 | return navigateTo(`/${currentServer.value}/public/local`) 29 | } 30 | 31 | if (!currentUser.value) { 32 | if (to.path === '/home' && to.query['share-target'] !== undefined) 33 | return navigateTo('/share-target') 34 | else 35 | return navigateTo(`/${currentServer.value}/public/local`) 36 | } 37 | 38 | if (to.path === '/') 39 | return navigateTo('/home') 40 | } 41 | -------------------------------------------------------------------------------- /app/pages/[...permalink].vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { hasProtocol, parseURL } from 'ufo' 3 | 4 | definePageMeta({ 5 | middleware: async (to) => { 6 | const permalink = Array.isArray(to.params.permalink) 7 | ? to.params.permalink.join('/') 8 | : to.params.permalink 9 | 10 | if (hasProtocol(permalink)) { 11 | const { host, pathname } = parseURL(permalink) 12 | 13 | if (host) 14 | return `/${host}${pathname}` 15 | } 16 | 17 | // We've reached a page that doesn't exist 18 | return false 19 | }, 20 | }) 21 | </script> 22 | 23 | <template> 24 | <div /> 25 | </template> 26 | -------------------------------------------------------------------------------- /app/pages/[[server]]/@[account]/index/followers.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { t } = useI18n() 3 | const params = useRoute().params 4 | const handle = computed(() => params.account as string) 5 | 6 | definePageMeta({ name: 'account-followers' }) 7 | 8 | const account = await fetchAccountByHandle(handle.value) 9 | const paginator = account ? useMastoClient().v1.accounts.$select(account.id).followers.list() : null 10 | 11 | const isSelf = useSelfAccount(account) 12 | 13 | if (account) { 14 | useHydratedHead({ 15 | title: () => `${t('account.followers')} | ${getDisplayName(account)} (@${account.acct})`, 16 | }) 17 | } 18 | </script> 19 | 20 | <template> 21 | <template v-if="paginator"> 22 | <AccountPaginator :paginator="paginator" :relationship-context="isSelf ? 'followedBy' : undefined" context="followers" :account="account" /> 23 | </template> 24 | </template> 25 | -------------------------------------------------------------------------------- /app/pages/[[server]]/@[account]/index/following.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { t } = useI18n() 3 | const params = useRoute().params 4 | const handle = computed(() => params.account as string) 5 | 6 | definePageMeta({ name: 'account-following' }) 7 | 8 | const account = await fetchAccountByHandle(handle.value) 9 | const paginator = account ? useMastoClient().v1.accounts.$select(account.id).following.list() : null 10 | 11 | const isSelf = useSelfAccount(account) 12 | 13 | if (account) { 14 | useHydratedHead({ 15 | title: () => `${t('account.following')} | ${getDisplayName(account)} (@${account.acct})`, 16 | }) 17 | } 18 | </script> 19 | 20 | <template> 21 | <template v-if="paginator"> 22 | <AccountPaginator :paginator="paginator" :relationship-context="isSelf ? 'following' : undefined" context="following" :account="account" /> 23 | </template> 24 | </template> 25 | -------------------------------------------------------------------------------- /app/pages/[[server]]/@[account]/index/media.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ name: 'account-media' }) 3 | 4 | const { t } = useI18n() 5 | const params = useRoute().params 6 | const handle = computed(() => params.account as string) 7 | 8 | const account = await fetchAccountByHandle(handle.value) 9 | 10 | const paginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ onlyMedia: true, excludeReplies: false }) 11 | 12 | if (account) { 13 | useHydratedHead({ 14 | title: () => `${t('tab.media')} | ${getDisplayName(account)} (@${account.acct})`, 15 | }) 16 | } 17 | </script> 18 | 19 | <template> 20 | <div> 21 | <AccountTabs /> 22 | <TimelinePaginator :paginator="paginator" :preprocess="reorderedTimeline" context="account" :account="account" /> 23 | </div> 24 | </template> 25 | -------------------------------------------------------------------------------- /app/pages/[[server]]/@[account]/index/with_replies.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ name: 'account-replies' }) 3 | 4 | const { t } = useI18n() 5 | const params = useRoute().params 6 | const handle = computed(() => params.account as string) 7 | 8 | const account = await fetchAccountByHandle(handle.value) 9 | 10 | const paginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ excludeReplies: false }) 11 | 12 | if (account) { 13 | useHydratedHead({ 14 | title: () => `${t('tab.posts_with_replies')} | ${getDisplayName(account)} (@${account.acct})`, 15 | }) 16 | } 17 | </script> 18 | 19 | <template> 20 | <div> 21 | <AccountTabs /> 22 | <TimelinePaginator :paginator="paginator" :preprocess="reorderedTimeline" context="account" :account="account" /> 23 | </div> 24 | </template> 25 | -------------------------------------------------------------------------------- /app/pages/[[server]]/explore/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants' 3 | 4 | const { t } = useI18n() 5 | const route = useRoute() 6 | 7 | const paginator = useMastoClient().v1.trends.statuses.list() 8 | 9 | const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, false) 10 | 11 | useHydratedHead({ 12 | title: () => `${t('tab.posts')} | ${t('nav.explore')}`, 13 | }) 14 | 15 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '') 16 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '') 17 | 18 | onActivated(() => { 19 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '') 20 | }) 21 | </script> 22 | 23 | <template> 24 | <CommonAlert v-if="isHydrated && !hideNewsTips" @close="hideNewsTips = true"> 25 | <p>{{ $t('tooltip.explore_posts_intro') }}</p> 26 | </CommonAlert> 27 | <!-- TODO: Tabs for trending statuses, tags, and links --> 28 | <TimelinePaginator v-if="isHydrated" :paginator="paginator" context="public" /> 29 | </template> 30 | -------------------------------------------------------------------------------- /app/pages/[[server]]/explore/links.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants' 3 | 4 | const { t } = useI18n() 5 | const route = useRoute() 6 | 7 | const paginator = useMastoClient().v1.trends.links.list() 8 | 9 | const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS, false) 10 | 11 | useHydratedHead({ 12 | title: () => `${t('tab.news')} | ${t('nav.explore')}`, 13 | }) 14 | 15 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '') 16 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '') 17 | 18 | onActivated(() => { 19 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '') 20 | }) 21 | </script> 22 | 23 | <template> 24 | <CommonAlert v-if="isHydrated && !hideNewsTips" @close="hideNewsTips = true"> 25 | <p>{{ $t('tooltip.explore_links_intro') }}</p> 26 | </CommonAlert> 27 | 28 | <CommonPaginator v-bind="{ paginator }"> 29 | <template #default="{ item }"> 30 | <StatusPreviewCard :card="item" border="!b base" rounded="!none" p="!4" small-picture-only root /> 31 | </template> 32 | <template #loading> 33 | <StatusPreviewCardSkeleton square root border="b base" /> 34 | <StatusPreviewCardSkeleton square root border="b base" op50 /> 35 | <StatusPreviewCardSkeleton square root border="b base" op25 /> 36 | </template> 37 | </CommonPaginator> 38 | </template> 39 | -------------------------------------------------------------------------------- /app/pages/[[server]]/explore/tags.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants' 3 | 4 | const { t } = useI18n() 5 | const route = useRoute() 6 | const { client } = useMasto() 7 | 8 | const paginator = client.value.v1.trends.tags.list({ 9 | limit: 20, 10 | }) 11 | 12 | const hideTagsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, false) 13 | 14 | useHydratedHead({ 15 | title: () => `${t('tab.hashtags')} | ${t('nav.explore')}`, 16 | }) 17 | 18 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '') 19 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '') 20 | 21 | onActivated(() => { 22 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '') 23 | }) 24 | </script> 25 | 26 | <template> 27 | <CommonAlert v-if="!hideTagsTips" @close="hideTagsTips = true"> 28 | <p>{{ $t('tooltip.explore_tags_intro') }}</p> 29 | </CommonAlert> 30 | 31 | <TagCardPaginator v-bind="{ paginator }" /> 32 | </template> 33 | -------------------------------------------------------------------------------- /app/pages/[[server]]/explore/users.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants' 3 | 4 | const { t } = useI18n() 5 | const route = useRoute() 6 | 7 | // limit: 20 is the default configuration of the official client 8 | const paginator = useMastoClient().v2.suggestions.list({ limit: 20 }) 9 | 10 | useHydratedHead({ 11 | title: () => `${t('tab.for_you')} | ${t('nav.explore')}`, 12 | }) 13 | 14 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '') 15 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '') 16 | 17 | onActivated(() => { 18 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '') 19 | }) 20 | </script> 21 | 22 | <template> 23 | <CommonPaginator :paginator="paginator" key-prop="account"> 24 | <template #default="{ item }"> 25 | <AccountBigCard 26 | :account="item.account" 27 | as="router-link" 28 | :to="getAccountRoute(item.account)" 29 | border="b base" 30 | /> 31 | </template> 32 | <template #loading> 33 | <AccountBigCardSkeleton border="b base" /> 34 | <AccountBigCardSkeleton border="b base" op50 /> 35 | <AccountBigCardSkeleton border="b base" op25 /> 36 | </template> 37 | </CommonPaginator> 38 | </template> 39 | -------------------------------------------------------------------------------- /app/pages/[[server]]/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const instance = instanceStorage.value[currentServer.value] 3 | try { 4 | clearError({ redirect: currentUser.value ? '/home' : `/${currentServer.value}/public/local` }) 5 | } 6 | catch (err) { 7 | console.error(err) 8 | } 9 | </script> 10 | 11 | <template> 12 | <MainContent text-base grid gap-3 m3> 13 | <img rounded-3 :src="instance.thumbnail.url"> 14 | </MainContent> 15 | </template> 16 | -------------------------------------------------------------------------------- /app/pages/[[server]]/list/[list]/index/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | name: 'list', 4 | }) 5 | 6 | const params = useRoute().params 7 | const listId = computed(() => params.list as string) 8 | 9 | const client = useMastoClient() 10 | 11 | const paginator = client.v1.timelines.list.$select(listId.value).list() 12 | const stream = useStreaming(client => client.list.subscribe({ list: listId.value })) 13 | </script> 14 | 15 | <template> 16 | <TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderedTimeline" context="home" /> 17 | </template> 18 | -------------------------------------------------------------------------------- /app/pages/[[server]]/lists.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { t } = useI18n() 3 | 4 | useHydratedHead({ 5 | title: () => t('nav.lists'), 6 | }) 7 | </script> 8 | 9 | <template> 10 | <MainContent> 11 | <template #title> 12 | <NuxtLink to="/lists" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 13 | <div i-ri:list-check /> 14 | <span text-lg font-bold>{{ t('nav.lists') }}</span> 15 | </NuxtLink> 16 | </template> 17 | <NuxtPage v-if="isHydrated" /> 18 | </MainContent> 19 | </template> 20 | -------------------------------------------------------------------------------- /app/pages/[[server]]/public/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { t } = useI18n() 3 | 4 | useHydratedHead({ 5 | title: () => t('title.federated_timeline'), 6 | }) 7 | </script> 8 | 9 | <template> 10 | <MainContent> 11 | <template #title> 12 | <NuxtLink to="/public" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 13 | <div i-ri:earth-line /> 14 | <span>{{ $t('title.federated_timeline') }}</span> 15 | </NuxtLink> 16 | </template> 17 | 18 | <TimelinePublic v-if="isHydrated" /> 19 | </MainContent> 20 | </template> 21 | -------------------------------------------------------------------------------- /app/pages/[[server]]/public/local.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { t } = useI18n() 3 | 4 | useHydratedHead({ 5 | title: () => t('title.local_timeline'), 6 | }) 7 | </script> 8 | 9 | <template> 10 | <MainContent> 11 | <template #title> 12 | <NuxtLink to="/public/local" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 13 | <div i-ri:group-2-line /> 14 | <span>{{ t('title.local_timeline') }}</span> 15 | </NuxtLink> 16 | </template> 17 | 18 | <TimelinePublicLocal v-if="isHydrated" /> 19 | </MainContent> 20 | </template> 21 | -------------------------------------------------------------------------------- /app/pages/[[server]]/search.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const keys = useMagicKeys() 3 | const { t } = useI18n() 4 | 5 | useHydratedHead({ 6 | title: () => t('nav.search'), 7 | }) 8 | 9 | const search = ref<{ input?: HTMLInputElement }>() 10 | 11 | watchEffect(() => { 12 | if (search.value?.input) 13 | search.value?.input?.focus() 14 | }) 15 | onActivated(() => search.value?.input?.focus()) 16 | onDeactivated(() => search.value?.input?.blur()) 17 | 18 | watch(keys['/'], (v) => { 19 | // focus on input when '/' is up to avoid '/' being typed 20 | if (!v) 21 | search.value?.input?.focus() 22 | }) 23 | </script> 24 | 25 | <template> 26 | <MainContent> 27 | <template #title> 28 | <NuxtLink to="/search" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 29 | <div i-ri:search-line class="rtl-flip" /> 30 | <span>{{ $t('nav.search') }}</span> 31 | </NuxtLink> 32 | </template> 33 | 34 | <div px2 mt3> 35 | <SearchWidget v-if="isHydrated" ref="search" m-1 /> 36 | </div> 37 | </MainContent> 38 | </template> 39 | -------------------------------------------------------------------------------- /app/pages/[[server]]/status/[status].vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | name: 'status-by-id', 4 | middleware: async (to) => { 5 | const params = to.params 6 | const id = params.status as string 7 | const status = await fetchStatus(id) 8 | return getStatusRoute(status) 9 | }, 10 | }) 11 | </script> 12 | 13 | <template> 14 | <div /> 15 | </template> 16 | -------------------------------------------------------------------------------- /app/pages/blocks.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => t('nav.blocked_users'), 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent back> 15 | <template #title> 16 | <span timeline-title-style>{{ $t('nav.blocked_users') }}</span> 17 | </template> 18 | 19 | <TimelineBlocks v-if="isHydrated" /> 20 | </MainContent> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/pages/bookmarks.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => t('nav.bookmarks'), 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent> 15 | <template #title> 16 | <NuxtLink to="/bookmarks" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 17 | <div i-ri:bookmark-line /> 18 | <span>{{ t('nav.bookmarks') }}</span> 19 | </NuxtLink> 20 | </template> 21 | 22 | <TimelineBookmarks v-if="isHydrated" /> 23 | </MainContent> 24 | </template> 25 | -------------------------------------------------------------------------------- /app/pages/compose.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => t('nav.compose'), 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent> 15 | <template #title> 16 | <NuxtLink to="/compose" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 17 | <div i-ri:quill-pen-line /> 18 | <span>{{ $t('nav.compose') }}</span> 19 | </NuxtLink> 20 | </template> 21 | <PublishWidgetFull /> 22 | </MainContent> 23 | </template> 24 | -------------------------------------------------------------------------------- /app/pages/conversations.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => t('nav.conversations'), 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent> 15 | <template #title> 16 | <NuxtLink to="/conversations" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 17 | <div i-ri:at-line /> 18 | <span>{{ t('nav.conversations') }}</span> 19 | </NuxtLink> 20 | </template> 21 | 22 | <TimelineConversations v-if="isHydrated" /> 23 | </MainContent> 24 | </template> 25 | -------------------------------------------------------------------------------- /app/pages/domain_blocks.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => t('nav.blocked_domains'), 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent back> 15 | <template #title> 16 | <span timeline-title-style>{{ $t('nav.blocked_domains') }}</span> 17 | </template> 18 | 19 | <TimelineDomainBlocks v-if="isHydrated" /> 20 | </MainContent> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/pages/favourites.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon') 8 | 9 | useHydratedHead({ 10 | title: () => t('nav.favourites'), 11 | }) 12 | </script> 13 | 14 | <template> 15 | <MainContent> 16 | <template #title> 17 | <NuxtLink to="/favourites" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 18 | <div :class="useStarFavoriteIcon ? 'i-ri:star-line' : 'i-ri:heart-3-line'" /> 19 | <span>{{ t('nav.favourites') }}</span> 20 | </NuxtLink> 21 | </template> 22 | 23 | <TimelineFavourites v-if="isHydrated" /> 24 | </MainContent> 25 | </template> 26 | -------------------------------------------------------------------------------- /app/pages/hashtags.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { t } = useI18n() 3 | 4 | useHydratedHead({ 5 | title: () => t('nav.hashtags'), 6 | }) 7 | </script> 8 | 9 | <template> 10 | <MainContent> 11 | <template #title> 12 | <NuxtLink to="/hashtags" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 13 | <div class="i-ri:hashtag" /> 14 | <span>{{ t('nav.hashtags') }}</span> 15 | </NuxtLink> 16 | </template> 17 | 18 | <NuxtPage v-if="isHydrated && currentUser" /> 19 | </MainContent> 20 | </template> 21 | -------------------------------------------------------------------------------- /app/pages/hashtags/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | const { client } = useMasto() 9 | const paginator = client.value.v1.followedTags.list({ 10 | limit: 20, 11 | }) 12 | 13 | useHydratedHead({ 14 | title: () => t('nav.hashtags'), 15 | }) 16 | </script> 17 | 18 | <template> 19 | <TagCardPaginator v-bind="{ paginator }" /> 20 | </template> 21 | -------------------------------------------------------------------------------- /app/pages/home.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | alias: ['/signin/callback'], 5 | }) 6 | 7 | const route = useRoute() 8 | const router = useRouter() 9 | if (import.meta.client && route.path === '/signin/callback') 10 | router.push('/home') 11 | 12 | const { t } = useI18n() 13 | useHydratedHead({ 14 | title: () => t('nav.home'), 15 | }) 16 | </script> 17 | 18 | <template> 19 | <MainContent> 20 | <template #title> 21 | <NuxtLink to="/home" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 22 | <div i-ri:home-5-line /> 23 | <span>{{ $t('nav.home') }}</span> 24 | </NuxtLink> 25 | </template> 26 | 27 | <TimelineHome v-if="isHydrated" /> 28 | </MainContent> 29 | </template> 30 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | </script> 6 | 7 | <template> 8 | <div /> 9 | </template> 10 | -------------------------------------------------------------------------------- /app/pages/intent/post.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const router = useRouter() 5 | const route = useRoute() 6 | 7 | onMounted(async () => { 8 | // TODO: login check 9 | await openPublishDialog('intent', getDefaultDraftItem({ 10 | status: route.query.text as string, 11 | sensitive: route.query.sensitive === 'true' || route.query.sensitive === null, 12 | spoilerText: route.query.spoiler_text as string, 13 | visibility: route.query.visibility as mastodon.v1.StatusVisibility, 14 | language: route.query.language as string, 15 | }), true) 16 | // TODO: need a better idea 👀 17 | await router.replace('/home') 18 | }) 19 | </script> 20 | 21 | <template> 22 | <div> 23 | <slot /> 24 | </div> 25 | </template> 26 | -------------------------------------------------------------------------------- /app/pages/mutes.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => t('nav.muted_users'), 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent back> 15 | <template #title> 16 | <span timeline-title-style>{{ $t('nav.muted_users') }}</span> 17 | </template> 18 | 19 | <TimelineMutes v-if="isHydrated" /> 20 | </MainContent> 21 | </template> 22 | -------------------------------------------------------------------------------- /app/pages/notifications/[filter].vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import type { mastodon } from 'masto' 3 | 4 | const route = useRoute() 5 | const { t } = useI18n() 6 | 7 | const filter = computed<mastodon.v1.NotificationType | undefined>(() => { 8 | if (!isHydrated.value) 9 | return undefined 10 | 11 | const rawFilter = route.params?.filter 12 | const actualFilter = Array.isArray(rawFilter) ? rawFilter[0] : rawFilter 13 | if (isNotification(actualFilter)) 14 | return actualFilter 15 | 16 | return undefined 17 | }) 18 | 19 | useHydratedHead({ 20 | title: () => `${t(`tab.notifications_${filter.value ?? 'all'}`)} | ${t('nav.notifications')}`, 21 | }) 22 | </script> 23 | 24 | <template> 25 | <TimelineNotifications v-if="isHydrated" :filter="filter" /> 26 | </template> 27 | -------------------------------------------------------------------------------- /app/pages/notifications/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { t } = useI18n() 3 | 4 | useHydratedHead({ 5 | title: () => `${t('tab.notifications_all')} | ${t('nav.notifications')}`, 6 | }) 7 | </script> 8 | 9 | <template> 10 | <TimelineNotifications v-if="isHydrated" /> 11 | </template> 12 | -------------------------------------------------------------------------------- /app/pages/pinned.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => t('account.pinned'), 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent> 15 | <template #title> 16 | <NuxtLink to="/pinned" timeline-title-style flex items-center gap-2 @click="$scrollToTop"> 17 | <div i-ri:pushpin-line /> 18 | <span>{{ t('account.pinned') }}</span> 19 | </NuxtLink> 20 | </template> 21 | 22 | <TimelinePinned v-if="isHydrated && currentUser" /> 23 | </MainContent> 24 | </template> 25 | -------------------------------------------------------------------------------- /app/pages/settings/index.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div min-h-screen flex justify-center items-center> 3 | <div text-center flex="~ col gap-2" items-center> 4 | <div i-ri:settings-3-line text-5xl /> 5 | <span text-xl>{{ $t('settings.select_a_settings') }}</span> 6 | </div> 7 | </div> 8 | </template> 9 | -------------------------------------------------------------------------------- /app/pages/settings/interface/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const { t } = useI18n() 3 | 4 | useHydratedHead({ 5 | title: () => `${t('settings.interface.label')} | ${t('nav.settings')}`, 6 | }) 7 | </script> 8 | 9 | <template> 10 | <MainContent back-on-small-screen> 11 | <template #title> 12 | <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> 13 | <span>{{ $t('settings.interface.label') }}</span> 14 | </div> 15 | </template> 16 | <div px-6 pt-3 pb-6 flex="~ col gap6"> 17 | <SettingsFontSize /> 18 | <SettingsColorMode /> 19 | <SettingsThemeColors /> 20 | <SettingsBottomNav /> 21 | </div> 22 | </MainContent> 23 | </template> 24 | -------------------------------------------------------------------------------- /app/pages/settings/notifications/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | const pwaEnabled = useAppConfig().pwaEnabled 8 | 9 | useHydratedHead({ 10 | title: () => `${t('settings.notifications.label')} | ${t('nav.settings')}`, 11 | }) 12 | </script> 13 | 14 | <template> 15 | <MainContent back-on-small-screen> 16 | <template #title> 17 | <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> 18 | <span>{{ $t('settings.notifications.label') }}</span> 19 | </div> 20 | </template> 21 | 22 | <SettingsItem 23 | command 24 | :text="$t('settings.notifications.notifications.label')" 25 | to="/settings/notifications/notifications" 26 | /> 27 | <SettingsItem 28 | command 29 | :disabled="!pwaEnabled" 30 | :text="$t('settings.notifications.push_notifications.label')" 31 | :description="$t('settings.notifications.push_notifications.description')" 32 | to="/settings/notifications/push-notifications" 33 | /> 34 | </MainContent> 35 | </template> 36 | -------------------------------------------------------------------------------- /app/pages/settings/notifications/notifications.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => `${t('settings.notifications.notifications.label')} | ${t('settings.notifications.label')} | ${t('nav.settings')}`, 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent back> 15 | <template #title> 16 | <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> 17 | <div i-ri:test-tube-line /> 18 | <span>{{ $t('settings.notifications.notifications.label') }}</span> 19 | </div> 20 | </template> 21 | <div text-center mt-10> 22 | <h1 text-4xl> 23 | <span sr-only>{{ $t('settings.notifications.under_construction') }}</span> 24 | 🚧 25 | </h1> 26 | <h3 text-xl> 27 | {{ $t('settings.notifications.notifications.label') }} 28 | </h3> 29 | </div> 30 | </MainContent> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/pages/settings/notifications/push-notifications.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: ['auth', () => { 4 | if (!useAppConfig().pwaEnabled) 5 | return navigateTo('/settings/notifications') 6 | }], 7 | }) 8 | 9 | const { t } = useI18n() 10 | 11 | useHydratedHead({ 12 | title: () => `${t('settings.notifications.push_notifications.label')} | ${t('settings.notifications.label')} | ${t('nav.settings')}`, 13 | }) 14 | </script> 15 | 16 | <template> 17 | <MainContent back> 18 | <template #title> 19 | <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> 20 | <span>{{ $t('settings.notifications.push_notifications.label') }}</span> 21 | </div> 22 | </template> 23 | <NotificationPreferences show /> 24 | </MainContent> 25 | </template> 26 | -------------------------------------------------------------------------------- /app/pages/settings/profile/featured-tags.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => `${t('settings.profile.featured_tags.label')} | ${t('nav.settings')}`, 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent back> 15 | <template #title> 16 | <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> 17 | <div i-ri:test-tube-line /> 18 | <span>{{ $t('settings.profile.featured_tags.label') }}</span> 19 | </div> 20 | </template> 21 | <div text-center mt-10> 22 | <h1 text-4xl> 23 | <span sr-only>{{ $t('settings.profile.featured_tags.under_construction') }}</span> 24 | 🚧 25 | </h1> 26 | <h3 text-xl> 27 | {{ $t('settings.profile.featured_tags.label') }} 28 | </h3> 29 | </div> 30 | </MainContent> 31 | </template> 32 | -------------------------------------------------------------------------------- /app/pages/settings/profile/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: 'auth', 4 | }) 5 | 6 | const { t } = useI18n() 7 | 8 | useHydratedHead({ 9 | title: () => `${t('settings.profile.label')} | ${t('nav.settings')}`, 10 | }) 11 | </script> 12 | 13 | <template> 14 | <MainContent back-on-small-screen> 15 | <template #title> 16 | <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> 17 | <span>{{ $t('settings.profile.label') }}</span> 18 | </div> 19 | </template> 20 | 21 | <SettingsItem 22 | command large 23 | icon="i-ri:user-settings-line" 24 | :text="$t('settings.profile.appearance.label')" 25 | :description="$t('settings.profile.appearance.description')" 26 | to="/settings/profile/appearance" 27 | /> 28 | <SettingsItem 29 | command large 30 | icon="i-ri:hashtag" 31 | :text="$t('settings.profile.featured_tags.label')" 32 | :description="$t('settings.profile.featured_tags.description')" 33 | to="/settings/profile/featured-tags" 34 | /> 35 | <SettingsItem 36 | v-if="isHydrated && currentUser" 37 | command large 38 | icon="i-ri:settings-line" 39 | :text="$t('settings.account_settings.label')" 40 | :description="$t('settings.account_settings.description')" 41 | :to="`https://${currentUser!.server}/auth/edit`" 42 | external target="_blank" 43 | /> 44 | </MainContent> 45 | </template> 46 | -------------------------------------------------------------------------------- /app/pages/share-target.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | definePageMeta({ 3 | middleware: () => { 4 | if (!useAppConfig().pwaEnabled) 5 | return navigateTo('/') 6 | }, 7 | }) 8 | 9 | useWebShareTarget() 10 | 11 | const pwaIsInstalled = import.meta.client && !!useNuxtApp().$pwa?.isInstalled 12 | </script> 13 | 14 | <template> 15 | <MainContent> 16 | <template #title> 17 | <NuxtLink to="/share-target" flex items-center gap-2> 18 | <div i-ri:share-line /> 19 | <span>{{ $t('share_target.title') }}</span> 20 | </NuxtLink> 21 | </template> 22 | <slot> 23 | <div flex="~ col" px5 py2 gap-y-4> 24 | <div 25 | v-if="!pwaIsInstalled || !currentUser" 26 | role="alert" 27 | gap-1 28 | p-2 29 | text-red-600 dark:text-red-400 30 | border="~ base rounded red-600 dark:red-400" 31 | > 32 | {{ $t('share_target.hint') }} 33 | </div> 34 | <div>{{ $t('share_target.description') }}</div> 35 | </div> 36 | </slot> 37 | </MainContent> 38 | </template> 39 | -------------------------------------------------------------------------------- /app/plugins/1.scroll-to-top.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(() => { 2 | return { 3 | provide: { 4 | scrollToTop: () => { 5 | window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) 6 | }, 7 | }, 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /app/plugins/color-mode.ts: -------------------------------------------------------------------------------- 1 | import { THEME_COLORS } from '~/constants' 2 | 3 | export default defineNuxtPlugin(() => { 4 | const colorMode = useColorMode() 5 | useHead({ 6 | meta: [{ 7 | id: 'theme-color', 8 | name: 'theme-color', 9 | content: () => colorMode.value === 'dark' ? THEME_COLORS.themeDark : THEME_COLORS.themeLight, 10 | }], 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /app/plugins/floating-vue.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from '#imports' 2 | import FloatingVue from 'floating-vue' 3 | 4 | export default defineNuxtPlugin((nuxtApp) => { 5 | nuxtApp.vueApp.use(FloatingVue) 6 | }) 7 | -------------------------------------------------------------------------------- /app/plugins/hydration.client.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin((nuxtApp) => { 2 | nuxtApp.hooks.hookOnce('app:suspense:resolve', () => { 3 | isHydrated.value = true 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /app/plugins/page-lifecycle.client.ts: -------------------------------------------------------------------------------- 1 | import lifecycle from 'page-lifecycle/dist/lifecycle.mjs' 2 | import { ELK_PAGE_LIFECYCLE_FROZEN } from '~/constants' 3 | import { closeDatabases } from '~/utils/elk-idb' 4 | 5 | export default defineNuxtPlugin(() => { 6 | const state = ref(lifecycle.state) 7 | const frozenListeners: (() => void)[] = [] 8 | const frozenState = useLocalStorage(ELK_PAGE_LIFECYCLE_FROZEN, false) 9 | 10 | lifecycle.addEventListener('statechange', (evt) => { 11 | if (evt.newState === 'hidden' && evt.oldState === 'frozen') { 12 | frozenState.value = false 13 | nextTick().then(() => window.location.reload()) 14 | return 15 | } 16 | 17 | if (evt.newState === 'frozen') { 18 | frozenState.value = true 19 | frozenListeners.forEach(listener => listener()) 20 | } 21 | else { 22 | state.value = evt.newState 23 | } 24 | }) 25 | 26 | const addFrozenListener = (listener: () => void) => { 27 | frozenListeners.push(listener) 28 | } 29 | 30 | addFrozenListener(() => { 31 | if (useAppConfig().pwaEnabled && navigator.serviceWorker.controller) 32 | navigator.serviceWorker.controller.postMessage(ELK_PAGE_LIFECYCLE_FROZEN) 33 | 34 | closeDatabases() 35 | }) 36 | 37 | return { 38 | provide: { 39 | pageLifecycle: reactive({ 40 | state, 41 | addFrozenListener, 42 | }), 43 | }, 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /app/plugins/path.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin({ 2 | order: -40, 3 | setup: (nuxtApp) => { 4 | delete nuxtApp.payload.path 5 | }, 6 | }) 7 | -------------------------------------------------------------------------------- /app/plugins/setup-global-effects.client.ts: -------------------------------------------------------------------------------- 1 | import type { OldFontSize } from '~/composables/settings' 2 | import { DEFAULT_FONT_SIZE } from '~/constants' 3 | import { oldFontSizeMap } from '~/constants/options' 4 | 5 | export default defineNuxtPlugin(() => { 6 | const userSettings = useUserSettings() 7 | const html = document.documentElement 8 | watchEffect(() => { 9 | const { fontSize } = userSettings.value 10 | html.style.setProperty('--font-size', fontSize ? (oldFontSizeMap[fontSize as OldFontSize] ?? fontSize) : DEFAULT_FONT_SIZE) 11 | }) 12 | watchEffect(() => { 13 | html.classList.toggle('zen', getPreferences(userSettings.value, 'zenMode')) 14 | }) 15 | watchEffect(() => { 16 | Object.entries(userSettings.value.themeColors || {}).forEach(([k, v]) => html.style.setProperty(k, v)) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /app/plugins/setup-head-script.server.ts: -------------------------------------------------------------------------------- 1 | import { STORAGE_KEY_CURRENT_USER_HANDLE, STORAGE_KEY_SETTINGS } from '~/constants' 2 | import { oldFontSizeMap } from '~/constants/options' 3 | 4 | /** 5 | * Injecting scripts before renders 6 | */ 7 | export default defineNuxtPlugin(() => { 8 | useHead({ 9 | script: [ 10 | { 11 | innerHTML: ` 12 | ;(function() { 13 | const handle = localStorage.getItem('${STORAGE_KEY_CURRENT_USER_HANDLE}') || '[anonymous]' 14 | const allSettings = JSON.parse(localStorage.getItem('${STORAGE_KEY_SETTINGS}') || '{}') 15 | const settings = allSettings[handle] 16 | if (!settings) { return } 17 | 18 | const html = document.documentElement 19 | ${import.meta.dev ? 'console.log({ settings })' : ''} 20 | 21 | if (settings.fontSize) { 22 | const oldFontSizeMap = ${JSON.stringify(oldFontSizeMap)} 23 | html.style.setProperty('--font-size', oldFontSizeMap[settings.fontSize] || settings.fontSize) 24 | } 25 | if (settings.language) { 26 | html.setAttribute('lang', settings.language) 27 | } 28 | if (settings.preferences.zenMode) { 29 | html.classList.add('zen') 30 | } 31 | if (settings.themeColors) { 32 | Object.entries(settings.themeColors).map(i => html.style.setProperty(i[0], i[1])) 33 | } 34 | })()`.trim().replace(/\s*\n\s*/g, ';'), 35 | }, 36 | ], 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /app/plugins/setup-i18n.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from '#i18n' 2 | 3 | export default defineNuxtPlugin(async (nuxt) => { 4 | const t = nuxt.vueApp.config.globalProperties.$t 5 | const d = nuxt.vueApp.config.globalProperties.$d 6 | const n = nuxt.vueApp.config.globalProperties.$n 7 | 8 | nuxt.vueApp.config.globalProperties.$t = wrapI18n(t) 9 | nuxt.vueApp.config.globalProperties.$d = wrapI18n(d) 10 | nuxt.vueApp.config.globalProperties.$n = wrapI18n(n) 11 | 12 | if (import.meta.client) { 13 | const i18n = useNuxtApp().$i18n 14 | const { setLocale, locales } = i18n 15 | const userSettings = useUserSettings() 16 | const lang = computed(() => userSettings.value.language as Locale) 17 | 18 | const supportLanguages = unref(locales).map(locale => locale.code) 19 | if (!supportLanguages.includes(lang.value)) 20 | userSettings.value.language = getDefaultLanguage(supportLanguages) 21 | 22 | if (lang.value !== i18n.locale) 23 | await setLocale(userSettings.value.language as Locale) 24 | 25 | watch([lang, isHydrated], () => { 26 | if (isHydrated.value && lang.value !== i18n.locale) 27 | setLocale(lang.value) 28 | }, { immediate: true }) 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /app/plugins/social.server.ts: -------------------------------------------------------------------------------- 1 | import { sendRedirect } from 'h3' 2 | 3 | const BOT_RE = /bot\b|index|spider|facebookexternalhit|crawl|wget|slurp|mediapartners-google|whatsapp/i 4 | 5 | export default defineNuxtPlugin(async (nuxtApp) => { 6 | const route = useRoute() 7 | if (!('server' in route.params)) 8 | return 9 | 10 | const userAgent = useRequestHeaders()['user-agent'] 11 | if (!userAgent) 12 | return 13 | 14 | const isOpenGraphCrawler = BOT_RE.test(userAgent) 15 | if (isOpenGraphCrawler) { 16 | // Redirect bots to the original instance to respect their social sharing settings 17 | await sendRedirect(nuxtApp.ssrContext!.event, `https:/${route.path}`, 301) 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /app/styles/default-theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --theme-color-name: #cc7d24; 3 | --c-primary: rgb(var(--rgb-primary)); 4 | --c-primary-active: #b16605; 5 | --c-primary-light: #cc7d2480; 6 | --c-primary-fade: #c7781f1a; 7 | --rgb-primary: 204, 125, 36; 8 | --c-dark-primary: rgb(var(--rgb-dark-primary)); 9 | --c-dark-primary-active: #b66b0d; 10 | --c-dark-primary-light: #d1822980; 11 | --c-dark-primary-fade: #cc7d241a; 12 | --rgb-dark-primary: 204, 125, 36; 13 | } 14 | -------------------------------------------------------------------------------- /app/styles/dropdown.css: -------------------------------------------------------------------------------- 1 | .v-popper--theme-dropdown .v-popper__inner { 2 | --at-apply: bg-base text-base rounded border border-base shadow; 3 | box-shadow: 0 6px 30px #0000001a; 4 | } 5 | .v-popper--theme-dropdown .v-popper__arrow-inner { 6 | visibility: visible; 7 | --at-apply: border-$c-bg-base; 8 | } 9 | .v-popper--theme-dropdown .v-popper__arrow-outer { 10 | --at-apply: border-base; 11 | } 12 | .v-popper--theme-tooltip .v-popper__inner { 13 | --at-apply: bg-base text-base rounded border border-base shadow-sm; 14 | padding: 7px 12px 6px; 15 | } 16 | .v-popper--theme-tooltip .v-popper__arrow-inner { 17 | visibility: visible; 18 | --at-apply: border-bg-base; 19 | } 20 | .v-popper--theme-tooltip .v-popper__arrow-outer { 21 | --at-apply: border-base; 22 | } 23 | -------------------------------------------------------------------------------- /app/styles/scrollbars.css: -------------------------------------------------------------------------------- 1 | * { 2 | scrollbar-color: #8885 var(--c-border); 3 | } 4 | 5 | ::-webkit-scrollbar { 6 | width: 10px; 7 | } 8 | 9 | ::-webkit-scrollbar:horizontal { 10 | height: 10px; 11 | } 12 | 13 | ::-webkit-scrollbar-track { 14 | background: var(--c-border); 15 | border-radius: 1px; 16 | } 17 | 18 | ::-webkit-scrollbar-thumb { 19 | background: #8885; 20 | border-radius: 1px; 21 | } 22 | 23 | ::-webkit-scrollbar-thumb:hover { 24 | background: #8886; 25 | } -------------------------------------------------------------------------------- /app/styles/tiptap.css: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | p.is-editor-empty:first-child::before { 3 | content: attr(data-placeholder); 4 | float: left; 5 | pointer-events: none; 6 | height: 0; 7 | opacity: 0.4; 8 | } 9 | span[data-type='mention'], 10 | span[data-type='hashtag'] { 11 | --at-apply: text-primary; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/styles/vars.css: -------------------------------------------------------------------------------- 1 | :root, :root::selection { 2 | --c-border: #eee; 3 | --c-border-dark: #dccfcf; 4 | --c-border-code: #ddd; 5 | --c-danger: #FF3C1B; 6 | --c-danger-active: #B50900; 7 | 8 | --rgb-bg-base: 250, 250, 250; 9 | 10 | --c-bg-base: rgb(var(--rgb-bg-base)); 11 | 12 | --c-bg-active: #f2f2f2; 13 | --c-bg-card: #00000006; 14 | --c-bg-code: #00000006; 15 | --c-bg-selection: #8885; 16 | --c-bg-dm: #f1e8e6; 17 | 18 | --c-text-base: #232323; 19 | --c-text-code: #63470c; 20 | --c-text-secondary: #686868; 21 | --c-text-secondary-light: #919191; 22 | 23 | --c-bg-btn-disabled: #a1a1a1; 24 | --c-text-btn-disabled: #fff; 25 | --c-text-btn-disabled-deeper: #a1a1a1; 26 | 27 | --c-success: #67C23A; 28 | --c-warning: #E6A23C; 29 | --c-error: #F56C6C; 30 | } 31 | 32 | .dark { 33 | --c-primary: var(--c-dark-primary); 34 | --c-primary-active: var(--c-dark-primary-active); 35 | --c-primary-light: var(--c-dark-primary-light); 36 | --c-primary-fade: var(--c-dark-primary-fade); 37 | --c-danger: #FF2810; 38 | --c-danger-active: #E02F00; 39 | 40 | --c-border: #222; 41 | --c-border-code: #333; 42 | --c-border-dark: #545251; 43 | 44 | --rgb-bg-base: 17, 17, 17; 45 | 46 | --c-bg-active: #191919; 47 | --c-bg-card: #ffffff06; 48 | --c-bg-code: #ffffff06; 49 | --c-bg-dm: #0a2f35; 50 | 51 | --c-text-base: #f3f3f3; 52 | --c-text-code: #ecd88e; 53 | --c-text-secondary: #888; 54 | --c-text-secondary-light: #686868; 55 | 56 | --c-bg-btn-disabled: #2a2a2a; 57 | --c-text-btn-disabled: #919191; 58 | } 59 | -------------------------------------------------------------------------------- /app/utils/elk-idb.ts: -------------------------------------------------------------------------------- 1 | import type { UseStore } from 'idb-keyval' 2 | import { 3 | del as delIdb, 4 | get as getIdb, 5 | promisifyRequest, 6 | set as setIdb, 7 | update as updateIdb, 8 | 9 | } from 'idb-keyval' 10 | 11 | const databases: IDBOpenDBRequest[] = [] 12 | 13 | function createStore(): UseStore { 14 | const storeName = 'keyval' 15 | const request = indexedDB.open('keyval-store') 16 | databases.push(request) 17 | request.onupgradeneeded = () => request.result.createObjectStore(storeName) 18 | const dbp = promisifyRequest(request) 19 | return (txMode, callback) => dbp.then(db => callback(db.transaction(storeName, txMode).objectStore(storeName))) 20 | } 21 | 22 | let defaultGetStoreFunc: UseStore | undefined 23 | function defaultGetStore() { 24 | if (!defaultGetStoreFunc) 25 | defaultGetStoreFunc = createStore() 26 | 27 | return defaultGetStoreFunc 28 | } 29 | 30 | export function get<T = any>(key: IDBValidKey) { 31 | return getIdb<T>(key, defaultGetStore()) 32 | } 33 | 34 | export function set(key: IDBValidKey, value: any) { 35 | return setIdb(key, value, defaultGetStore()) 36 | } 37 | 38 | export function update<T = any>(key: IDBValidKey, updater: (oldValue: T | undefined) => T) { 39 | return updateIdb(key, updater, defaultGetStore()) 40 | } 41 | 42 | export function del(key: IDBValidKey) { 43 | return delIdb(key, defaultGetStore()) 44 | } 45 | 46 | export function closeDatabases() { 47 | databases.forEach((db) => { 48 | if (db.result) 49 | db.result.close() 50 | }) 51 | defaultGetStoreFunc = undefined 52 | } 53 | -------------------------------------------------------------------------------- /app/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import { useI18n as useOriginalI18n } from 'vue-i18n' 2 | 3 | export function useI18n() { 4 | const { 5 | t, 6 | d, 7 | n, 8 | ...rest 9 | } = useOriginalI18n() 10 | 11 | return { 12 | ...rest, 13 | t: wrapI18n(t), 14 | d: wrapI18n(d), 15 | n: wrapI18n(n), 16 | } satisfies ReturnType<typeof useOriginalI18n> 17 | } 18 | 19 | export function wrapI18n<T extends (...args: any[]) => any>(t: T): T { 20 | return <T>((...args: any[]) => { 21 | return isHydrated.value ? t(...args) : '' 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /app/utils/language.ts: -------------------------------------------------------------------------------- 1 | export function matchLanguages(languages: string[], acceptLanguages: readonly string[]): string | null { 2 | { 3 | // const lang = acceptLanguages.map(userLang => languages.find(lang => lang.startsWith(userLang))).filter(v => !!v)[0] 4 | // TODO: Support es-419, remove this code if we include spanish country variants 5 | const lang = acceptLanguages.map(userLang => languages.find((currentLang) => { 6 | if (currentLang === userLang) 7 | return currentLang 8 | 9 | // Edge browser: case for ca-valencia 10 | if (currentLang === 'ca-valencia' && userLang === 'ca-Es-VALENCIA') 11 | return currentLang 12 | 13 | if (userLang.startsWith('es-') && userLang !== 'es-ES' && currentLang === 'es-419') 14 | return currentLang 15 | 16 | return currentLang.startsWith(userLang) ? currentLang : undefined 17 | })).filter(v => !!v)?.[0] 18 | if (lang) 19 | return lang 20 | } 21 | 22 | const lang = acceptLanguages.map((userLang) => { 23 | userLang = userLang.split('-')[0]! 24 | return languages.find(lang => lang.startsWith(userLang)) 25 | }).filter(v => !!v)[0] 26 | if (lang) 27 | return lang 28 | 29 | return null 30 | } 31 | -------------------------------------------------------------------------------- /config/emojis.ts: -------------------------------------------------------------------------------- 1 | import type { EmojiRegexMatch } from '@iconify/utils/lib/emoji/replace/find' 2 | // @unimport-disabled 3 | import { emojiFilename, emojiPrefix, emojiRegEx } from '@iconify-emoji/twemoji' 4 | import { getEmojiMatchesInText } from '@iconify/utils/lib/emoji/replace/find' 5 | 6 | // Re-export everything from package 7 | export * from '@iconify-emoji/twemoji' 8 | 9 | // Package name 10 | export const iconifyEmojiPackage = '@iconify-emoji/twemoji' 11 | 12 | export function getEmojiAttributes(input: EmojiRegexMatch | string) { 13 | const match = typeof input === 'string' 14 | ? getEmojiMatchesInText(emojiRegEx, input)?.[0] 15 | : input 16 | const file = emojiFilename(match) 17 | const className = `iconify-emoji iconify-emoji--${emojiPrefix}${file.padding ? ' iconify-emoji-padded' : ''}` 18 | return { 19 | class: className, 20 | src: `/emojis/${emojiPrefix}/${file.filename}`, 21 | alt: match.match, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /config/i18n.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | currentLocales, 3 | datetimeFormats, 4 | numberFormats, 5 | pluralRules, 6 | } from './i18n' 7 | 8 | export default defineI18nConfig(() => { 9 | return { 10 | legacy: false, 11 | availableLocales: currentLocales.map(l => l.code), 12 | fallbackLocale: 'en-US', 13 | fallbackWarn: true, 14 | missingWarn: true, 15 | datetimeFormats, 16 | numberFormats, 17 | pluralRules, 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /config/pwa.ts: -------------------------------------------------------------------------------- 1 | import type { VitePWANuxtOptions } from '../modules/pwa/types' 2 | import { isCI, isDevelopment } from 'std-env' 3 | 4 | export const pwa: VitePWANuxtOptions = { 5 | mode: isCI ? 'production' : 'development', 6 | // disable PWA only when in preview mode 7 | disable: /* temporarily test in CI isPreview || */ (isDevelopment && process.env.VITE_DEV_PWA !== 'true'), 8 | scope: '/', 9 | srcDir: '../service-worker', 10 | filename: 'elk-sw.ts', 11 | strategies: 'injectManifest', 12 | injectRegister: false, 13 | includeManifestIcons: false, 14 | manifest: false, 15 | injectManifest: { 16 | globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm}'], 17 | globIgnores: ['emojis/**', 'manifest**.webmanifest'], 18 | }, 19 | devOptions: { 20 | enabled: process.env.VITE_DEV_PWA === 'true', 21 | type: 'module', 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | elk: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | volumes: 7 | # make sure this directory has the same ownership as the elk user from the Dockerfile 8 | # otherwise Elk will not be able to store configs for accounts 9 | # e.q. mkdir ./elk-storage; sudo chown 911:911 ./elk-storage 10 | - './elk-storage:/elk/data' 11 | ports: 12 | - 5314:5314 13 | -------------------------------------------------------------------------------- /docs/.env.example: -------------------------------------------------------------------------------- 1 | # Create one with no scope selected on https://github.com/settings/tokens/new 2 | # This token is used for fetching the repository releases. 3 | GITHUB_TOKEN= -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.iml 3 | .idea 4 | *.log* 5 | .nuxt 6 | .vscode 7 | .DS_Store 8 | coverage 9 | dist 10 | sw.* 11 | .env 12 | .output 13 | translation-status.json 14 | -------------------------------------------------------------------------------- /docs/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | docus: { 3 | title: 'Elk', 4 | description: 'A nimble Mastodon web client.', 5 | image: 'https://docs.elk.zone/elk-screenshot.png', 6 | socials: { 7 | // twitter: 'elk_zone', 8 | github: 'elk-zone/elk', 9 | mastodon: { 10 | label: 'Mastodon', 11 | icon: 'IconMastodon', 12 | href: 'https://elk.zone/@elk@webtoo.ls', 13 | }, 14 | }, 15 | aside: { 16 | level: 0, 17 | exclude: [], 18 | }, 19 | header: { 20 | logo: true, 21 | showLinkIcon: true, 22 | exclude: [], 23 | }, 24 | footer: { 25 | iconLinks: [ 26 | { 27 | href: 'https://nuxt.com', 28 | icon: 'IconNuxtLabs', 29 | }, 30 | { 31 | href: 'https://m.webtoo.ls/@elk', 32 | icon: 'IconMastodon', 33 | }, 34 | ], 35 | }, 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /docs/app.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <AppLayout> 3 | <NuxtPage /> 4 | </AppLayout> 5 | </template> 6 | -------------------------------------------------------------------------------- /docs/components/global/ClipboardIcon.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | copy?: boolean 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <svg v-if="copy" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> 9 | <path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2v2Z" /> 10 | </svg> 11 | <svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> 12 | <path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2v2m.5 6.5L9 12l2 2l4.5-4.5L17 11l-6 6l-3.5-3.5Z" /> 13 | </svg> 14 | </template> 15 | -------------------------------------------------------------------------------- /docs/components/global/IconMastodon.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"></script> 2 | 3 | <template> 4 | <svg width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M21.258 13.99c-.274 1.41-2.456 2.955-4.962 3.254c-1.306.156-2.593.3-3.965.236c-2.243-.103-4.014-.535-4.014-.535c0 .218.014.426.04.62c.292 2.215 2.196 2.347 4 2.41c1.82.062 3.44-.45 3.44-.45l.076 1.646s-1.274.684-3.542.81c-1.25.068-2.803-.032-4.612-.51c-3.923-1.039-4.598-5.22-4.701-9.464c-.031-1.26-.012-2.447-.012-3.44c0-4.34 2.843-5.611 2.843-5.611c1.433-.658 3.892-.935 6.45-.956h.062c2.557.02 5.018.298 6.451.956c0 0 2.843 1.272 2.843 5.61c0 0 .036 3.201-.397 5.424zm-2.956-5.087c0-1.074-.273-1.927-.822-2.558c-.567-.631-1.308-.955-2.229-.955c-1.065 0-1.871.41-2.405 1.228l-.518.87l-.519-.87C11.276 5.8 10.47 5.39 9.405 5.39c-.921 0-1.663.324-2.229.955c-.549.631-.822 1.484-.822 2.558v5.253h2.081V9.057c0-1.075.452-1.62 1.357-1.62c1 0 1.501.647 1.501 1.927v2.79h2.07v-2.79c0-1.28.5-1.927 1.5-1.927c.905 0 1.358.545 1.358 1.62v5.1h2.08V8.902z" /></svg> 5 | </template> 6 | -------------------------------------------------------------------------------- /docs/components/global/Logo.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="logo"> 3 | <img alt="Elk" src="/logo.svg"> 4 | Elk 5 | </div> 6 | </template> 7 | 8 | <style lang="ts" scoped> 9 | css({ 10 | '.logo': { 11 | display: 'flex', 12 | flexDirection: 'row', 13 | alignItems: 'center', 14 | gap: '0.5rem', 15 | fontSize: '1.5rem', 16 | }, 17 | 'img': { 18 | flexShrink: 0, 19 | aspectRatio: '1/1', 20 | height: '2.5rem', 21 | }, 22 | }) 23 | </style> 24 | -------------------------------------------------------------------------------- /docs/components/global/ToggleIcon.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | defineProps<{ 3 | up?: boolean 4 | }>() 5 | </script> 6 | 7 | <template> 8 | <svg v-if="up" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"> 9 | <path fill="currentColor" d="m12 10.828l-4.95 4.95l-1.414-1.414L12 8l6.364 6.364l-1.414 1.414z" /> 10 | </svg> 11 | <svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"> 12 | <path fill="currentColor" d="m12 13.172l4.95-4.95l1.414 1.414L12 16L5.636 9.636L7.05 8.222z" /> 13 | </svg> 14 | </template> 15 | -------------------------------------------------------------------------------- /docs/content/0.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Elk 3 | navigation: false 4 | layout: page 5 | --- 6 | 7 | ::block-hero 8 | --- 9 | cta: 10 | - Read more 11 | - /guide 12 | secondary: 13 | - Try it out → 14 | - https://elk.zone 15 | --- 16 | 17 | #title 18 | Elk 19 | 20 | #description 21 | An in-progress, nimble Mastodon web client 22 | 23 | #support 24 |  25 | 26 | #extra 27 | ::list 28 | - markdown support 29 | - code blocks 30 | - reordering and connecting posts in timelines 31 | - multi account 32 | - GitHub HTML cards 33 | - and more... 34 | :: 35 | 36 | :: 37 | -------------------------------------------------------------------------------- /docs/content/1.guide/4.sponsoring.md: -------------------------------------------------------------------------------- 1 | # Sponsoring 2 | 3 | If you're enjoying the app, consider sponsoring our team: 4 | 5 | - [Anthony Fu](https://github.com/sponsors/antfu) 6 | - [Daniel Roe](https://github.com/sponsors/danielroe) 7 | - [三咲智子 Kevin Deng](https://github.com/sponsors/sxzz) 8 | - [Patak](https://github.com/sponsors/patak-dev) 9 | 10 | 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. 11 | -------------------------------------------------------------------------------- /docs/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "dist" 3 | command = "pnpm generate" 4 | 5 | # Allow previewing docs 6 | [[redirects]] 7 | from = "/docs/*" 8 | to = "/:splat" 9 | status = 200 10 | force = true 11 | -------------------------------------------------------------------------------- /docs/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | extends: '@nuxt-themes/docus', 3 | 4 | vite: { 5 | optimizeDeps: { 6 | include: ['scule'], 7 | }, 8 | }, 9 | 10 | compatibilityDate: '2024-11-07', 11 | }) 12 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elk-docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate", 9 | "preview": "nuxi preview" 10 | }, 11 | "dependencies": { 12 | "theme-colors": "^0.1.0" 13 | }, 14 | "devDependencies": { 15 | "@nuxt-themes/docus": "^1.15.1", 16 | "nuxt": "^3.17.6" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/elk-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/elk-screenshot.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/fonts/DM-sans-v11.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/fonts/DM-sans-v11.ttf -------------------------------------------------------------------------------- /docs/public/images/selfhosting-guide/cf-api-token-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/images/selfhosting-guide/cf-api-token-settings.png -------------------------------------------------------------------------------- /docs/public/images/selfhosting-guide/github-fork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/images/selfhosting-guide/github-fork.png -------------------------------------------------------------------------------- /docs/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/pwa-192x192.png -------------------------------------------------------------------------------- /docs/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/pwa-512x512.png -------------------------------------------------------------------------------- /docs/public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/screenshot.png -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/pwa-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/pwa-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /docs/tokens.config.ts: -------------------------------------------------------------------------------- 1 | import { defineTheme } from 'pinceau' 2 | import { getColors } from 'theme-colors' 3 | 4 | const light = getColors('#995e1b') 5 | const primary = Object 6 | .entries(getColors('#d98018')) 7 | .reduce((acc, [key, value]) => { 8 | acc[key] = { 9 | initial: light[key]!, 10 | dark: value, 11 | } 12 | return acc 13 | }, {} as Record<string | number, { initial: string, dark: string }>) 14 | 15 | export default defineTheme({ 16 | color: { primary }, 17 | }) 18 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /docs/types.ts: -------------------------------------------------------------------------------- 1 | export interface LocaleEntry { 2 | title: string 3 | file: string 4 | useFile: string 5 | translated: string[] 6 | missing: string[] 7 | outdated: string[] 8 | total: number 9 | isSource?: boolean 10 | } 11 | 12 | export type TranslationStatus = Record<string, LocaleEntry> 13 | -------------------------------------------------------------------------------- /emoji-mart-traslation.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'virtual:emoji-mart-lang-importer' { 2 | export default function (lang: string): Promise<any> 3 | } 4 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import antfu from '@antfu/eslint-config' 3 | 4 | export default await antfu( 5 | { 6 | unocss: false, 7 | vue: { 8 | overrides: { 9 | 'vue/no-restricted-syntax': ['error', { 10 | selector: 'VElement[name=\'a\']', 11 | message: 'Use NuxtLink instead.', 12 | }], 13 | }, 14 | }, 15 | ignores: [ 16 | 'public/**', 17 | 'public-dev/**', 18 | 'public-staging/**', 19 | 'https-dev-config/**', 20 | 'elk-translation-status.json', 21 | 'docs/translation-status.json', 22 | ], 23 | }, 24 | { 25 | rules: { 26 | // TODO: migrate all process reference to `import.meta.env` and remove this rule 27 | 'node/prefer-global/process': 'off', 28 | }, 29 | }, 30 | // Sort local files 31 | { 32 | files: ['locales/**.json'], 33 | rules: { 34 | 'jsonc/sort-keys': 'error', 35 | }, 36 | }, 37 | ) 38 | -------------------------------------------------------------------------------- /https-dev-config/local-https-server.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | process.env.NITRO_SSL_CERT = readFileSync(fileURLToPath(new URL('./localhost.crt', import.meta.url)), 'utf8') 5 | process.env.NITRO_SSL_KEY = readFileSync(fileURLToPath(new URL('./localhost.key', import.meta.url)), 'utf8') 6 | 7 | async function run() { 8 | await import('../.output/server/index.mjs') 9 | } 10 | 11 | run() 12 | -------------------------------------------------------------------------------- /locales/ar-EG.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/ca-ES.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/en-US.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/es-ES.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/pt-PT.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /mocks/class.ts: -------------------------------------------------------------------------------- 1 | export default class SomeClass { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /mocks/prosemirror.ts: -------------------------------------------------------------------------------- 1 | import proxy from 'mocked-exports/proxy' 2 | 3 | export const Plugin = proxy 4 | export const PluginKey = proxy 5 | export const createParser = proxy 6 | export const createHighlightPlugin = proxy 7 | 8 | export { proxy as default } 9 | -------------------------------------------------------------------------------- /mocks/semver.ts: -------------------------------------------------------------------------------- 1 | import proxy from 'mocked-exports/proxy' 2 | 3 | export const lt = proxy 4 | export const gt = proxy 5 | export const gte = proxy 6 | export const satisfies = proxy 7 | export class SemVer {} 8 | -------------------------------------------------------------------------------- /mocks/tiptap.ts: -------------------------------------------------------------------------------- 1 | import proxy from 'mocked-exports/proxy' 2 | 3 | export const Extension = proxy 4 | export const useEditor = proxy 5 | export const EditorContent = proxy 6 | export const NodeViewContent = proxy 7 | export const NodeViewWrapper = proxy 8 | export const nodeViewProps = proxy 9 | export const Node = proxy 10 | export const mergeAttributes = proxy 11 | export const nodeInputRule = proxy 12 | export const nodePasteRule = proxy 13 | export const VueNodeViewRenderer = proxy 14 | export const findChildren = proxy 15 | export const VueRenderer = proxy 16 | export const callOrReturn = proxy 17 | export const InputRule = proxy 18 | 19 | export { proxy as default } 20 | -------------------------------------------------------------------------------- /modules/build-env.ts: -------------------------------------------------------------------------------- 1 | import type { BuildInfo } from '#shared/types' 2 | import { createResolver, defineNuxtModule } from '@nuxt/kit' 3 | import { isCI } from 'std-env' 4 | import { getEnv, version } from '../config/env' 5 | 6 | const { resolve } = createResolver(import.meta.url) 7 | 8 | export default defineNuxtModule({ 9 | meta: { 10 | name: 'elk:build-env', 11 | }, 12 | async setup(_options, nuxt) { 13 | const { env, commit, shortCommit, branch } = await getEnv() 14 | const buildInfo: BuildInfo = { 15 | version, 16 | time: +Date.now(), 17 | commit, 18 | shortCommit, 19 | branch, 20 | env, 21 | } 22 | 23 | nuxt.options.appConfig = nuxt.options.appConfig || {} 24 | nuxt.options.appConfig.env = env 25 | nuxt.options.appConfig.buildInfo = buildInfo 26 | 27 | nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {} 28 | nuxt.options.nitro.virtual['#build-info'] = `export const env = ${JSON.stringify(env)}` 29 | 30 | nuxt.options.nitro.publicAssets = nuxt.options.nitro.publicAssets || [] 31 | if (env === 'dev') 32 | nuxt.options.nitro.publicAssets.unshift({ dir: resolve('../public-dev') }) 33 | else if (env === 'canary' || env === 'preview' || !isCI) 34 | nuxt.options.nitro.publicAssets.unshift({ dir: resolve('../public-staging') }) 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /modules/purge-comments.ts: -------------------------------------------------------------------------------- 1 | import { addVitePlugin, defineNuxtModule } from '@nuxt/kit' 2 | import MagicString from 'magic-string' 3 | 4 | export default defineNuxtModule({ 5 | meta: { 6 | name: 'purge-comments', 7 | }, 8 | setup() { 9 | addVitePlugin({ 10 | name: 'purge-comments', 11 | enforce: 'pre', 12 | transform: (code, id) => { 13 | if (!id.endsWith('.vue') || !code.includes('<!--')) 14 | return 15 | 16 | const s = new MagicString(code) 17 | s.replace(/<!--.*?-->/gs, '') 18 | 19 | if (s.hasChanged()) { 20 | return { 21 | code: s.toString(), 22 | map: s.generateMap({ source: id, includeContent: true }), 23 | } 24 | } 25 | }, 26 | }) 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /modules/pwa/runtime/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | import type { UnwrapNestedRefs } from 'vue' 3 | 4 | export interface PwaInjection { 5 | isInstalled: boolean 6 | showInstallPrompt: Ref<boolean> 7 | cancelInstall: () => void 8 | install: () => Promise<void> 9 | swActivated: Ref<boolean> 10 | registrationError: Ref<boolean> 11 | needRefresh: Ref<boolean> 12 | updateServiceWorker: (reloadPage?: boolean | undefined) => Promise<void> 13 | close: () => Promise<void> 14 | } 15 | 16 | declare module '#app' { 17 | interface NuxtApp { 18 | $pwa?: UnwrapNestedRefs<PwaInjection> 19 | } 20 | } 21 | 22 | declare module 'vue' { 23 | interface ComponentCustomProperties { 24 | $pwa?: UnwrapNestedRefs<PwaInjection> 25 | } 26 | } 27 | 28 | export {} 29 | -------------------------------------------------------------------------------- /modules/pwa/types.ts: -------------------------------------------------------------------------------- 1 | import type { VitePWAOptions } from 'vite-plugin-pwa' 2 | 3 | export interface VitePWANuxtOptions extends Partial<VitePWAOptions> {} 4 | 5 | declare module '@nuxt/schema' { 6 | interface NuxtConfig { 7 | pwa?: { [K in keyof VitePWANuxtOptions]?: Partial<VitePWANuxtOptions[K]> } 8 | } 9 | interface NuxtOptions { 10 | pwa: VitePWANuxtOptions 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /modules/tauri/runtime/build-info.ts: -------------------------------------------------------------------------------- 1 | export const env = useAppConfig().env 2 | -------------------------------------------------------------------------------- /modules/tauri/runtime/logging.client.ts: -------------------------------------------------------------------------------- 1 | import * as log from 'tauri-plugin-log-api' 2 | 3 | // When running inside Tauri, catch all logs from 3rd party packages and direct them to the unified logging stream 4 | export default defineNuxtPlugin(() => { 5 | // eslint-disable-next-line no-global-assign 6 | console = { 7 | ...console, 8 | trace: log.trace, 9 | debug: log.debug, 10 | log: log.info, 11 | warn: log.warn, 12 | error: log.error, 13 | } 14 | 15 | window.addEventListener('unhandledrejection', err => 16 | log.error(err.reason)) 17 | window.addEventListener('error', err => log.error(err.error), true) 18 | }) 19 | -------------------------------------------------------------------------------- /modules/tauri/runtime/storage-config.ts: -------------------------------------------------------------------------------- 1 | export const driver = undefined 2 | export const fsBase = '' 3 | -------------------------------------------------------------------------------- /modules/tauri/runtime/storage.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'tauri-plugin-store-api' 2 | import { createStorage } from 'unstorage' 3 | 4 | const store = new Store('.servers.dat') 5 | const storage = createStorage() 6 | storage.mount('servers', { 7 | getKeys() { 8 | return store.keys() 9 | }, 10 | async removeItem(key: string) { 11 | await store.delete(key) 12 | }, 13 | clear() { 14 | return store.clear() 15 | }, 16 | hasItem(key: string) { 17 | return store.has(key) 18 | }, 19 | setItem(key: string, value: any) { 20 | return store.set(key, value) 21 | }, 22 | getItem(key: string) { 23 | return store.get(key) 24 | }, 25 | }) 26 | 27 | export function useStorage() { 28 | return storage 29 | } 30 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "dist" 3 | command = "pnpm run build" 4 | 5 | [build.environment] 6 | NODE_OPTIONS = '--max-old-space-size=4096' 7 | 8 | # Redirect to Discord server 9 | [[redirects]] 10 | from = "https://chat.elk.zone" 11 | to = "https://discord.gg/vAZSDU9J" 12 | status = 301 13 | force = true 14 | 15 | # Redirect to Discord server 16 | [[redirects]] 17 | from = "https://code.elk.zone" 18 | to = "https://github.com/elk-zone/elk" 19 | status = 301 20 | force = true 21 | -------------------------------------------------------------------------------- /page-lifecycle.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'page-lifecycle/dist/lifecycle.mjs' { 2 | type PageLifecycleState = 'active' | 'passive' | 'hidden' | 'frozen' | 'terminated' 3 | 4 | interface PageLifecycleEvent extends Event { 5 | newState: PageLifecycleState 6 | oldState: PageLifecycleState 7 | } 8 | interface PageLifecycle extends EventTarget { 9 | get state(): PageLifecycleState 10 | get pageWasDiscarded(): boolean 11 | addUnsavedChanges: (id: symbol | any) => void 12 | removeUnsavedChanges: (id: symbol | any) => void 13 | addEventListener: (type: string, listener: (evt: PageLifecycleEvent) => void) => void 14 | } 15 | const lifecycle: PageLifecycle 16 | export default lifecycle 17 | } 18 | -------------------------------------------------------------------------------- /patches/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/patches/.gitkeep -------------------------------------------------------------------------------- /patches/pinceau.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/index.d.ts b/dist/index.d.ts 2 | index 612f1c7908c2e973870be08c6fba1515e6e2b9ca..445a3b0574c5388b624d537fc17cbf4a08973ded 100644 3 | --- a/dist/index.d.ts 4 | +++ b/dist/index.d.ts 5 | @@ -115,7 +115,7 @@ interface ModuleHooks { 6 | interface ModuleOptions extends PinceauOptions { 7 | } 8 | 9 | -declare module '@vue/runtime-core' { 10 | +declare module 'vue' { 11 | interface ComponentCustomProperties { 12 | $dt: DtFunction; 13 | $pinceau: ComputedRef<string>; 14 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - docs 3 | -------------------------------------------------------------------------------- /public-dev/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/apple-touch-icon.png -------------------------------------------------------------------------------- /public-dev/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/favicon.ico -------------------------------------------------------------------------------- /public-dev/maskable-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/maskable-icon.png -------------------------------------------------------------------------------- /public-dev/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/pwa-192x192.png -------------------------------------------------------------------------------- /public-dev/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/pwa-512x512.png -------------------------------------------------------------------------------- /public-dev/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/pwa-64x64.png -------------------------------------------------------------------------------- /public-staging/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/apple-touch-icon.png -------------------------------------------------------------------------------- /public-staging/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/favicon.ico -------------------------------------------------------------------------------- /public-staging/maskable-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/maskable-icon.png -------------------------------------------------------------------------------- /public-staging/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/pwa-192x192.png -------------------------------------------------------------------------------- /public-staging/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/pwa-512x512.png -------------------------------------------------------------------------------- /public-staging/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/pwa-64x64.png -------------------------------------------------------------------------------- /public-staging/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /docs/* https://docs.elk.zone/:splat 200 2 | /settings/* /index.html 200 3 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/avatars/antfu-100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/antfu-100x100.png -------------------------------------------------------------------------------- /public/avatars/antfu-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/antfu-60x60.png -------------------------------------------------------------------------------- /public/avatars/danielroe-100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/danielroe-100x100.png -------------------------------------------------------------------------------- /public/avatars/danielroe-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/danielroe-60x60.png -------------------------------------------------------------------------------- /public/avatars/patak-dev-100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/patak-dev-100x100.png -------------------------------------------------------------------------------- /public/avatars/patak-dev-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/patak-dev-60x60.png -------------------------------------------------------------------------------- /public/avatars/shuuji3-100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/shuuji3-100x100.png -------------------------------------------------------------------------------- /public/avatars/shuuji3-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/shuuji3-60x60.png -------------------------------------------------------------------------------- /public/avatars/sxzz-100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/sxzz-100x100.png -------------------------------------------------------------------------------- /public/avatars/sxzz-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/sxzz-60x60.png -------------------------------------------------------------------------------- /public/avatars/userquin-100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/userquin-100x100.png -------------------------------------------------------------------------------- /public/avatars/userquin-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/userquin-60x60.png -------------------------------------------------------------------------------- /public/elk-og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/elk-og.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/DM-mono-v10.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/fonts/DM-mono-v10.ttf -------------------------------------------------------------------------------- /public/fonts/DM-sans-v11.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/fonts/DM-sans-v11.ttf -------------------------------------------------------------------------------- /public/fonts/DM-serif-display-v10.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/fonts/DM-serif-display-v10.ttf -------------------------------------------------------------------------------- /public/fonts/homemade-apple-v18.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/fonts/homemade-apple-v18.ttf -------------------------------------------------------------------------------- /public/maskable-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/maskable-icon.png -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/pwa-64x64.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | # Disallow authenticated pages 5 | 6 | Disallow: /intent 7 | Disallow: /settings 8 | Disallow: /blocks 9 | Disallow: /bookmarks 10 | Disallow: /compose 11 | Disallow: /conversations 12 | Disallow: /domain_blocks 13 | Disallow: /favourites 14 | Disallow: /home 15 | Disallow: /mutes 16 | Disallow: /notifications 17 | Disallow: /pinned 18 | Disallow: /search 19 | Disallow: /settings 20 | Disallow: /share-target 21 | 22 | # Wait 1 second between successive requests. 23 | Crawl-delay: 1 24 | -------------------------------------------------------------------------------- /public/screenshots/dark-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/screenshots/dark-1.webp -------------------------------------------------------------------------------- /public/screenshots/light-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/screenshots/light-1.webp -------------------------------------------------------------------------------- /public/shortcuts/compose-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/compose-96x96.png -------------------------------------------------------------------------------- /public/shortcuts/compose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/compose.png -------------------------------------------------------------------------------- /public/shortcuts/home-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/home-96x96.png -------------------------------------------------------------------------------- /public/shortcuts/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/home.png -------------------------------------------------------------------------------- /public/shortcuts/local-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/local-96x96.png -------------------------------------------------------------------------------- /public/shortcuts/local.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/local.png -------------------------------------------------------------------------------- /public/shortcuts/notifications-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/notifications-96x96.png -------------------------------------------------------------------------------- /public/shortcuts/notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/notifications.png -------------------------------------------------------------------------------- /public/shortcuts/settings-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/settings-96x96.png -------------------------------------------------------------------------------- /public/shortcuts/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/settings.png -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | // DON'T REMOVE THIS FILE: IT IS THE OLD sw.js 2 | self.addEventListener('install', (e) => { 3 | self.skipWaiting(); 4 | }); 5 | self.addEventListener('activate', (e) => { 6 | self.registration.unregister() 7 | .then(() => self.clients.matchAll()) 8 | .then((clients) => { 9 | clients.forEach((client) => { 10 | if (client instanceof WindowClient) 11 | client.navigate(client.url); 12 | }); 13 | return Promise.resolve(); 14 | }) 15 | .then(() => { 16 | self.caches.keys().then((cacheNames) => { 17 | Promise.all( 18 | cacheNames.map((cacheName) => { 19 | return self.caches.delete(cacheName); 20 | }) 21 | ); 22 | }) 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /scripts/avatars.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from 'node:fs/promises' 2 | import fs from 'fs-extra' 3 | import { ofetch } from 'ofetch' 4 | import { join, resolve } from 'pathe' 5 | import { elkTeamMembers } from '../app/composables/about' 6 | 7 | const avatarsDir = resolve('./public/avatars/') 8 | 9 | const sizes = [60, 100] 10 | 11 | async function download(url: string, fileName: string) { 12 | console.log('downloading', fileName) 13 | try { 14 | const image = await ofetch(url, { responseType: 'arrayBuffer' }) 15 | await writeFile(fileName, new Uint8Array(image)) 16 | } 17 | catch (err) { 18 | console.error(err) 19 | } 20 | } 21 | 22 | async function fetchAvatars() { 23 | await fs.ensureDir(avatarsDir) 24 | 25 | await Promise.all(elkTeamMembers.reduce((acc, { github }) => { 26 | acc.push(...sizes.map(s => download(`https://github.com/${github}.png?size=${s}`, join(avatarsDir, `${github}-${s}x${s}.png`)))) 27 | return acc 28 | }, [] as Promise<void>[])) 29 | } 30 | 31 | fetchAvatars() 32 | -------------------------------------------------------------------------------- /scripts/generate-themes.ts: -------------------------------------------------------------------------------- 1 | import type { ThemeColors } from '~/composables/settings' 2 | import chroma from 'chroma-js' 3 | 4 | // #cc7d24 -> hcl(67.14,62.19,59.56) 5 | export const themesColor = Array.from( 6 | { length: 9 }, 7 | (_, i) => chroma.hcl((67.14 + i * 40) % 360, 62.19, 59.56).hex(), 8 | ) 9 | 10 | export function getThemeColors(primary: string): ThemeColors { 11 | const c = chroma(primary) 12 | const dc = c.brighten(0.1) 13 | 14 | return { 15 | '--theme-color-name': primary, 16 | 17 | '--c-primary': 'rgb(var(--rgb-primary))', 18 | '--c-primary-active': c.darken(0.5).hex(), 19 | '--c-primary-light': c.alpha(0.5).hex(), 20 | '--c-primary-fade': c.darken(0.1).alpha(0.1).hex(), 21 | '--rgb-primary': c.rgb().join(', '), 22 | 23 | '--c-dark-primary': 'rgb(var(--rgb-dark-primary))', 24 | '--c-dark-primary-active': dc.darken(0.5).hex(), 25 | '--c-dark-primary-light': dc.alpha(0.5).hex(), 26 | '--c-dark-primary-fade': dc.darken(0.1).alpha(0.1).hex(), 27 | '--rgb-dark-primary': c.rgb().join(', '), 28 | } 29 | } 30 | 31 | export const colorsMap = themesColor.map(color => [color, getThemeColors(color)]) 32 | -------------------------------------------------------------------------------- /scripts/prepare.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process' 2 | import fs from 'fs-extra' 3 | import { emojiPrefix, iconifyEmojiPackage } from '../config/emojis' 4 | import { colorsMap } from './generate-themes' 5 | 6 | const dereference = process.platform === 'win32' ? true : undefined 7 | 8 | await fs.copy(`node_modules/${iconifyEmojiPackage}/icons`, `public/emojis/${emojiPrefix}`, { overwrite: true, dereference }) 9 | 10 | await fs.writeJSON('app/constants/themes.json', colorsMap, { spaces: 2, EOL: '\n' }) 11 | await fs.writeFile('app/styles/default-theme.css', `:root {\n${Object.entries(colorsMap[0][1]).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}\n`, { encoding: 'utf-8' }) 12 | -------------------------------------------------------------------------------- /scripts/release.ts: -------------------------------------------------------------------------------- 1 | import Git from 'simple-git' 2 | 3 | const git = Git() 4 | 5 | const hash = await git.revparse(['main']) 6 | 7 | console.log('Checkout release branch') 8 | await git.checkout('release') 9 | 10 | console.log(`Reset to main branch (${hash})`) 11 | await git.reset(['--hard', hash]) 12 | 13 | console.log('Push to release branch') 14 | await git.push(['--force']) 15 | 16 | console.log('Checkout main branch') 17 | await git.checkout('main') 18 | -------------------------------------------------------------------------------- /server/api/[server]/clear.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const { server } = getRouterParams(event) 3 | const { key } = getQuery(event) 4 | 5 | if (key !== String(useRuntimeConfig().adminKey)) 6 | return { status: false, error: 'incorrect key' } 7 | 8 | await deleteApp(server) 9 | 10 | return { status: true } 11 | }) 12 | -------------------------------------------------------------------------------- /server/api/[server]/login.ts: -------------------------------------------------------------------------------- 1 | import { stringifyQuery } from 'ufo' 2 | 3 | export default defineEventHandler(async (event) => { 4 | let { server } = getRouterParams(event) 5 | const { origin, force_login, lang } = await readBody(event) 6 | server = server.toLocaleLowerCase().trim() 7 | const app = await getApp(origin, server) 8 | 9 | if (!app) { 10 | throw createError({ 11 | statusCode: 400, 12 | statusMessage: `App not registered for server: ${server}`, 13 | }) 14 | } 15 | 16 | const query = stringifyQuery({ 17 | client_id: app.client_id, 18 | force_login: force_login === true ? 'true' : 'false', 19 | scope: 'read write follow push', 20 | response_type: 'code', 21 | lang, 22 | redirect_uri: getRedirectURI(origin, server), 23 | }) 24 | 25 | return `https://${server}/oauth/authorize?${query}` 26 | }) 27 | -------------------------------------------------------------------------------- /server/api/[server]/oauth/[origin].ts: -------------------------------------------------------------------------------- 1 | import { stringifyQuery } from 'ufo' 2 | 3 | import { defaultUserAgent } from '~~/server/utils/shared' 4 | 5 | export default defineEventHandler(async (event) => { 6 | let { server, origin } = getRouterParams(event) 7 | server = server.toLocaleLowerCase().trim() 8 | origin = decodeURIComponent(origin) 9 | const app = await getApp(origin, server) 10 | 11 | if (!app) { 12 | throw createError({ 13 | statusCode: 400, 14 | statusMessage: `App not registered for server: ${server}`, 15 | }) 16 | } 17 | 18 | const { code } = getQuery(event) 19 | if (!code) { 20 | throw createError({ 21 | statusCode: 422, 22 | statusMessage: 'Missing authentication code.', 23 | }) 24 | } 25 | 26 | try { 27 | const result: any = await $fetch(`https://${server}/oauth/token`, { 28 | method: 'POST', 29 | headers: { 30 | 'user-agent': defaultUserAgent, 31 | }, 32 | body: { 33 | client_id: app.client_id, 34 | client_secret: app.client_secret, 35 | redirect_uri: getRedirectURI(origin, server), 36 | grant_type: 'authorization_code', 37 | code, 38 | scope: 'read write follow push', 39 | }, 40 | retry: 3, 41 | }) 42 | 43 | const url = `/signin/callback?${stringifyQuery({ server, token: result.access_token, vapid_key: app.vapid_key })}` 44 | await sendRedirect(event, url, 302) 45 | } 46 | catch { 47 | throw createError({ 48 | statusCode: 400, 49 | statusMessage: 'Could not complete log in.', 50 | }) 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /server/api/list-servers.ts: -------------------------------------------------------------------------------- 1 | let servers: string[] 2 | 3 | export default defineEventHandler(async () => { 4 | if (!servers) 5 | servers = await listServers() 6 | return servers 7 | }) 8 | -------------------------------------------------------------------------------- /server/cache-driver.ts: -------------------------------------------------------------------------------- 1 | import type { Driver } from 'unstorage' 2 | import { defineDriver } from 'unstorage' 3 | import memory from 'unstorage/drivers/memory' 4 | 5 | export interface CacheDriverOptions { 6 | driver: Driver 7 | } 8 | 9 | export default defineDriver((driver: Driver = memory()) => { 10 | const memoryDriver = memory() 11 | return { 12 | ...driver, 13 | async hasItem(key: string) { 14 | if (await memoryDriver.hasItem(key, {})) 15 | return true 16 | 17 | return driver.hasItem(key, {}) 18 | }, 19 | async setItem(key: string, value: any, opts: any = {}) { 20 | await Promise.all([ 21 | memoryDriver.setItem?.(key, value, {}), 22 | driver.setItem?.(key, value, opts), 23 | ]) 24 | }, 25 | async getItem(key: string) { 26 | let value = await memoryDriver.getItem(key) 27 | 28 | if (value !== null) 29 | return value 30 | 31 | value = await driver.getItem(key) 32 | memoryDriver.setItem?.(key, value as string, {}) 33 | 34 | return value 35 | }, 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /service-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "WebWorker", "DOM.Iterable"], 5 | "types": ["vite/client"] 6 | }, 7 | "include": ["./"], 8 | "exclude": [] 9 | } 10 | -------------------------------------------------------------------------------- /shared/types/translation-status.ts: -------------------------------------------------------------------------------- 1 | export interface ElkTranslationStatus { 2 | total: number 3 | locales: Record<string, { 4 | percentage: string 5 | total: number 6 | }> 7 | } 8 | -------------------------------------------------------------------------------- /shared/types/utils.ts: -------------------------------------------------------------------------------- 1 | export type Mutable<T> = { 2 | -readonly[P in keyof T]: T[P] 3 | } 4 | 5 | export type Overwrite<T, O> = Omit<T, keyof O> & O 6 | export type MarkNonNullable<T, K extends keyof T> = Overwrite<T, { 7 | [P in K]-?: NonNullable<T[P]> 8 | }> 9 | -------------------------------------------------------------------------------- /shims.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface Process { 4 | test?: boolean 5 | } 6 | } 7 | } 8 | 9 | export {} 10 | -------------------------------------------------------------------------------- /tests/nuxt/html-to-text.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | describe('html-to-text', () => { 4 | it('inline code', () => { 5 | expect(htmlToText('<p>text <code>code</code> inline</p>')) 6 | .toMatchInlineSnapshot('"text `code` inline"') 7 | }) 8 | 9 | it('code block', () => { 10 | expect(htmlToText('<p>text </p><pre><code class="language-js">code</code></pre>')) 11 | .toMatchInlineSnapshot(` 12 | "text 13 | \`\`\`js 14 | code 15 | \`\`\`" 16 | `) 17 | }) 18 | 19 | it('bold & italic', () => { 20 | expect(htmlToText('<p>text <b>bold</b> <em>italic</em></p>')) 21 | .toMatchInlineSnapshot('"text **bold** *italic*"') 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | // We have TypeError: AbortSignal.timeout is not a function when running tests against masto.js v6 2 | if (!AbortSignal.timeout) { 3 | AbortSignal.timeout = (ms) => { 4 | const controller = new AbortController() 5 | setTimeout(() => controller.abort(new DOMException('TimeoutError')), ms) 6 | return controller.signal 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/unit/language.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { matchLanguages } from '../../app/utils/language' 3 | 4 | describe('language', () => { 5 | it('match language', () => { 6 | expect(matchLanguages(['zh-CN', 'zh-TW'], ['zh'])).toMatchInlineSnapshot('"zh-CN"') 7 | expect(matchLanguages(['zh-CN', 'zh-TW'], ['en'])).toMatchInlineSnapshot('null') 8 | 9 | expect(matchLanguages(['zh-CN', 'zh-TW', 'en-US'], ['zh', 'en'])).toMatchInlineSnapshot('"zh-CN"') 10 | expect(matchLanguages(['zh-CN', 'zh-TW', 'en-US'], ['en', 'zh-CN'])).toMatchInlineSnapshot('"en-US"') 11 | expect(matchLanguages(['zh-CN', 'zh-TW', 'en-US'], ['zh-TW', 'en'])).toMatchInlineSnapshot('"zh-TW"') 12 | 13 | expect(matchLanguages(['zh-TW', 'en-US'], ['zh-CN', 'en-GB'])).toMatchInlineSnapshot('"zh-TW"') 14 | expect(matchLanguages(['zh-TW', 'en-GB'], ['ja-JP', 'zh-CN'])).toMatchInlineSnapshot('"zh-TW"') 15 | 16 | expect(matchLanguages(['zh-TW'], ['zh-tw'])).toMatchInlineSnapshot('"zh-TW"') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /tests/unit/permalinks.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { HANDLED_MASTO_URLS } from '~/constants' 3 | 4 | const validPermalinks = [ 5 | 'https://m1as-social34.to.social/@elk', 6 | 'https://m1as-social34.to.social/@elk22/123', 7 | 'https://m1as-social34.to.social/@elk22/objects/123', 8 | 'webtoo.ls/@elk', 9 | ] 10 | 11 | const invalidPermalinks = [ 12 | 'https://webtoo.ls', 13 | 'https://webtoo.ls/elk/123', 14 | ] 15 | 16 | describe('permalinks', () => { 17 | it.each(validPermalinks)('should recognise %s', (url) => { 18 | expect(HANDLED_MASTO_URLS.test(url)).toBe(true) 19 | }) 20 | it.each(invalidPermalinks)('should not recognise %s', (url) => { 21 | expect(HANDLED_MASTO_URLS.test(url)).toBe(false) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineVitestConfig } from '@nuxt/test-utils/config' 2 | import { isCI } from 'std-env' 3 | 4 | export default defineVitestConfig({ 5 | define: { 6 | 'process.test': 'true', 7 | }, 8 | test: { 9 | reporters: isCI ? ['default', 'hanging-process'] : ['default'], 10 | setupFiles: [ 11 | './tests/setup.ts', 12 | ], 13 | environmentOptions: { 14 | nuxt: { 15 | mock: { 16 | indexedDb: true, 17 | intersectionObserver: true, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /vue-compiler-options.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pkg-types' { 2 | interface TSConfig { 3 | // TODO: augment in nuxt 4 | vueCompilerOptions: any 5 | } 6 | } 7 | 8 | export {} 9 | --------------------------------------------------------------------------------