diff options
Diffstat (limited to 'src')
94 files changed, 4757 insertions, 578 deletions
diff --git a/src/App.scss b/src/App.scss index 3f352e8d..ef68ac50 100644 --- a/src/App.scss +++ b/src/App.scss @@ -645,6 +645,20 @@ option { } } +.cards-list { + list-style: none; + display: grid; + grid-auto-flow: row dense; + grid-template-columns: 1fr 1fr; + + li { + border: 1px solid var(--border); + border-radius: var(--inputRadius); + padding: 0.5em; + margin: 0.25em; + } +} + .btn-block { display: block; width: 100%; @@ -655,16 +669,19 @@ option { display: inline-flex; vertical-align: middle; - button { + button, + .button-dropdown { position: relative; flex: 1 1 auto; - &:not(:last-child) { + &:not(:last-child), + &:not(:last-child) .button-default { border-top-right-radius: 0; border-bottom-right-radius: 0; } - &:not(:first-child) { + &:not(:first-child), + &:not(:first-child) .button-default { border-top-left-radius: 0; border-bottom-left-radius: 0; } diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 9c1f007b..395d4834 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -259,6 +259,7 @@ const getNodeInfo = async ({ store }) => { store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) + store.dispatch('setInstanceOption', { name: 'quotingAvailable', value: features.includes('quote_posting') }) const uploadLimits = metadata.uploadLimits store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) }) diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index 42f89be9..b8b77e7c 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -1,7 +1,7 @@ <template> <label class="checkbox" - :class="{ disabled, indeterminate }" + :class="{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }" > <input type="checkbox" @@ -14,6 +14,7 @@ <i class="checkbox-indicator" :aria-hidden="true" + @transitionend.capture="onTransitionEnd" /> <span v-if="!!$slots.default" @@ -31,7 +32,24 @@ export default { 'indeterminate', 'disabled' ], - emits: ['update:modelValue'] + emits: ['update:modelValue'], + data: (vm) => ({ + indeterminateTransitionFix: vm.indeterminate + }), + watch: { + indeterminate (e) { + if (e) { + this.indeterminateTransitionFix = true + } + } + }, + methods: { + onTransitionEnd (e) { + if (!this.indeterminate) { + this.indeterminateTransitionFix = false + } + } + } } </script> @@ -98,6 +116,12 @@ export default { } } + &.indeterminate-fix { + input[type="checkbox"] + .checkbox-indicator::before { + content: "–"; + } + } + & > span { margin-left: 0.5em; } diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js index 745b1a81..f6a2e294 100644 --- a/src/components/desktop_nav/desktop_nav.js +++ b/src/components/desktop_nav/desktop_nav.js @@ -107,7 +107,10 @@ export default { this.searchBarHidden = hidden }, openSettingsModal () { - this.$store.dispatch('openSettingsModal') + this.$store.dispatch('openSettingsModal', 'user') + }, + openAdminModal () { + this.$store.dispatch('openSettingsModal', 'admin') } } } diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index dc8bbfd3..49382f8e 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -48,20 +48,19 @@ icon="cog" /> </button> - <a + <button v-if="currentUser && currentUser.role === 'admin'" - href="/pleroma/admin/#/login-pleroma" - class="nav-icon" + class="button-unstyled nav-icon" target="_blank" :title="$t('nav.administration')" - @click.stop + @click.stop="openAdminModal" > <FAIcon fixed-width class="fa-scale-110 fa-old-padding" icon="tachometer-alt" /> - </a> + </button> <span class="spacer" /> <button v-if="currentUser" diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index 349b043d..30c01aa5 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -105,6 +105,7 @@ const EmojiPicker = { default: false } }, + inject: ['popoversZLayer'], data () { return { keyword: '', @@ -350,6 +351,9 @@ const EmojiPicker = { return emoji.displayText } + }, + isInModal () { + return this.popoversZLayer === 'modals' } } } diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 6972164b..b8d33309 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -9,8 +9,14 @@ > <template #content> <div class="heading"> + <!-- + Body scroll lock needs to be on every scrollable element on safari iOS. + Here we tell it to enable scrolling for this element. + See https://github.com/willmcpo/body-scroll-lock#vanilla-js + --> <span ref="header" + v-body-scroll-lock="isInModal" class="emoji-tabs" > <span @@ -22,6 +28,7 @@ active: activeGroupView === group.id }" :title="group.text" + role="button" @click.prevent="highlight(group.id)" > <span @@ -75,8 +82,10 @@ @input="$event.target.composing = false" > </div> + <!-- Enables scrolling for this element on safari iOS. See comments for header. --> <DynamicScroller ref="emoji-groups" + v-body-scroll-lock="isInModal" class="emoji-groups" :class="groupsScrolledClass" :min-item-size="minItemSize" @@ -108,6 +117,7 @@ :key="group.id + emoji.displayText" :title="maybeLocalizedEmojiName(emoji)" class="emoji-item" + role="button" @click.stop.prevent="onEmoji(emoji)" > <span @@ -118,6 +128,7 @@ v-else class="emoji-picker-emoji -custom" loading="lazy" + :alt="maybeLocalizedEmojiName(emoji)" :src="emoji.imageUrl" :data-emoji-name="group.id + emoji.displayText" /> diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js index bb11b840..4d5c6c5a 100644 --- a/src/components/emoji_reactions/emoji_reactions.js +++ b/src/components/emoji_reactions/emoji_reactions.js @@ -1,5 +1,17 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faPlus, + faMinus, + faCheck +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faPlus, + faMinus, + faCheck +) const EMOJI_REACTION_COUNT_CUTOFF = 12 @@ -33,6 +45,9 @@ const EmojiReactions = { }, loggedIn () { return !!this.$store.state.users.currentUser + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) } }, methods: { @@ -42,10 +57,10 @@ const EmojiReactions = { reactedWith (emoji) { return this.status.emoji_reactions.find(r => r.name === emoji).me }, - fetchEmojiReactionsByIfMissing () { + async fetchEmojiReactionsByIfMissing () { const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts) if (hasNoAccounts) { - this.$store.dispatch('fetchEmojiReactionsBy', this.status.id) + return await this.$store.dispatch('fetchEmojiReactionsBy', this.status.id) } }, reactWith (emoji) { @@ -54,14 +69,26 @@ const EmojiReactions = { unreact (emoji) { this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji }) }, - emojiOnClick (emoji, event) { + async emojiOnClick (emoji, event) { if (!this.loggedIn) return + await this.fetchEmojiReactionsByIfMissing() if (this.reactedWith(emoji)) { this.unreact(emoji) } else { this.reactWith(emoji) } + }, + counterTriggerAttrs (reaction) { + return { + class: [ + 'btn', + 'button-default', + 'emoji-reaction-count-button', + { '-picked-reaction': this.reactedWith(reaction.name) } + ], + 'aria-label': this.$tc('status.reaction_count_label', reaction.count, { num: reaction.count }) + } } } } diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index eb46018e..c11b338e 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -1,15 +1,19 @@ <template> <div class="EmojiReactions"> - <UserListPopover + <span v-for="(reaction) in emojiReactions" :key="reaction.url || reaction.name" - :users="accountsForEmoji[reaction.name]" + class="emoji-reaction-container btn-group" > - <button + <component + :is="loggedIn ? 'button' : 'a'" + v-bind="!loggedIn ? { href: remoteInteractionLink } : {}" + role="button" class="emoji-reaction btn button-default" - :class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" + :class="{ '-picked-reaction': reactedWith(reaction.name) }" + :title="reaction.url ? reaction.name : undefined" + :aria-pressed="reactedWith(reaction.name)" @click="emojiOnClick(reaction.name, $event)" - @mouseenter="fetchEmojiReactionsByIfMissing()" > <span class="reaction-emoji" @@ -17,7 +21,6 @@ <img v-if="reaction.url" :src="reaction.url" - :title="reaction.name" class="reaction-emoji-content" width="1em" > @@ -26,9 +29,36 @@ class="reaction-emoji reaction-emoji-content" >{{ reaction.name }}</span> </span> - <span>{{ reaction.count }}</span> - </button> - </UserListPopover> + <FALayers> + <FAIcon + v-if="reactedWith(reaction.name)" + class="active-marker" + transform="shrink-6 up-9" + icon="check" + /> + <FAIcon + v-if="!reactedWith(reaction.name)" + class="focus-marker" + transform="shrink-6 up-9" + icon="plus" + /> + <FAIcon + v-else + class="focus-marker" + transform="shrink-6 up-9" + icon="minus" + /> + </FALayers> + </component> + <UserListPopover + :users="accountsForEmoji[reaction.name]" + class="emoji-reaction-popover" + :trigger-attrs="counterTriggerAttrs(reaction)" + @show="fetchEmojiReactionsByIfMissing()" + > + <span class="emoji-reaction-counts">{{ reaction.count }}</span> + </UserListPopover> + </span> <a v-if="tooManyReactions" class="emoji-reaction-expand faint" @@ -43,6 +73,7 @@ <script src="./emoji_reactions.js"></script> <style lang="scss"> @import "../../variables"; +@import "../../mixins"; .EmojiReactions { display: flex; @@ -51,14 +82,46 @@ --emoji-size: calc(1.25em * var(--emojiReactionsScale, 1)); - .emoji-reaction { - padding: 0 0.5em; - margin-right: 0.5em; + .emoji-reaction-container { + display: flex; + align-items: stretch; margin-top: 0.5em; + margin-right: 0.5em; + + .emoji-reaction-popover { + padding: 0; + + .emoji-reaction-count-button { + background-color: var(--btn); + margin: 0; + height: 100%; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + box-sizing: border-box; + min-width: 2em; + display: inline-flex; + justify-content: center; + align-items: center; + color: $fallback--text; + color: var(--btnText, $fallback--text); + + &.-picked-reaction { + border: 1px solid var(--accent, $fallback--link); + margin-right: -1px; + } + } + } + } + + .emoji-reaction { + padding-left: 0.5em; display: flex; align-items: center; justify-content: center; box-sizing: border-box; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin: 0; .reaction-emoji { width: var(--emoji-size); @@ -85,19 +148,45 @@ outline: none; } - &.not-clickable { - cursor: default; - - &:hover { - box-shadow: $fallback--buttonShadow; - box-shadow: var(--buttonShadow); - } + .svg-inline--fa { + color: $fallback--text; + color: var(--btnText, $fallback--text); } &.-picked-reaction { border: 1px solid var(--accent, $fallback--link); margin-left: -1px; // offset the border, can't use inset shadows either - margin-right: calc(0.5em - 1px); + margin-right: -1px; + + .svg-inline--fa { + color: $fallback--link; + color: var(--accent, $fallback--link); + } + } + + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + + .active-marker { + visibility: visible; + } + } + + @include focused-style { + .svg-inline--fa { + color: $fallback--link; + color: var(--accent, $fallback--link); + } + + .focus-marker { + visibility: visible; + } + + .active-marker { + visibility: hidden; + } } } diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index a57e8761..364791a1 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -36,7 +36,9 @@ <button class="button-default btn" @click="addLanguage" - >{{ $t('settings.add_language') }}</button> + > + {{ $t('settings.add_language') }} + </button> </li> </ul> </div> diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index cfd42d4c..8c9e5f71 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -23,6 +23,11 @@ const mediaUpload = { } }, methods: { + onClick () { + if (this.uploadReady) { + this.$refs.input.click() + } + }, uploadFile (file) { const self = this const store = this.$store @@ -69,10 +74,15 @@ const mediaUpload = { this.multiUpload(target.files) } }, - props: [ - 'dropFiles', - 'disabled' - ], + props: { + dropFiles: Object, + disabled: Boolean, + normalButton: Boolean, + acceptTypes: { + type: String, + default: '*/*' + } + }, watch: { dropFiles: function (fileInfos) { if (!this.uploading) { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 2799495b..2ea5567a 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -1,8 +1,9 @@ <template> - <label + <button class="media-upload" - :class="{ disabled: disabled }" + :class="[normalButton ? 'button-default btn' : 'button-unstyled', { disabled }]" :title="$t('tool_tip.media_upload')" + @click="onClick" > <FAIcon v-if="uploading" @@ -15,15 +16,21 @@ class="new-icon" icon="upload" /> + <template v-if="normalButton"> + {{ ' ' }} + {{ uploading ? $t('general.loading') : $t('tool_tip.media_upload') }} + </template> <input v-if="uploadReady" + ref="input" class="hidden-input-file" :disabled="disabled" type="file" multiple="true" + :accept="acceptTypes" @change="change" > - </label> + </button> </template> <script src="./media_upload.js"></script> @@ -32,10 +39,12 @@ @import "../../variables"; .media-upload { - cursor: pointer; // We use <label> for interactivity... i wonder if it's fine - .hidden-input-file { display: none; } } - </style> + +label.media-upload { + cursor: pointer; // We use <label> for interactivity... i wonder if it's fine +} +</style> diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 4d801c5e..6b3315f9 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -125,7 +125,8 @@ v-if="notification.emoji_url" class="emoji-reaction-emoji emoji-reaction-emoji-image" :src="notification.emoji_url" - :name="notification.emoji" + :alt="notification.emoji" + :title="notification.emoji" > <span v-else @@ -162,8 +163,8 @@ </router-link> <button class="button-unstyled expand-icon" - :aria-expanded="statusExpanded" :title="$t('tool_tip.toggle_expand')" + :aria-expanded="statusExpanded" @click.prevent="toggleStatusExpanded" > <FAIcon diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 61f7317e..5749e430 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -136,6 +136,7 @@ .emoji-reaction-emoji-image { vertical-align: middle; + object-fit: contain; } .notification-details { diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index d44b266b..bc078533 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -45,6 +45,9 @@ const Popover = { // Lets hover popover stay when clicking inside of it stayOnClick: Boolean, + // Use styled button (to avoid nested buttons) + normalButton: Boolean, + triggerAttrs: { type: Object, default: {} diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index fd0fd821..1a4bffd9 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -5,7 +5,8 @@ > <button ref="trigger" - class="button-unstyled popover-trigger-button" + class="popover-trigger-button" + :class="normalButton ? 'button-default btn' : 'button-unstyled'" type="button" v-bind="triggerAttrs" @click="onClick" diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index b75fee69..ba49961d 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -156,11 +156,13 @@ const PostStatusForm = { poll: this.statusPoll || {}, mediaDescriptions: this.statusMediaDescriptions || {}, visibility: this.statusScope || scope, - contentType: statusContentType + contentType: statusContentType, + quoting: false } } return { + randomSeed: `${Math.random()}`.replace('.', '-'), dropFiles: [], uploadingFiles: false, error: null, @@ -265,6 +267,30 @@ const PostStatusForm = { isEdit () { return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' }, + quotable () { + if (!this.$store.state.instance.quotingAvailable) { + return false + } + + if (!this.replyTo) { + return false + } + + const repliedStatus = this.$store.state.statuses.allStatusesObject[this.replyTo] + if (!repliedStatus) { + return false + } + + if (repliedStatus.visibility === 'public' || + repliedStatus.visibility === 'unlisted' || + repliedStatus.visibility === 'local') { + return true + } else if (repliedStatus.visibility === 'private') { + return repliedStatus.user.id === this.$store.state.users.currentUser.id + } + + return false + }, ...mapGetters(['mergedConfig']), ...mapState({ mobileLayout: state => state.interface.mobileLayout @@ -292,7 +318,8 @@ const PostStatusForm = { visibility: newStatus.visibility, contentType: newStatus.contentType, poll: {}, - mediaDescriptions: {} + mediaDescriptions: {}, + quoting: false } this.pollFormVisible = false this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile() @@ -340,6 +367,8 @@ const PostStatusForm = { return } + const replyOrQuoteAttr = newStatus.quoting ? 'quoteId' : 'inReplyToStatusId' + const postingOptions = { status: newStatus.status, spoilerText: newStatus.spoilerText || null, @@ -347,7 +376,7 @@ const PostStatusForm = { sensitive: newStatus.nsfw, media: newStatus.files, store: this.$store, - inReplyToStatusId: this.replyTo, + [replyOrQuoteAttr]: this.replyTo, contentType: newStatus.contentType, poll, idempotencyKey: this.idempotencyKey @@ -373,6 +402,7 @@ const PostStatusForm = { } const newStatus = this.newStatus this.previewLoading = true + const replyOrQuoteAttr = newStatus.quoting ? 'quoteId' : 'inReplyToStatusId' statusPoster.postStatus({ status: newStatus.status, spoilerText: newStatus.spoilerText || null, @@ -380,7 +410,7 @@ const PostStatusForm = { sensitive: newStatus.nsfw, media: [], store: this.$store, - inReplyToStatusId: this.replyTo, + [replyOrQuoteAttr]: this.replyTo, contentType: newStatus.contentType, poll: {}, preview: true diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 86c1f907..9b108a5a 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -126,6 +126,36 @@ class="preview-status" /> </div> + <div + v-if="quotable" + role="radiogroup" + class="btn-group reply-or-quote-selector" + > + <button + :id="`reply-or-quote-option-${randomSeed}-reply`" + class="btn button-default reply-or-quote-option" + :class="{ toggled: !newStatus.quoting }" + tabindex="0" + role="radio" + :aria-labelledby="`reply-or-quote-option-${randomSeed}-reply`" + :aria-checked="!newStatus.quoting" + @click="newStatus.quoting = false" + > + {{ $t('post_status.reply_option') }} + </button> + <button + :id="`reply-or-quote-option-${randomSeed}-quote`" + class="btn button-default reply-or-quote-option" + :class="{ toggled: newStatus.quoting }" + tabindex="0" + role="radio" + :aria-labelledby="`reply-or-quote-option-${randomSeed}-quote`" + :aria-checked="newStatus.quoting" + @click="newStatus.quoting = true" + > + {{ $t('post_status.quote_option') }} + </button> + </div> <EmojiInput v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-model="newStatus.spoilerText" @@ -420,6 +450,10 @@ margin: 0; } + .reply-or-quote-selector { + margin-bottom: 0.5em; + } + .text-format { .only-format { color: $fallback--faint; diff --git a/src/components/post_status_modal/post_status_modal.js b/src/components/post_status_modal/post_status_modal.js index b44354db..8970dd9b 100644 --- a/src/components/post_status_modal/post_status_modal.js +++ b/src/components/post_status_modal/post_status_modal.js @@ -44,6 +44,10 @@ const PostStatusModal = { methods: { closeModal () { this.$store.dispatch('closePostStatusModal') + }, + resetAndClose () { + this.$store.dispatch('resetPostStatusModal') + this.$store.dispatch('closePostStatusModal') } } } diff --git a/src/components/post_status_modal/post_status_modal.vue b/src/components/post_status_modal/post_status_modal.vue index dbcd321e..bc2cad4a 100644 --- a/src/components/post_status_modal/post_status_modal.vue +++ b/src/components/post_status_modal/post_status_modal.vue @@ -12,7 +12,7 @@ <PostStatusForm class="panel-body" v-bind="params" - @posted="closeModal" + @posted="resetAndClose" /> </div> </Modal> diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js index 8eed4b60..0d252155 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -46,7 +46,7 @@ const ReactButton = { }, computed: { hideCustomEmoji () { - return !this.$store.state.instance.pleromaChatMessagesAvailable + return !this.$store.state.instance.pleromaCustomEmojiReactionsAvailable } } } diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx index 7881e365..ff14a58a 100644 --- a/src/components/rich_content/rich_content.jsx +++ b/src/components/rich_content/rich_content.jsx @@ -8,6 +8,27 @@ import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue' import './rich_content.scss' +const MAYBE_LINE_BREAKING_ELEMENTS = [ + 'blockquote', + 'br', + 'hr', + 'ul', + 'ol', + 'li', + 'p', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5' +] + /** * RichContent, The Über-powered component for rendering Post HTML. * @@ -149,7 +170,9 @@ export default { // Handle tag nodes if (Array.isArray(item)) { const [opener, children, closer] = item - const Tag = getTagName(opener) + let Tag = getTagName(opener) + if (Tag.toLowerCase() === 'script') Tag = 'js-exploit' + if (Tag.toLowerCase() === 'style') Tag = 'css-exploit' const fullAttrs = getAttrs(opener, () => true) const attrs = getAttrs(opener) const previouslyMentions = currentMentions !== null @@ -164,25 +187,22 @@ export default { !(children && typeof children[0] === 'string' && children[0].match(/^\s/)) ? lastSpacing : '' - switch (Tag) { - case 'br': + if (MAYBE_LINE_BREAKING_ELEMENTS.includes(Tag)) { + // all the elements that can cause a line change + currentMentions = null + } else if (Tag === 'img') { // replace images with StillImage + return ['', [mentionsLinePadding, renderImage(opener)], ''] + } else if (Tag === 'a' && this.handleLinks) { // replace mentions with MentionLink + if (fullAttrs.class && fullAttrs.class.includes('mention')) { + // Handling mentions here + return renderMention(attrs, children) + } else { currentMentions = null - break - case 'img': // replace images with StillImage - return ['', [mentionsLinePadding, renderImage(opener)], ''] - case 'a': // replace mentions with MentionLink - if (!this.handleLinks) break - if (fullAttrs.class && fullAttrs.class.includes('mention')) { - // Handling mentions here - return renderMention(attrs, children) - } else { - currentMentions = null - break - } - case 'span': - if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) { - return ['', children.map(processItem), ''] - } + } + } else if (Tag === 'span') { + if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) { + return ['', children.map(processItem), ''] + } } if (children !== undefined) { diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.js b/src/components/settings_modal/admin_tabs/frontends_tab.js new file mode 100644 index 00000000..a2c27c2a --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.js @@ -0,0 +1,64 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' +import GroupSetting from '../helpers/group_setting.vue' +import Popover from 'src/components/popover/popover.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const FrontendsTab = { + provide () { + return { + defaultDraftMode: true, + defaultSource: 'admin' + } + }, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting, + GroupSetting, + Popover + }, + created () { + if (this.user.rights.admin) { + this.$store.dispatch('loadFrontendsStuff') + } + }, + computed: { + frontends () { + return this.$store.state.adminSettings.frontends + }, + ...SharedComputedObject() + }, + methods: { + update (frontend, suggestRef) { + const ref = suggestRef || frontend.refs[0] + const { name } = frontend + const payload = { name, ref } + + this.$store.state.api.backendInteractor.installFrontend({ payload }) + .then((externalUser) => { + this.$store.dispatch('loadFrontendsStuff') + }) + }, + setDefault (frontend, suggestRef) { + const ref = suggestRef || frontend.refs[0] + const { name } = frontend + + this.$store.commit('updateAdminDraft', { path: [':pleroma', ':frontends', ':primary'], value: { name, ref } }) + } + } +} + +export default FrontendsTab diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.scss b/src/components/settings_modal/admin_tabs/frontends_tab.scss new file mode 100644 index 00000000..e3e04bc6 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.scss @@ -0,0 +1,13 @@ +.frontends-tab { + .cards-list { + padding: 0; + } + + dd { + text-overflow: ellipsis; + word-wrap: nowrap; + white-space: nowrap; + overflow-x: hidden; + max-width: 10em; + } +} diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.vue b/src/components/settings_modal/admin_tabs/frontends_tab.vue new file mode 100644 index 00000000..13b8fa6b --- /dev/null +++ b/src/components/settings_modal/admin_tabs/frontends_tab.vue @@ -0,0 +1,184 @@ +<template> + <div + class="frontends-tab" + :label="$t('admin_dash.tabs.frontends')" + > + <div class="setting-item"> + <h2>{{ $t('admin_dash.tabs.frontends') }}</h2> + <p>{{ $t('admin_dash.frontend.wip_notice') }}</p> + <ul class="setting-list"> + <li> + <h3>{{ $t('admin_dash.frontend.default_frontend') }}</h3> + <p>{{ $t('admin_dash.frontend.default_frontend_tip') }}</p> + <p>{{ $t('admin_dash.frontend.default_frontend_tip2') }}</p> + <ul class="setting-list"> + <li> + <StringSetting path=":pleroma.:frontends.:primary.name" /> + </li> + <li> + <StringSetting path=":pleroma.:frontends.:primary.ref" /> + </li> + <li> + <GroupSetting path=":pleroma.:frontends.:primary" /> + </li> + </ul> + </li> + </ul> + <div class="setting-list"> + <h3>{{ $t('admin_dash.frontend.available_frontends') }}</h3> + <ul class="cards-list"> + <li + v-for="frontend in frontends" + :key="frontend.name" + > + <strong>{{ frontend.name }}</strong> + {{ ' ' }} + <span v-if="adminDraft[':pleroma'][':frontends'][':primary'].name === frontend.name"> + <i18n-t + v-if="adminDraft[':pleroma'][':frontends'][':primary'].ref === frontend.refs[0]" + keypath="admin_dash.frontend.is_default" + /> + <i18n-t + v-else + keypath="admin_dash.frontend.is_default_custom" + > + <template #version> + <code>{{ adminDraft[':pleroma'][':frontends'][':primary'].ref }}</code> + </template> + </i18n-t> + </span> + <dl> + <dt>{{ $t('admin_dash.frontend.repository') }}</dt> + <dd> + <a + :href="frontend.git" + target="_blank" + >{{ frontend.git }}</a> + </dd> + <template v-if="expertLevel"> + <dt>{{ $t('admin_dash.frontend.versions') }}</dt> + <dd + v-for="ref in frontend.refs" + :key="ref" + > + <code>{{ ref }}</code> + </dd> + </template> + <dt v-if="expertLevel"> + {{ $t('admin_dash.frontend.build_url') }} + </dt> + <dd v-if="expertLevel"> + <a + :href="frontend.build_url" + target="_blank" + >{{ frontend.build_url }}</a> + </dd> + </dl> + <div> + <span class="btn-group"> + <button + class="button button-default btn" + type="button" + @click="update(frontend)" + > + {{ + frontend.installed + ? $t('admin_dash.frontend.reinstall') + : $t('admin_dash.frontend.install') + }} + </button> + <Popover + v-if="frontend.refs.length > 1" + trigger="click" + class="button-dropdown" + placement="bottom" + > + <template #content> + <div class="dropdown-menu"> + <button + v-for="ref in frontend.refs" + :key="ref" + class="button-default dropdown-item" + @click="update(frontend, ref)" + > + <i18n-t keypath="admin_dash.frontend.install_version"> + <template #version> + <code>{{ ref }}</code> + </template> + </i18n-t> + </button> + </div> + </template> + <template #trigger> + <button + class="button button-default btn dropdown-button" + type="button" + :title="$t('admin_dash.frontend.more_install_options')" + > + <FAIcon icon="chevron-down" /> + </button> + </template> + </Popover> + </span> + <span + v-if="frontend.installed && frontend.name !== 'admin-fe'" + class="btn-group" + > + <button + class="button button-default btn" + type="button" + :disabled=" + adminDraft[':pleroma'][':frontends'][':primary'].name === frontend.name && + adminDraft[':pleroma'][':frontends'][':primary'].ref === frontend.refs[0] + " + @click="setDefault(frontend)" + > + {{ + $t('admin_dash.frontend.set_default') + }} + </button> + {{ ' ' }} + <Popover + v-if="frontend.refs.length > 1" + trigger="click" + class="button-dropdown" + placement="bottom" + > + <template #content> + <div class="dropdown-menu"> + <button + v-for="ref in frontend.refs.slice(1)" + :key="ref" + class="button-default dropdown-item" + @click="setDefault(frontend, ref)" + > + <i18n-t keypath="admin_dash.frontend.set_default_version"> + <template #version> + <code>{{ ref }}</code> + </template> + </i18n-t> + </button> + </div> + </template> + <template #trigger> + <button + class="button button-default btn dropdown-button" + type="button" + :title="$t('admin_dash.frontend.more_default_options')" + > + <FAIcon icon="chevron-down" /> + </button> + </template> + </Popover> + </span> + </div> + </li> + </ul> + </div> + </div> + </div> +</template> + +<script src="./frontends_tab.js"></script> + +<style lang="scss" src="./frontends_tab.scss"></style> diff --git a/src/components/settings_modal/admin_tabs/instance_tab.js b/src/components/settings_modal/admin_tabs/instance_tab.js new file mode 100644 index 00000000..b07bafe8 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/instance_tab.js @@ -0,0 +1,38 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' +import GroupSetting from '../helpers/group_setting.vue' +import AttachmentSetting from '../helpers/attachment_setting.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const InstanceTab = { + provide () { + return { + defaultDraftMode: true, + defaultSource: 'admin' + } + }, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting, + AttachmentSetting, + GroupSetting + }, + computed: { + ...SharedComputedObject() + } +} + +export default InstanceTab diff --git a/src/components/settings_modal/admin_tabs/instance_tab.vue b/src/components/settings_modal/admin_tabs/instance_tab.vue new file mode 100644 index 00000000..a6be776b --- /dev/null +++ b/src/components/settings_modal/admin_tabs/instance_tab.vue @@ -0,0 +1,196 @@ +<template> + <div :label="$t('admin_dash.tabs.instance')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.instance') }}</h2> + <ul class="setting-list"> + <li> + <StringSetting path=":pleroma.:instance.:name" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:email" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:description" /> + </li> + <li> + <StringSetting path=":pleroma.:instance.:short_description" /> + </li> + <li> + <AttachmentSetting path=":pleroma.:instance.:instance_thumbnail" /> + </li> + <li> + <AttachmentSetting path=":pleroma.:instance.:background_image" /> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.registrations') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path=":pleroma.:instance.:registrations_open" /> + <ul class="setting-list suboptions"> + <li> + <BooleanSetting + path=":pleroma.:instance.:invites_enabled" + parent-path=":pleroma.:instance.:registrations_open" + parent-invert + /> + </li> + </ul> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:birthday_required" /> + <ul class="setting-list suboptions"> + <li> + <IntegerSetting + path=":pleroma.:instance.:birthday_min_age" + parent-path=":pleroma.:instance.:birthday_required" + /> + </li> + </ul> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:account_activation_required" /> + </li> + <li> + <BooleanSetting path=":pleroma.:instance.:account_approval_required" /> + </li> + <li> + <h3>{{ $t('admin_dash.instance.captcha_header') }}</h3> + <ul class="setting-list"> + <li> + <BooleanSetting :path="[':pleroma', 'Pleroma.Captcha', ':enabled']" /> + <ul class="setting-list suboptions"> + <li> + <ChoiceSetting + :path="[':pleroma', 'Pleroma.Captcha', ':method']" + :parent-path="[':pleroma', 'Pleroma.Captcha', ':enabled']" + :option-label-map="{ + 'Pleroma.Captcha.Native': $t('admin_dash.captcha.native'), + 'Pleroma.Captcha.Kocaptcha': $t('admin_dash.captcha.kocaptcha') + }" + /> + <IntegerSetting + :path="[':pleroma', 'Pleroma.Captcha', ':seconds_valid']" + :parent-path="[':pleroma', 'Pleroma.Captcha', ':enabled']" + /> + </li> + <li + v-if="adminDraft[':pleroma']['Pleroma.Captcha'][':enabled'] && adminDraft[':pleroma']['Pleroma.Captcha'][':method'] === 'Pleroma.Captcha.Kocaptcha'" + > + <h4>{{ $t('admin_dash.instance.kocaptcha') }}</h4> + <ul class="setting-list"> + <li> + <StringSetting :path="[':pleroma', 'Pleroma.Captcha.Kocaptcha', ':endpoint']" /> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('admin_dash.instance.access') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting + override-backend-description + override-backend-description-label + path=":pleroma.:instance.:public" + /> + </li> + <li> + <ChoiceSetting + override-backend-description + override-backend-description-label + path=":pleroma.:instance.:limit_to_local_content" + /> + </li> + <li v-if="expertLevel"> + <h3>{{ $t('admin_dash.instance.restrict.header') }}</h3> + <p> + {{ $t('admin_dash.instance.restrict.description') }} + </p> + <ul class="setting-list"> + <li> + <h4>{{ $t('admin_dash.instance.restrict.timelines') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:timelines.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:timelines.:federated" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:timelines" /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.profiles') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:profiles.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:profiles.:remote" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:profiles" /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.instance.restrict.activities') }}</h4> + <ul class="setting-list"> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:activities.:local" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <BooleanSetting + path=":pleroma.:restrict_unauthenticated.:activities.:remote" + indeterminate-state=":if_instance_is_private" + swap-description-and-label + hide-description + /> + </li> + <li> + <GroupSetting path=":pleroma.:restrict_unauthenticated.:activities" /> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </div> + </div> +</template> + +<script src="./instance_tab.js"></script> diff --git a/src/components/settings_modal/admin_tabs/limits_tab.js b/src/components/settings_modal/admin_tabs/limits_tab.js new file mode 100644 index 00000000..684739c3 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/limits_tab.js @@ -0,0 +1,29 @@ +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import StringSetting from '../helpers/string_setting.vue' + +import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faGlobe +) + +const LimitsTab = { + data () {}, + components: { + BooleanSetting, + ChoiceSetting, + IntegerSetting, + StringSetting + }, + computed: { + ...SharedComputedObject() + } +} + +export default LimitsTab diff --git a/src/components/settings_modal/admin_tabs/limits_tab.vue b/src/components/settings_modal/admin_tabs/limits_tab.vue new file mode 100644 index 00000000..ef4b9271 --- /dev/null +++ b/src/components/settings_modal/admin_tabs/limits_tab.vue @@ -0,0 +1,136 @@ +<template> + <div :label="$t('admin_dash.tabs.limits')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.limits.arbitrary_limits') }}</h2> + <ul class="setting-list"> + <li> + <h3>{{ $t('admin_dash.limits.posts') }}</h3> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:remote_limit" + expert="1" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h3>{{ $t('admin_dash.limits.uploads') }}</h3> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:description_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:upload_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_media_attachments" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h3>{{ $t('admin_dash.limits.users') }}</h3> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_pinned_statuses" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:user_bio_length" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:user_name_length" + draft-mode + /> + </li> + <li> + <h4>{{ $t('admin_dash.limits.profile_fields') }}</h4> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_account_fields" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:max_remote_account_fields" + draft-mode + expert="1" + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:account_field_name_length" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:account_field_value_length" + draft-mode + /> + </li> + </ul> + </li> + <li> + <h4>{{ $t('admin_dash.limits.user_uploads') }}</h4> + <ul class="setting-list"> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:avatar_upload_limit" + draft-mode + /> + </li> + <li> + <IntegerSetting + source="admin" + path=":pleroma.:instance.:banner_upload_limit" + draft-mode + /> + </li> + </ul> + </li> + </ul> + </li> + </ul> + </div> + </div> +</template> + +<script src="./limits_tab.js"></script> diff --git a/src/components/settings_modal/helpers/attachment_setting.js b/src/components/settings_modal/helpers/attachment_setting.js new file mode 100644 index 00000000..ac5c6f86 --- /dev/null +++ b/src/components/settings_modal/helpers/attachment_setting.js @@ -0,0 +1,43 @@ +import Setting from './setting.js' +import { fileTypeExt } from 'src/services/file_type/file_type.service.js' +import MediaUpload from 'src/components/media_upload/media_upload.vue' +import Attachment from 'src/components/attachment/attachment.vue' + +export default { + ...Setting, + props: { + ...Setting.props, + acceptTypes: { + type: String, + required: false, + default: 'image/*' + } + }, + components: { + ...Setting.components, + MediaUpload, + Attachment + }, + computed: { + ...Setting.computed, + attachment () { + const path = this.realDraftMode ? this.draft : this.state + // The "server" part is primarily for local dev, but could be useful for alt-domain or multiuser usage. + const url = path.includes('://') ? path : this.$store.state.instance.server + path + return { + mimetype: fileTypeExt(url), + url + } + } + }, + methods: { + ...Setting.methods, + setMediaFile (fileInfo) { + if (this.realDraftMode) { + this.draft = fileInfo.url + } else { + this.configSink(this.path, fileInfo.url) + } + } + } +} diff --git a/src/components/settings_modal/helpers/attachment_setting.vue b/src/components/settings_modal/helpers/attachment_setting.vue new file mode 100644 index 00000000..bbc5172c --- /dev/null +++ b/src/components/settings_modal/helpers/attachment_setting.vue @@ -0,0 +1,96 @@ +<template> + <span + v-if="matchesExpertLevel" + class="AttachmentSetting" + > + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> + + </label> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> + <div class="attachment-input"> + <div>{{ $t('settings.url') }}</div> + <div class="controls"> + <input + :id="path" + class="string-input" + :disabled="shouldBeDisabled" + :value="realDraftMode ? draft : state" + @change="update" + > + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + </div> + <div>{{ $t('settings.preview') }}</div> + <Attachment + class="attachment" + :compact="compact" + :attachment="attachment" + size="small" + hide-description + @setMedia="onMedia" + @naturalSizeLoad="onNaturalSizeLoad" + /> + <div class="controls"> + <MediaUpload + ref="mediaUpload" + class="media-upload-icon" + :drop-files="dropFiles" + normal-button + :accept-types="acceptTypes" + @uploaded="setMediaFile" + @upload-failed="uploadFailed" + /> + </div> + </div> + <DraftButtons /> + </span> +</template> + +<script src="./attachment_setting.js"></script> + +<style lang="scss"> +.AttachmentSetting { + .attachment { + display: block; + width: 100%; + height: 15em; + margin-bottom: 0.5em; + } + + .attachment-input { + margin-left: 1em; + display: flex; + flex-direction: column; + width: 20em; + } + + .controls { + margin-bottom: 0.5em; + + input, + button { + width: 100%; + } + } +} +</style> diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js index 2e6992cb..199d3d0f 100644 --- a/src/components/settings_modal/helpers/boolean_setting.js +++ b/src/components/settings_modal/helpers/boolean_setting.js @@ -1,56 +1,31 @@ -import { get, set } from 'lodash' import Checkbox from 'src/components/checkbox/checkbox.vue' -import ModifiedIndicator from './modified_indicator.vue' -import ServerSideIndicator from './server_side_indicator.vue' +import Setting from './setting.js' + export default { + ...Setting, + props: { + ...Setting.props, + indeterminateState: [String, Object] + }, components: { - Checkbox, - ModifiedIndicator, - ServerSideIndicator + ...Setting.components, + Checkbox }, - props: [ - 'path', - 'disabled', - 'expert' - ], computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isServerSide () { - return this.path.startsWith('serverSide_') - }, - isChanged () { - return !this.path.startsWith('serverSide_') && this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + ...Setting.computed, + isIndeterminate () { + return this.visibleState === this.indeterminateState } }, methods: { - update (e) { - const [firstSegment, ...rest] = this.path.split('.') - set(this.$parent, this.path, e) - // Updating nested properties does not trigger update on its parent. - // probably still not as reliable, but works for depth=1 at least - if (rest.length > 0) { - set(this.$parent, firstSegment, { ...get(this.$parent, firstSegment) }) + ...Setting.methods, + getValue (e) { + // Basic tri-state toggle implementation + if (!!this.indeterminateState && !e && this.visibleState === true) { + // If we have indeterminate state, switching from true to false first goes through indeterminate + return this.indeterminateState } - }, - reset () { - set(this.$parent, this.path, this.defaultState) + return e } } } diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue index 41142966..5a9eab34 100644 --- a/src/components/settings_modal/helpers/boolean_setting.vue +++ b/src/components/settings_modal/helpers/boolean_setting.vue @@ -4,23 +4,37 @@ class="BooleanSetting" > <Checkbox - :model-value="state" - :disabled="disabled" + :model-value="visibleState" + :disabled="shouldBeDisabled" + :indeterminate="isIndeterminate" @update:modelValue="update" > <span - v-if="!!$slots.default" class="label" + :class="{ 'faint': shouldBeDisabled }" > - <slot /> + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> </span> - {{ ' ' }} - <ModifiedIndicator - :changed="isChanged" - :onclick="reset" - /> - <ServerSideIndicator :server-side="isServerSide" /> </Checkbox> + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> </label> </template> diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js index 3da559fe..bdeece76 100644 --- a/src/components/settings_modal/helpers/choice_setting.js +++ b/src/components/settings_modal/helpers/choice_setting.js @@ -1,51 +1,41 @@ -import { get, set } from 'lodash' import Select from 'src/components/select/select.vue' -import ModifiedIndicator from './modified_indicator.vue' -import ServerSideIndicator from './server_side_indicator.vue' +import Setting from './setting.js' + export default { + ...Setting, components: { - Select, - ModifiedIndicator, - ServerSideIndicator + ...Setting.components, + Select }, - props: [ - 'path', - 'disabled', - 'options', - 'expert' - ], - computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') + props: { + ...Setting.props, + options: { + type: Array, + required: false }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value + optionLabelMap: { + type: Object, + required: false, + default: {} + } + }, + computed: { + ...Setting.computed, + realOptions () { + if (this.realSource === 'admin') { + return this.backendDescriptionSuggestions.map(x => ({ + key: x, + value: x, + label: this.optionLabelMap[x] || x + })) } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isServerSide () { - return this.path.startsWith('serverSide_') - }, - isChanged () { - return !this.path.startsWith('serverSide_') && this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + return this.options } }, methods: { - update (e) { - set(this.$parent, this.path, e) - }, - reset () { - set(this.$parent, this.path, this.defaultState) + ...Setting.methods, + getValue (e) { + return e } } } diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue index 8fdbb5d3..114e9b7d 100644 --- a/src/components/settings_modal/helpers/choice_setting.vue +++ b/src/components/settings_modal/helpers/choice_setting.vue @@ -3,15 +3,20 @@ v-if="matchesExpertLevel" class="ChoiceSetting" > - <slot /> + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel }} + </template> + <template v-else> + <slot /> + </template> {{ ' ' }} <Select - :model-value="state" + :model-value="realDraftMode ? draft :state" :disabled="disabled" @update:modelValue="update" > <option - v-for="option in options" + v-for="option in realOptions" :key="option.key" :value="option.value" > @@ -23,7 +28,14 @@ :changed="isChanged" :onclick="reset" /> - <ServerSideIndicator :server-side="isServerSide" /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + > + {{ backendDescriptionDescription + ' ' }} + </p> </label> </template> diff --git a/src/components/settings_modal/helpers/draft_buttons.vue b/src/components/settings_modal/helpers/draft_buttons.vue new file mode 100644 index 00000000..46a70e86 --- /dev/null +++ b/src/components/settings_modal/helpers/draft_buttons.vue @@ -0,0 +1,88 @@ +<!-- this is a helper exclusive to Setting components --> +<!-- TODO make it reusable --> +<template> + <span + class="DraftButtons" + > + <Popover + v-if="$parent.isDirty" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.commit_value_tooltip') }" + @click="$parent.commitDraft" + > + <template #trigger> + {{ $t('settings.commit_value') }} + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.commit_value_tooltip') }} + </div> + </template> + </Popover> + <Popover + v-if="$parent.isDirty" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.reset_value_tooltip') }" + @click="$parent.reset" + > + <template #trigger> + {{ $t('settings.reset_value') }} + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.reset_value_tooltip') }} + </div> + </template> + </Popover> + <Popover + v-if="$parent.canHardReset" + trigger="hover" + normal-button + :trigger-attrs="{ 'aria-label': $t('settings.hard_reset_value_tooltip') }" + @click="$parent.hardReset" + > + <template #trigger> + {{ $t('settings.hard_reset_value') }} + </template> + <template #content> + <div class="modified-tooltip"> + {{ $t('settings.hard_reset_value_tooltip') }} + </div> + </template> + </Popover> + </span> +</template> + +<script> +import Popover from 'src/components/popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faWrench } from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench +) + +export default { + components: { Popover }, + props: ['changed'] +} +</script> + +<style lang="scss"> +.DraftButtons { + display: inline-block; + position: relative; + + .button-default { + margin-left: 0.5em; + } +} + +.draft-tooltip { + margin: 0.5em 1em; + min-width: 10em; + text-align: center; +} +</style> diff --git a/src/components/settings_modal/helpers/group_setting.js b/src/components/settings_modal/helpers/group_setting.js new file mode 100644 index 00000000..23a2a202 --- /dev/null +++ b/src/components/settings_modal/helpers/group_setting.js @@ -0,0 +1,13 @@ +import { isEqual } from 'lodash' + +import Setting from './setting.js' + +export default { + ...Setting, + computed: { + ...Setting.computed, + isDirty () { + return !isEqual(this.state, this.draft) + } + } +} diff --git a/src/components/settings_modal/helpers/group_setting.vue b/src/components/settings_modal/helpers/group_setting.vue new file mode 100644 index 00000000..a4df4bf3 --- /dev/null +++ b/src/components/settings_modal/helpers/group_setting.vue @@ -0,0 +1,15 @@ +<template> + <span + v-if="matchesExpertLevel" + class="GroupSetting" + > + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + </span> +</template> + +<script src="./group_setting.js"></script> diff --git a/src/components/settings_modal/helpers/number_setting.js b/src/components/settings_modal/helpers/number_setting.js index 73c39948..676a0d22 100644 --- a/src/components/settings_modal/helpers/number_setting.js +++ b/src/components/settings_modal/helpers/number_setting.js @@ -1,56 +1,24 @@ -import { get, set } from 'lodash' -import ModifiedIndicator from './modified_indicator.vue' +import Setting from './setting.js' + export default { - components: { - ModifiedIndicator - }, + ...Setting, props: { - path: String, - disabled: Boolean, - min: Number, - step: Number, - truncate: Number, - expert: [Number, String] - }, - computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, - parent () { - return this.$parent.$parent - }, - state () { - const value = get(this.parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.parent, this.pathDefault) - }, - isChanged () { - return this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.parent.expertLevel + ...Setting.props, + truncate: { + type: Number, + required: false, + default: 1 } }, methods: { - truncateValue (value) { - if (!this.truncate) { - return value + ...Setting.methods, + getValue (e) { + if (!this.truncate === 1) { + return parseInt(e.target.value) + } else if (this.truncate > 1) { + return Math.trunc(e.target.value / this.truncate) * this.truncate } - - return Math.trunc(value / this.truncate) * this.truncate - }, - update (e) { - set(this.parent, this.path, this.truncateValue(parseFloat(e.target.value))) - }, - reset () { - set(this.parent, this.path, this.defaultState) + return parseFloat(e.target.value) } } } diff --git a/src/components/settings_modal/helpers/number_setting.vue b/src/components/settings_modal/helpers/number_setting.vue index 3eab5178..93f11331 100644 --- a/src/components/settings_modal/helpers/number_setting.vue +++ b/src/components/settings_modal/helpers/number_setting.vue @@ -3,17 +3,26 @@ v-if="matchesExpertLevel" class="NumberSetting" > - <label :for="path"> - <slot /> + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> </label> <input :id="path" class="number-input" type="number" :step="step || 1" - :disabled="disabled" + :disabled="shouldBeDisabled" :min="min || 0" - :value="state" + :value="realDraftMode ? draft :state" @change="update" > {{ ' ' }} @@ -21,6 +30,15 @@ :changed="isChanged" :onclick="reset" /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> </span> </template> diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/profile_setting_indicator.vue index bf181959..d160781b 100644 --- a/src/components/settings_modal/helpers/server_side_indicator.vue +++ b/src/components/settings_modal/helpers/profile_setting_indicator.vue @@ -1,7 +1,7 @@ <template> <span - v-if="serverSide" - class="ServerSideIndicator" + v-if="isProfile" + class="ProfileSettingIndicator" > <Popover trigger="hover" @@ -14,7 +14,7 @@ /> </template> <template #content> - <div class="serverside-tooltip"> + <div class="profilesetting-tooltip"> {{ $t('settings.setting_server_side') }} </div> </template> @@ -33,17 +33,17 @@ library.add( export default { components: { Popover }, - props: ['serverSide'] + props: ['isProfile'] } </script> <style lang="scss"> -.ServerSideIndicator { +.ProfileSettingIndicator { display: inline-block; position: relative; } -.serverside-tooltip { +.profilesetting-tooltip { margin: 0.5em 1em; min-width: 10em; text-align: center; diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js new file mode 100644 index 00000000..b3add346 --- /dev/null +++ b/src/components/settings_modal/helpers/setting.js @@ -0,0 +1,237 @@ +import ModifiedIndicator from './modified_indicator.vue' +import ProfileSettingIndicator from './profile_setting_indicator.vue' +import DraftButtons from './draft_buttons.vue' +import { get, set, cloneDeep } from 'lodash' + +export default { + components: { + ModifiedIndicator, + DraftButtons, + ProfileSettingIndicator + }, + props: { + path: { + type: [String, Array], + required: true + }, + disabled: { + type: Boolean, + default: false + }, + parentPath: { + type: [String, Array] + }, + parentInvert: { + type: Boolean, + default: false + }, + expert: { + type: [Number, String], + default: 0 + }, + source: { + type: String, + default: undefined + }, + hideDescription: { + type: Boolean + }, + swapDescriptionAndLabel: { + type: Boolean + }, + overrideBackendDescription: { + type: Boolean + }, + overrideBackendDescriptionLabel: { + type: Boolean + }, + draftMode: { + type: Boolean, + default: undefined + } + }, + inject: { + defaultSource: { + default: 'default' + }, + defaultDraftMode: { + default: false + } + }, + data () { + return { + localDraft: null + } + }, + created () { + if (this.realDraftMode && this.realSource !== 'admin') { + this.draft = this.state + } + }, + computed: { + draft: { + // TODO allow passing shared draft object? + get () { + if (this.realSource === 'admin') { + return get(this.$store.state.adminSettings.draft, this.canonPath) + } else { + return this.localDraft + } + }, + set (value) { + if (this.realSource === 'admin') { + this.$store.commit('updateAdminDraft', { path: this.canonPath, value }) + } else { + this.localDraft = value + } + } + }, + state () { + const value = get(this.configSource, this.canonPath) + if (value === undefined) { + return this.defaultState + } else { + return value + } + }, + visibleState () { + return this.realDraftMode ? this.draft : this.state + }, + realSource () { + return this.source || this.defaultSource + }, + realDraftMode () { + return typeof this.draftMode === 'undefined' ? this.defaultDraftMode : this.draftMode + }, + backendDescription () { + return get(this.$store.state.adminSettings.descriptions, this.path) + }, + backendDescriptionLabel () { + if (this.realSource !== 'admin') return '' + if (!this.backendDescription || this.overrideBackendDescriptionLabel) { + return this.$t([ + 'admin_dash', + 'temp_overrides', + ...this.canonPath.map(p => p.replace(/\./g, '_DOT_')), + 'label' + ].join('.')) + } else { + return this.swapDescriptionAndLabel + ? this.backendDescription?.description + : this.backendDescription?.label + } + }, + backendDescriptionDescription () { + if (this.realSource !== 'admin') return '' + if (this.hideDescription) return null + if (!this.backendDescription || this.overrideBackendDescription) { + return this.$t([ + 'admin_dash', + 'temp_overrides', + ...this.canonPath.map(p => p.replace(/\./g, '_DOT_')), + 'description' + ].join('.')) + } else { + return this.swapDescriptionAndLabel + ? this.backendDescription?.label + : this.backendDescription?.description + } + }, + backendDescriptionSuggestions () { + return this.backendDescription?.suggestions + }, + shouldBeDisabled () { + const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null + return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false) + }, + configSource () { + switch (this.realSource) { + case 'profile': + return this.$store.state.profileConfig + case 'admin': + return this.$store.state.adminSettings.config + default: + return this.$store.getters.mergedConfig + } + }, + configSink () { + switch (this.realSource) { + case 'profile': + return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v }) + case 'admin': + return (k, v) => this.$store.dispatch('pushAdminSetting', { path: k, value: v }) + default: + return (k, v) => this.$store.dispatch('setOption', { name: k, value: v }) + } + }, + defaultState () { + switch (this.realSource) { + case 'profile': + return {} + default: + return get(this.$store.getters.defaultConfig, this.path) + } + }, + isProfileSetting () { + return this.realSource === 'profile' + }, + isChanged () { + switch (this.realSource) { + case 'profile': + case 'admin': + return false + default: + return this.state !== this.defaultState + } + }, + canonPath () { + return Array.isArray(this.path) ? this.path : this.path.split('.') + }, + isDirty () { + if (this.realSource === 'admin' && this.canonPath.length > 3) { + return false // should not show draft buttons for "grouped" values + } else { + return this.realDraftMode && this.draft !== this.state + } + }, + canHardReset () { + return this.realSource === 'admin' && this.$store.state.adminSettings.modifiedPaths.has(this.canonPath.join(' -> ')) + }, + matchesExpertLevel () { + return (this.expert || 0) <= this.$store.state.config.expertLevel > 0 + } + }, + methods: { + getValue (e) { + return e.target.value + }, + update (e) { + if (this.realDraftMode) { + this.draft = this.getValue(e) + } else { + this.configSink(this.path, this.getValue(e)) + } + }, + commitDraft () { + if (this.realDraftMode) { + this.configSink(this.path, this.draft) + } + }, + reset () { + if (this.realDraftMode) { + this.draft = cloneDeep(this.state) + } else { + set(this.$store.getters.mergedConfig, this.path, cloneDeep(this.defaultState)) + } + }, + hardReset () { + switch (this.realSource) { + case 'admin': + return this.$store.dispatch('resetAdminSetting', { path: this.path }) + .then(() => { this.draft = this.state }) + default: + console.warn('Hard reset not implemented yet!') + } + } + } +} diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js index 12431dca..bb3d36ac 100644 --- a/src/components/settings_modal/helpers/shared_computed_object.js +++ b/src/components/settings_modal/helpers/shared_computed_object.js @@ -1,52 +1,18 @@ -import { defaultState as configDefaultState } from 'src/modules/config.js' -import { defaultState as serverSideConfigDefaultState } from 'src/modules/serverSideConfig.js' - const SharedComputedObject = () => ({ user () { return this.$store.state.users.currentUser }, - // Getting values for default properties - ...Object.keys(configDefaultState) - .map(key => [ - key + 'DefaultValue', - function () { - return this.$store.getters.defaultConfig[key] - } - ]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - // Generating computed values for vuex properties - ...Object.keys(configDefaultState) - .map(key => [key, { - get () { return this.$store.getters.mergedConfig[key] }, - set (value) { - this.$store.dispatch('setOption', { name: key, value }) - } - }]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - ...Object.keys(serverSideConfigDefaultState) - .map(key => ['serverSide_' + key, { - get () { return this.$store.state.serverSideConfig[key] }, - set (value) { - this.$store.dispatch('setServerSideOption', { name: key, value }) - } - }]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - // Special cases (need to transform values or perform actions first) - useStreamingApi: { - get () { return this.$store.getters.mergedConfig.useStreamingApi }, - set (value) { - const promise = value - ? this.$store.dispatch('enableMastoSockets') - : this.$store.dispatch('disableMastoSockets') - - promise.then(() => { - this.$store.dispatch('setOption', { name: 'useStreamingApi', value }) - }).catch((e) => { - console.error('Failed starting MastoAPI Streaming socket', e) - this.$store.dispatch('disableMastoSockets') - this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false }) - }) - } + expertLevel () { + return this.$store.getters.mergedConfig.expertLevel > 0 + }, + mergedConfig () { + return this.$store.getters.mergedConfig + }, + adminConfig () { + return this.$store.state.adminSettings.config + }, + adminDraft () { + return this.$store.state.adminSettings.draft } }) diff --git a/src/components/settings_modal/helpers/size_setting.js b/src/components/settings_modal/helpers/size_setting.js index 58697412..12cef705 100644 --- a/src/components/settings_modal/helpers/size_setting.js +++ b/src/components/settings_modal/helpers/size_setting.js @@ -1,67 +1,40 @@ -import { get, set } from 'lodash' -import ModifiedIndicator from './modified_indicator.vue' import Select from 'src/components/select/select.vue' +import Setting from './setting.js' export const allCssUnits = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%'] export const defaultHorizontalUnits = ['px', 'rem', 'vw'] export const defaultVerticalUnits = ['px', 'rem', 'vh'] export default { + ...Setting, components: { - ModifiedIndicator, + ...Setting.components, Select }, props: { - path: String, - disabled: Boolean, + ...Setting.props, min: Number, units: { - type: [String], + type: Array, default: () => allCssUnits - }, - expert: [Number, String] + } }, computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, + ...Setting.computed, stateUnit () { - return (this.state || '').replace(/\d+/, '') + return this.state.replace(/\d+/, '') }, stateValue () { - return (this.state || '').replace(/\D+/, '') - }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value - } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isChanged () { - return this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + return this.state.replace(/\D+/, '') } }, methods: { - update (e) { - set(this.$parent, this.path, e) - }, - reset () { - set(this.$parent, this.path, this.defaultState) - }, + ...Setting.methods, updateValue (e) { - set(this.$parent, this.path, parseInt(e.target.value) + this.stateUnit) + this.configSink(this.path, parseInt(e.target.value) + this.stateUnit) }, updateUnit (e) { - set(this.$parent, this.path, this.stateValue + e.target.value) + this.configSink(this.path, this.stateValue + e.target.value) } } } diff --git a/src/components/settings_modal/helpers/size_setting.vue b/src/components/settings_modal/helpers/size_setting.vue index 5a78f100..6c3fbaeb 100644 --- a/src/components/settings_modal/helpers/size_setting.vue +++ b/src/components/settings_modal/helpers/size_setting.vue @@ -45,11 +45,18 @@ <script src="./size_setting.js"></script> <style lang="scss"> -.css-unit-input, -.css-unit-input select { - margin-left: 0.5em; - width: 4em; - max-width: 4em; - min-width: 4em; +.SizeSetting { + .number-input { + max-width: 6.5em; + } + + .css-unit-input, + .css-unit-input select { + margin-left: 0.5em; + width: 4em; + max-width: 4em; + min-width: 4em; + } } + </style> diff --git a/src/components/settings_modal/helpers/string_setting.js b/src/components/settings_modal/helpers/string_setting.js new file mode 100644 index 00000000..b368cfc8 --- /dev/null +++ b/src/components/settings_modal/helpers/string_setting.js @@ -0,0 +1,5 @@ +import Setting from './setting.js' + +export default { + ...Setting +} diff --git a/src/components/settings_modal/helpers/string_setting.vue b/src/components/settings_modal/helpers/string_setting.vue new file mode 100644 index 00000000..0cfa61ce --- /dev/null +++ b/src/components/settings_modal/helpers/string_setting.vue @@ -0,0 +1,42 @@ +<template> + <label + v-if="matchesExpertLevel" + class="StringSetting" + > + <label + :for="path" + :class="{ 'faint': shouldBeDisabled }" + > + <template v-if="backendDescriptionLabel"> + {{ backendDescriptionLabel + ' ' }} + </template> + <template v-else-if="source === 'admin'"> + MISSING LABEL FOR {{ path }} + </template> + <slot v-else /> + </label> + <input + :id="path" + class="string-input" + :disabled="shouldBeDisabled" + :value="realDraftMode ? draft : state" + @change="update" + > + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ProfileSettingIndicator :is-profile="isProfileSetting" /> + <DraftButtons /> + <p + v-if="backendDescriptionDescription" + class="setting-description" + :class="{ 'faint': shouldBeDisabled }" + > + {{ backendDescriptionDescription + ' ' }} + </p> + </label> +</template> + +<script src="./string_setting.js"></script> diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js index 0a72dca1..ff58f2c3 100644 --- a/src/components/settings_modal/settings_modal.js +++ b/src/components/settings_modal/settings_modal.js @@ -5,7 +5,7 @@ import getResettableAsyncComponent from 'src/services/resettable_async_component import Popover from '../popover/popover.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' import { library } from '@fortawesome/fontawesome-svg-core' -import { cloneDeep } from 'lodash' +import { cloneDeep, isEqual } from 'lodash' import { newImporter, newExporter @@ -53,8 +53,16 @@ const SettingsModal = { Modal, Popover, Checkbox, - SettingsModalContent: getResettableAsyncComponent( - () => import('./settings_modal_content.vue'), + SettingsModalUserContent: getResettableAsyncComponent( + () => import('./settings_modal_user_content.vue'), + { + loadingComponent: PanelLoading, + errorComponent: AsyncComponentError, + delay: 0 + } + ), + SettingsModalAdminContent: getResettableAsyncComponent( + () => import('./settings_modal_admin_content.vue'), { loadingComponent: PanelLoading, errorComponent: AsyncComponentError, @@ -147,6 +155,12 @@ const SettingsModal = { PLEROMAFE_SETTINGS_MINOR_VERSION ] return clone + }, + resetAdminDraft () { + this.$store.commit('resetAdminDraft') + }, + pushAdminDraft () { + this.$store.dispatch('pushAdminDraft') } }, computed: { @@ -156,8 +170,14 @@ const SettingsModal = { modalActivated () { return this.$store.state.interface.settingsModalState !== 'hidden' }, - modalOpenedOnce () { - return this.$store.state.interface.settingsModalLoaded + modalMode () { + return this.$store.state.interface.settingsModalMode + }, + modalOpenedOnceUser () { + return this.$store.state.interface.settingsModalLoadedUser + }, + modalOpenedOnceAdmin () { + return this.$store.state.interface.settingsModalLoadedAdmin }, modalPeeked () { return this.$store.state.interface.settingsModalState === 'minimized' @@ -167,9 +187,14 @@ const SettingsModal = { return this.$store.state.config.expertLevel > 0 }, set (value) { - console.log(value) this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 }) } + }, + adminDraftAny () { + return !isEqual( + this.$store.state.adminSettings.config, + this.$store.state.adminSettings.draft + ) } } } diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index f5861229..49ef83e0 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -17,6 +17,12 @@ } } + .setting-description { + margin-top: 0.2em; + margin-bottom: 2em; + font-size: 70%; + } + .settings-modal-panel { overflow: hidden; transition: transform; @@ -37,7 +43,9 @@ .btn { min-height: 2em; - min-width: 10em; + } + + .btn:not(.dropdown-button) { padding: 0 2em; } } @@ -45,6 +53,8 @@ .settings-footer { display: flex; + flex-wrap: wrap; + line-height: 2; >* { margin-right: 0.5em; diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue index 7b457371..4e7fd931 100644 --- a/src/components/settings_modal/settings_modal.vue +++ b/src/components/settings_modal/settings_modal.vue @@ -8,7 +8,7 @@ <div class="settings-modal-panel panel"> <div class="panel-heading"> <span class="title"> - {{ $t('settings.settings') }} + {{ modalMode === 'user' ? $t('settings.settings') : $t('admin_dash.window_title') }} </span> <transition name="fade"> <div @@ -42,10 +42,12 @@ </button> </div> <div class="panel-body"> - <SettingsModalContent v-if="modalOpenedOnce" /> + <SettingsModalUserContent v-if="modalMode === 'user' && modalOpenedOnceUser" /> + <SettingsModalAdminContent v-if="modalMode === 'admin' && modalOpenedOnceAdmin" /> </div> - <div class="panel-footer settings-footer"> + <div class="panel-footer settings-footer -flexible-height"> <Popover + v-if="modalMode === 'user'" class="export" trigger="click" placement="top" @@ -107,10 +109,42 @@ > {{ $t("settings.expert_mode") }} </Checkbox> + <span v-if="modalMode === 'admin'"> + <i18n-t keypath="admin_dash.wip_notice"> + <template #adminFeLink> + <a + href="/pleroma/admin/#/login-pleroma" + target="_blank" + > + {{ $t("admin_dash.old_ui_link") }} + </a> + </template> + </i18n-t> + </span> <span id="unscrolled-content" class="extra-content" /> + <span + v-if="modalMode === 'admin'" + class="admin-buttons" + > + <button + class="button-default btn" + :disabled="!adminDraftAny" + @click="resetAdminDraft" + > + {{ $t("admin_dash.reset_all") }} + </button> + {{ ' ' }} + <button + class="button-default btn" + :disabled="!adminDraftAny" + @click="pushAdminDraft" + > + {{ $t("admin_dash.commit_all") }} + </button> + </span> </div> </div> </Modal> diff --git a/src/components/settings_modal/settings_modal_admin_content.js b/src/components/settings_modal/settings_modal_admin_content.js new file mode 100644 index 00000000..f94721ec --- /dev/null +++ b/src/components/settings_modal/settings_modal_admin_content.js @@ -0,0 +1,93 @@ +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' + +import InstanceTab from './admin_tabs/instance_tab.vue' +import LimitsTab from './admin_tabs/limits_tab.vue' +import FrontendsTab from './admin_tabs/frontends_tab.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faWrench, + faHand, + faLaptopCode, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench, + faHand, + faLaptopCode, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +) + +const SettingsModalAdminContent = { + components: { + TabSwitcher, + + InstanceTab, + LimitsTab, + FrontendsTab + }, + computed: { + user () { + return this.$store.state.users.currentUser + }, + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + open () { + return this.$store.state.interface.settingsModalState !== 'hidden' + }, + bodyLock () { + return this.$store.state.interface.settingsModalState === 'visible' + }, + adminDbLoaded () { + return this.$store.state.adminSettings.loaded + }, + adminDescriptionsLoaded () { + return this.$store.state.adminSettings.descriptions !== null + }, + noDb () { + return this.$store.state.adminSettings.dbConfigEnabled === false + } + }, + created () { + if (this.user.rights.admin) { + this.$store.dispatch('loadAdminStuff') + } + }, + methods: { + onOpen () { + const targetTab = this.$store.state.interface.settingsModalTargetTab + // We're being told to open in specific tab + if (targetTab) { + const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => { + return elm.props && elm.props['data-tab-name'] === targetTab + }) + if (tabIndex >= 0) { + this.$refs.tabSwitcher.setTab(tabIndex) + } + } + // Clear the state of target tab, so that next time settings is opened + // it doesn't force it. + this.$store.dispatch('clearSettingsModalTargetTab') + } + }, + mounted () { + this.onOpen() + }, + watch: { + open: function (value) { + if (value) this.onOpen() + } + } +} + +export default SettingsModalAdminContent diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_admin_content.scss index 87df7982..c984d703 100644 --- a/src/components/settings_modal/settings_modal_content.scss +++ b/src/components/settings_modal/settings_modal_admin_content.scss @@ -48,9 +48,5 @@ color: var(--cRed, $fallback--cRed); color: $fallback--cRed; } - - .number-input { - max-width: 6em; - } } } diff --git a/src/components/settings_modal/settings_modal_admin_content.vue b/src/components/settings_modal/settings_modal_admin_content.vue new file mode 100644 index 00000000..a7a2ac9a --- /dev/null +++ b/src/components/settings_modal/settings_modal_admin_content.vue @@ -0,0 +1,68 @@ +<template> + <tab-switcher + v-if="adminDescriptionsLoaded && (noDb || adminDbLoaded)" + ref="tabSwitcher" + class="settings_tab-switcher" + :side-tab-bar="true" + :scrollable-tabs="true" + :render-only-focused="true" + :body-scroll-lock="bodyLock" + > + <div + v-if="noDb" + :label="$t('admin_dash.tabs.nodb')" + icon="exclamation-triangle" + data-tab-name="nodb-notice" + > + <div :label="$t('admin_dash.tabs.nodb')"> + <div class="setting-item"> + <h2>{{ $t('admin_dash.nodb.heading') }}</h2> + <i18n-t keypath="admin_dash.nodb.text"> + <template #documentation> + <a + href="https://docs-develop.pleroma.social/backend/configuration/howto_database_config/" + target="_blank" + > + {{ $t("admin_dash.nodb.documentation") }} + </a> + </template> + <template #property> + <code>config :pleroma, configurable_from_database</code> + </template> + <template #value> + <code>true</code> + </template> + </i18n-t> + <p>{{ $t('admin_dash.nodb.text2') }}</p> + </div> + </div> + </div> + <div + v-if="adminDbLoaded" + :label="$t('admin_dash.tabs.instance')" + icon="wrench" + data-tab-name="general" + > + <InstanceTab /> + </div> + <div + v-if="adminDbLoaded" + :label="$t('admin_dash.tabs.limits')" + icon="hand" + data-tab-name="limits" + > + <LimitsTab /> + </div> + <div + :label="$t('admin_dash.tabs.frontends')" + icon="laptop-code" + data-tab-name="frontends" + > + <FrontendsTab /> + </div> + </tab-switcher> +</template> + +<script src="./settings_modal_admin_content.js"></script> + +<style src="./settings_modal_admin_content.scss" lang="scss"></style> diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_user_content.js index 9ac0301f..9ac0301f 100644 --- a/src/components/settings_modal/settings_modal_content.js +++ b/src/components/settings_modal/settings_modal_user_content.js diff --git a/src/components/settings_modal/settings_modal_user_content.scss b/src/components/settings_modal/settings_modal_user_content.scss new file mode 100644 index 00000000..c984d703 --- /dev/null +++ b/src/components/settings_modal/settings_modal_user_content.scss @@ -0,0 +1,52 @@ +@import "src/variables"; + +.settings_tab-switcher { + height: 100%; + + .setting-item { + border-bottom: 2px solid var(--fg, $fallback--fg); + margin: 1em 1em 1.4em; + padding-bottom: 1.4em; + + > div, + > label { + display: block; + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + } + + .select-multiple { + display: flex; + + .option-list { + margin: 0; + padding-left: 0.5em; + } + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 1em; + } + + select { + min-width: 10em; + } + + textarea { + width: 100%; + max-width: 100%; + height: 100px; + } + + .unavailable, + .unavailable svg { + color: var(--cRed, $fallback--cRed); + color: $fallback--cRed; + } + } +} diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_user_content.vue index 0be76d22..0221cccb 100644 --- a/src/components/settings_modal/settings_modal_content.vue +++ b/src/components/settings_modal/settings_modal_user_content.vue @@ -78,6 +78,6 @@ </tab-switcher> </template> -<script src="./settings_modal_content.js"></script> +<script src="./settings_modal_user_content.js"></script> -<style src="./settings_modal_content.scss" lang="scss"></style> +<style src="./settings_modal_user_content.scss" lang="scss"></style> diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index 97046ff0..41d1b54f 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -7,13 +7,11 @@ <BooleanSetting path="hideFilteredStatuses"> {{ $t('settings.hide_filtered_statuses') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideWordFilteredPosts" > {{ $t('settings.hide_wordfiltered_statuses') }} @@ -22,7 +20,8 @@ <li> <BooleanSetting v-if="user" - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideMutedThreads" > {{ $t('settings.hide_muted_threads') }} @@ -31,7 +30,8 @@ <li> <BooleanSetting v-if="user" - :disabled="hideFilteredStatuses" + parent-path="hideFilteredStatuses" + :parent-invert="true" path="hideMutedPosts" > {{ $t('settings.hide_muted_posts') }} diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index be97710f..3f2bcb13 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -7,7 +7,7 @@ import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' -import ServerSideIndicator from '../helpers/server_side_indicator.vue' +import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faGlobe @@ -67,7 +67,7 @@ const GeneralTab = { SizeSetting, InterfaceLanguageSwitcher, ScopeSelector, - ServerSideIndicator + ProfileSettingIndicator }, computed: { horizontalUnits () { @@ -110,7 +110,7 @@ const GeneralTab = { }, methods: { changeDefaultScope (value) { - this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value }) + this.$store.dispatch('setProfileOption', { name: 'defaultScope', value }) } } } diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index 21e2d855..f56fa8e0 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -29,14 +29,11 @@ <BooleanSetting path="streaming"> {{ $t('settings.streaming') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting path="pauseOnUnfocused" - :disabled="!streaming" + parent-path="streaming" > {{ $t('settings.pause_on_unfocused') }} </BooleanSetting> @@ -213,7 +210,7 @@ </ChoiceSetting> </li> <ul - v-if="conversationDisplay !== 'linear'" + v-if="mergedConfig.conversationDisplay !== 'linear'" class="setting-list suboptions" > <li> @@ -265,7 +262,8 @@ <li> <BooleanSetting v-if="user" - path="serverSide_stripRichContent" + source="profile" + path="stripRichContent" expert="1" > {{ $t('settings.no_rich_text_description') }} @@ -299,7 +297,7 @@ <BooleanSetting path="preloadImage" expert="1" - :disabled="!hideNsfw" + parent-path="hideNsfw" > {{ $t('settings.preload_images') }} </BooleanSetting> @@ -308,7 +306,7 @@ <BooleanSetting path="useOneClickNsfw" expert="1" - :disabled="!hideNsfw" + parent-path="hideNsfw" > {{ $t('settings.use_one_click_nsfw') }} </BooleanSetting> @@ -321,15 +319,13 @@ > {{ $t('settings.loop_video') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !streaming}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting path="loopVideoSilentOnly" expert="1" - :disabled="!loopVideo || !loopSilentAvailable" + parent-path="loopVideo" + :disabled="!loopSilentAvailable" > {{ $t('settings.loop_video_silent_only') }} </BooleanSetting> @@ -427,18 +423,18 @@ <ul class="setting-list"> <li> <label for="default-vis"> - {{ $t('settings.default_vis') }} <ServerSideIndicator :server-side="true" /> + {{ $t('settings.default_vis') }} <ProfileSettingIndicator :is-profile="true" /> <ScopeSelector class="scope-selector" :show-all="true" - :user-default="serverSide_defaultScope" - :initial-scope="serverSide_defaultScope" + :user-default="$store.state.profileConfig.defaultScope" + :initial-scope="$store.state.profileConfig.defaultScope" :on-scope-change="changeDefaultScope" /> </label> </li> <li> - <!-- <BooleanSetting path="serverSide_defaultNSFW"> --> + <!-- <BooleanSetting source="profile" path="defaultNSFW"> --> <BooleanSetting path="sensitiveByDefault"> {{ $t('settings.sensitive_by_default') }} </BooleanSetting> diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue index dd3806ed..fcb92135 100644 --- a/src/components/settings_modal/tabs/notifications_tab.vue +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -4,7 +4,10 @@ <h2>{{ $t('settings.notification_setting_filters') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="serverSide_blockNotificationsFromStrangers"> + <BooleanSetting + source="profile" + path="blockNotificationsFromStrangers" + > {{ $t('settings.notification_setting_block_from_strangers') }} </BooleanSetting> </li> @@ -67,7 +70,8 @@ </li> <li> <BooleanSetting - path="serverSide_webPushHideContents" + source="profile" + path="webPushHideContents" expert="1" > {{ $t('settings.notification_setting_hide_notification_contents') }} diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index 6a5b478a..1cc850cb 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -254,37 +254,50 @@ <h2>{{ $t('settings.account_privacy') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="serverSide_locked"> + <BooleanSetting + source="profile" + path="locked" + > {{ $t('settings.lock_account_description') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_discoverable"> + <BooleanSetting + source="profile" + path="discoverable" + > {{ $t('settings.discoverable') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_allowFollowingMove"> + <BooleanSetting + source="profile" + path="allowFollowingMove" + > {{ $t('settings.allow_following_move') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_hideFavorites"> + <BooleanSetting + source="profile" + path="hideFavorites" + > {{ $t('settings.hide_favorites_description') }} </BooleanSetting> </li> <li> - <BooleanSetting path="serverSide_hideFollowers"> + <BooleanSetting + source="profile" + path="hideFollowers" + > {{ $t('settings.hide_followers_description') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !serverSide_hideFollowers}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - path="serverSide_hideFollowersCount" - :disabled="!serverSide_hideFollowers" + source="profile" + path="hideFollowersCount" + parent-path="hideFollowers" > {{ $t('settings.hide_followers_count_description') }} </BooleanSetting> @@ -292,17 +305,18 @@ </ul> </li> <li> - <BooleanSetting path="serverSide_hideFollows"> + <BooleanSetting + source="profile" + path="hideFollows" + > {{ $t('settings.hide_follows_description') }} </BooleanSetting> - <ul - class="setting-list suboptions" - :class="[{disabled: !serverSide_hideFollows}]" - > + <ul class="setting-list suboptions"> <li> <BooleanSetting - path="serverSide_hideFollowsCount" - :disabled="!serverSide_hideFollows" + source="profile" + path="hideFollowsCount" + parent-path="hideFollows" > {{ $t('settings.hide_follows_count_description') }} </BooleanSetting> diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue index 6e03bef4..d36d478f 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.vue +++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue @@ -143,8 +143,8 @@ /> </div> <div> - <i18n - path="settings.new_alias_target" + <i18n-t + keypath="settings.new_alias_target" tag="p" > <code @@ -152,7 +152,7 @@ > foo@example.org </code> - </i18n> + </i18n-t> <input v-model="addAliasTarget" > @@ -175,16 +175,16 @@ <h2>{{ $t('settings.move_account') }}</h2> <p>{{ $t('settings.move_account_notes') }}</p> <div> - <i18n - path="settings.move_account_target" + <i18n-t + keypath="settings.move_account_target" tag="p" > - <code - place="example" - > - foo@example.org - </code> - </i18n> + <template #example> + <code> + foo@example.org + </code> + </template> + </i18n-t> <input v-model="moveAccountTarget" > diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 27019577..81c5a612 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -115,7 +115,10 @@ const SideDrawer = { GestureService.updateSwipe(e, this.closeGesture) }, openSettingsModal () { - this.$store.dispatch('openSettingsModal') + this.$store.dispatch('openSettingsModal', 'user') + }, + openAdminModal () { + this.$store.dispatch('openSettingsModal', 'admin') } } } diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 994ac953..09588767 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -180,16 +180,16 @@ v-if="currentUser && currentUser.role === 'admin'" @click="toggleDrawer" > - <a - href="/pleroma/admin/#/login-pleroma" - target="_blank" + <button + class="button-unstyled -link -fullwidth" + @click.stop="openAdminModal" > <FAIcon fixed-width class="fa-scale-110 fa-old-padding" icon="tachometer-alt" /> {{ $t("nav.administration") }} - </a> + </button> </li> <li v-if="currentUser && supportsAnnouncements" diff --git a/src/components/status/status.js b/src/components/status/status.js index 9a9bca7a..e722a635 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -133,6 +133,7 @@ const Status = { 'showPinned', 'inProfile', 'profileUserId', + 'inQuote', 'simpleTree', 'controlledThreadDisplayStatus', @@ -159,7 +160,8 @@ const Status = { uncontrolledMediaPlaying: [], suspendable: true, error: null, - headTailLinks: null + headTailLinks: null, + displayQuote: !this.inQuote } }, computed: { @@ -401,6 +403,18 @@ const Status = { }, editingAvailable () { return this.$store.state.instance.editingAvailable + }, + hasVisibleQuote () { + return this.status.quote_url && this.status.quote_visible + }, + hasInvisibleQuote () { + return this.status.quote_url && !this.status.quote_visible + }, + quotedStatus () { + return this.status.quote_id ? this.$store.state.statuses.allStatusesObject[this.status.quote_id] : undefined + }, + shouldDisplayQuote () { + return this.quotedStatus && this.displayQuote } }, methods: { @@ -469,6 +483,18 @@ const Status = { window.scrollBy(0, rect.bottom - window.innerHeight + 50) } } + }, + toggleDisplayQuote () { + if (this.shouldDisplayQuote) { + this.displayQuote = false + } else if (!this.quotedStatus) { + this.$store.dispatch('fetchStatus', this.status.quote_id) + .then(() => { + this.displayQuote = true + }) + } else { + this.displayQuote = true + } } }, watch: { diff --git a/src/components/status/status.scss b/src/components/status/status.scss index 44812867..760c6ac1 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -422,4 +422,22 @@ } } } + + .quoted-status { + margin-top: 0.5em; + border: 1px solid var(--border, $fallback--border); + border-radius: var(--attachmentRadius, $fallback--attachmentRadius); + + &.-unavailable-prompt { + padding: 0.5em; + } + } + + .display-quoted-status-button { + margin: 0.5em; + + &-icon { + color: inherit; + } + } } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 35b15362..c49a9e7b 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -364,6 +364,45 @@ @parseReady="setHeadTailLinks" /> + <article + v-if="hasVisibleQuote" + class="quoted-status" + > + <button + class="button-unstyled -link display-quoted-status-button" + :aria-expanded="shouldDisplayQuote" + @click="toggleDisplayQuote" + > + {{ shouldDisplayQuote ? $t('status.hide_quote') : $t('status.display_quote') }} + <FAIcon + class="display-quoted-status-button-icon" + :icon="shouldDisplayQuote ? 'chevron-up' : 'chevron-down'" + /> + </button> + <Status + v-if="shouldDisplayQuote" + :statusoid="quotedStatus" + :in-quote="true" + /> + </article> + <p + v-else-if="hasInvisibleQuote" + class="quoted-status -unavailable-prompt" + > + <i18n-t keypath="status.invisible_quote"> + <template #link> + <bdi> + <a + :href="status.quote_url" + target="_blank" + > + {{ status.quote_url }} + </a> + </bdi> + </template> + </i18n-t> + </p> + <div v-if="inConversation && !isPreview && replies && replies.length" class="replies" diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx index a7ef8560..b444da43 100644 --- a/src/components/tab_switcher/tab_switcher.jsx +++ b/src/components/tab_switcher/tab_switcher.jsx @@ -60,13 +60,7 @@ export default { const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName return this.$slots.default().findIndex(isWanted) === this.activeIndex } - }, - settingsModalVisible () { - return this.settingsModalState === 'visible' - }, - ...mapState({ - settingsModalState: state => state.interface.settingsModalState - }) + } }, beforeUpdate () { const currentSlot = this.slots()[this.active] diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index b7414610..1050b87a 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -160,6 +160,9 @@ const Timeline = { if (this.timeline.flushMarker !== 0) { this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) + if (this.timelineName === 'user') { + this.$store.dispatch('fetchPinnedStatuses', this.userId) + } this.fetchOlderStatuses() } else { this.blockClicksTemporarily() diff --git a/src/i18n/ar.json b/src/i18n/ar.json index cd9a410a..0fd79af0 100644 --- a/src/i18n/ar.json +++ b/src/i18n/ar.json @@ -9,7 +9,8 @@ "scope_options": "", "text_limit": "الحد الأقصى للنص", "title": "الميّزات", - "who_to_follow": "للمتابعة" + "who_to_follow": "للمتابعة", + "upload_limit": "حد الرفع" }, "finder": { "error_fetching_user": "خطأ أثناء جلب صفحة المستخدم", @@ -17,7 +18,37 @@ }, "general": { "apply": "تطبيق", - "submit": "إرسال" + "submit": "إرسال", + "error_retry": "حاول مجددًا", + "retry": "حاول مجدداً", + "optional": "اختياري", + "show_more": "اعرض المزيد", + "show_less": "اعرض أقل", + "cancel": "ألغ", + "disable": "عطّل", + "enable": "فعّل", + "confirm": "تأكيد", + "close": "أغلق", + "role": { + "admin": "مدير", + "moderator": "مشرف" + }, + "generic_error_message": "حدث خطأ: {0}", + "never_show_again": "لا تظهره مجددًا", + "yes": "نعم", + "no": "لا", + "unpin": "ألغ تثبيت العنصر", + "undo": "تراجع", + "more": "المزيد", + "loading": "يحمل…", + "generic_error": "حدث خطأ", + "scope_in_timeline": { + "private": "المتابِعون فقط", + "public": "علني", + "unlisted": "غير مدرج" + }, + "scroll_to_top": "مرر لأعلى", + "pin": "ثبت العنصر" }, "login": { "login": "تسجيل الدخول", @@ -25,7 +56,21 @@ "password": "الكلمة السرية", "placeholder": "مثال lain", "register": "انشاء حساب", - "username": "إسم المستخدم" + "username": "إسم المستخدم", + "logout_confirm_title": "تأكيد الخروج", + "logout_confirm": "أتريد الخروج؟", + "logout_confirm_accept_button": "خروج", + "logout_confirm_cancel_button": "لا تخرج", + "hint": "لِج للانضمام للمناقشة", + "authentication_code": "رمز الاستيثاق", + "enter_recovery_code": "أدخل رمز التأكيد", + "enter_two_factor_code": "أدخل رمز الاستيثاق بعاملين", + "recovery_code": "رمز الاستعادة", + "heading": { + "totp": "الاستيثاق بعاملين", + "recovery": "الاستيثاق بعاملين" + }, + "description": "لج باستخدام OAuth" }, "nav": { "chat": "الدردشة المحلية", @@ -33,42 +78,108 @@ "mentions": "الإشارات", "public_tl": "الخيط الزمني العام", "timeline": "الخيط الزمني", - "twkn": "كافة الشبكة المعروفة" + "twkn": "كافة الشبكة المعروفة", + "search_close": "أغلق شربط البحث", + "back": "للخلف", + "administration": "الإدارة", + "preferences": "التفضيلات", + "chats": "المحادثات", + "lists": "القوائم", + "edit_nav_mobile": "خصص شريط التنقل", + "edit_pinned": "حرر العناصر المثبتة", + "mobile_notifications_close": "أغلق الاشعارات", + "announcements": "إعلانات", + "home_timeline": "الخط الزمني الرئيس", + "search": "بحث", + "who_to_follow": "للمتابعة", + "dms": "رسالة شخصية", + "edit_finish": "تم التحرير", + "timelines": "الخيوط الزمنية", + "mobile_notifications": "افتح الإشعارات (تتواجد اشعارات غير مقروءة)", + "about": "حول", + "user_search": "بحث عن مستخدم" }, "notifications": { "broken_favorite": "منشور مجهول، جارٍ البحث عنه…", "favorited_you": "أعجِب بمنشورك", "followed_you": "يُتابعك", "load_older": "تحميل الإشعارات الأقدم", - "notifications": "الإخطارات", + "notifications": "الاشعارات", "read": "مقروء!", - "repeated_you": "شارَك منشورك" + "repeated_you": "شارَك منشورك", + "error": "خطأ أثناء جلب الاشعارات: {0}", + "follow_request": "يريد متابعتك", + "poll_ended": "انتهى الاستطلاع", + "no_more_notifications": "لا مزيد من الإشعارات", + "reacted_with": "تفاعل بـ{0}", + "submitted_report": "أرسل بلاغًا", + "migrated_to": "انتقلَ إلى" }, "post_status": { - "account_not_locked_warning": "", + "account_not_locked_warning": "حسابك ليس {0}. يمكن للجميع مشاهدة مشاركاتك المحصورة على المتابِعين.", "account_not_locked_warning_link": "مقفل", "attachments_sensitive": "اعتبر المرفقات كلها كمحتوى حساس", "content_type": { - "text/plain": "نص صافٍ" + "text/plain": "نص صِرف", + "text/html": "HTML", + "text/markdown": "ماركداون" }, "content_warning": "الموضوع (اختياري)", "default": "وصلت للتوّ إلى لوس أنجلس.", "direct_warning": "", "posting": "النشر", "scope": { - "direct": "", - "private": "", + "direct": "مباشر - شارك مع المستخدمين المذكورين فقط", + "private": "للمتابِعين فقط - شارك حصرًا مع المتابِعين", "public": "علني - يُنشر على الخيوط الزمنية العمومية", "unlisted": "غير مُدرَج - لا يُنشَر على الخيوط الزمنية العمومية" - } + }, + "media_description": "وصف الوسائط", + "direct_warning_to_all": "سيكون عذا المنشور مرئيًا لكل المستخدمين المذكورين.", + "post": "انشر", + "preview": "معاينة", + "preview_empty": "فارغ", + "scope_notice": { + "public": "سيكون هذا المنشور مرئيًا للجميع", + "private": "سيكون هذا المنشور مرئيا لمتابِعيك فقط", + "unlisted": "لن تظهر هته المشاركة في الخط الزمني العلني والشبكات العلنية" + }, + "direct_warning_to_first_only": "سيكون عذا المنشور مرئيًا للمستخدمين المذكورين في أول الرسالة.", + "edit_unsupported_warning": "بليروما لا يدعم تعديل الذكر والاستطلاع.", + "empty_status_error": "يتعذر نشر منشور فارغ دون ملفات", + "edit_status": "حرر الحالة", + "new_status": "انشر حالة جديدة", + "content_type_selection": "نسق المشاركة", + "scope_notice_dismiss": "أغلق هذا التنبيه", + "media_description_error": "فشل تحديث الوسائط، حاول مجددًا" }, "registration": { "bio": "السيرة الذاتية", "email": "عنوان البريد الإلكتروني", - "fullname": "الإسم المعروض", + "fullname": "الاسم العلني", "password_confirm": "تأكيد الكلمة السرية", "registration": "التسجيل", - "token": "رمز الدعوة" + "token": "رمز الدعوة", + "bio_optional": "سيرة (اختيارية)", + "email_optional": "بيرد إلكتروني (اختياري)", + "username_placeholder": "مثل lain", + "reason": "سبب التسجيل", + "register": "سجل", + "validations": { + "username_required": "لايمكن تركه فارغًا", + "email_required": "لايمكن تركه فارغًا", + "password_required": "لايمكن تركه فارغًا", + "password_confirmation_required": "لايمكن تركه فارغًا", + "fullname_required": "لايمكن تركه فارغًا", + "password_confirmation_match": "يلزم أن يطابق كلمة السر", + "birthday_required": "لايمكن تركه فارغًا", + "birthday_min_age": "يلزم أن يكون في {date} أو قبله" + }, + "fullname_placeholder": "مثل Lain Iwakura", + "reason_placeholder": "قبول التسجيل في هذا المثيل يستلزم موافقة المدير\nلهذا يجب عليك إعلامه بسبب التسجيل.", + "birthday_optional": "تاريخ الميلاد (اختياري):", + "email_language": "بأي لغة تريد استلام رسائل البريد الإلكتروني؟", + "birthday": "تاريخ الميلاد:" }, "settings": { "attachmentRadius": "المُرفَقات", @@ -83,9 +194,9 @@ "cGreen": "أخضر (إعادة النشر)", "cOrange": "برتقالي (مفضلة)", "cRed": "أحمر (إلغاء)", - "change_password": "تغيير كلمة السر", - "change_password_error": "وقع هناك خلل أثناء تعديل كلمتك السرية.", - "changed_password": "تم تغيير كلمة المرور بنجاح!", + "change_password": "غيّر كلمة السر", + "change_password_error": "حدث خلل أثناء تعديل كلمتك السرية.", + "changed_password": "نجح تغيير كلمة السر!", "collapse_subject": "", "confirm_new_password": "تأكيد كلمة السر الجديدة", "current_avatar": "صورتك الرمزية الحالية", @@ -94,108 +205,449 @@ "data_import_export_tab": "تصدير واستيراد البيانات", "default_vis": "أسلوب العرض الافتراضي", "delete_account": "حذف الحساب", - "delete_account_description": "حذف حسابك و كافة منشوراتك نهائيًا.", - "delete_account_error": "", + "delete_account_description": "حذف حسابك و كافة بياناتك نهائيًا.", + "delete_account_error": "حدثة مشكلة اثناء حذف حسابك، إذا استمرت تواصل مع مدير المثيل.", "delete_account_instructions": "يُرجى إدخال كلمتك السرية أدناه لتأكيد عملية حذف الحساب.", "export_theme": "حفظ النموذج", - "filtering": "التصفية", + "filtering": "الترشيح", "filtering_explanation": "سيتم إخفاء كافة المنشورات التي تحتوي على هذه الكلمات، كلمة واحدة في كل سطر", "follow_export": "تصدير الاشتراكات", "follow_export_button": "تصدير الاشتراكات كملف csv", "follow_export_processing": "التصدير جارٍ، سوف يُطلَب منك تنزيل ملفك بعد حين", "follow_import": "استيراد الاشتراكات", "follow_import_error": "خطأ أثناء استيراد المتابِعين", - "follows_imported": "", + "follows_imported": "أُستورد المتابِعون! معالجتهم ستستغرق بعض الوقت.", "foreground": "الأمامية", "general": "الإعدادات العامة", - "hide_attachments_in_convo": "إخفاء المرفقات على المحادثات", - "hide_attachments_in_tl": "إخفاء المرفقات على الخيط الزمني", - "hide_post_stats": "", - "hide_user_stats": "", - "import_followers_from_a_csv_file": "", + "hide_attachments_in_convo": "اخف المرفقات من المحادثات", + "hide_attachments_in_tl": "اخف المرفقات من الخيط الزمني", + "hide_post_stats": "اخف احصائيات المنشور (مثل عدد التفضيلات)", + "hide_user_stats": "اخف احصائيات المستخدم (مثل عدد المتابِعين)", + "import_followers_from_a_csv_file": "استورد المتابِعين من ملف csv", "import_theme": "تحميل نموذج", "inputRadius": "", - "instance_default": "", + "instance_default": "(الافتراضي: {value})", "interfaceLanguage": "لغة الواجهة", - "invalid_theme_imported": "", + "invalid_theme_imported": "الملف المختار ليس سمة تدعمها بليروما.لن تطرأ تغييرات على سمتك.", "limited_availability": "غير متوفر على متصفحك", "links": "الروابط", "lock_account_description": "", - "loop_video": "", - "loop_video_silent_only": "", + "loop_video": "كرر تشغيل الفيديوهات", + "loop_video_silent_only": "كرر فيديوهات بدون صوت (مثل gif في ماستودون)", "name": "الاسم", "name_bio": "الاسم والسيرة الذاتية", "new_password": "كلمة السر الجديدة", "no_rich_text_description": "", "notification_visibility": "نوع الإشعارات التي تريد عرضها", "notification_visibility_follows": "يتابع", - "notification_visibility_likes": "الإعجابات", - "notification_visibility_mentions": "الإشارات", - "notification_visibility_repeats": "", + "notification_visibility_likes": "المفضلة", + "notification_visibility_mentions": "ذِكر", + "notification_visibility_repeats": "مشاركات", "nsfw_clickthrough": "", "oauth_tokens": "رموز OAuth", "token": "رمز", "refresh_token": "رمز التحديث", "valid_until": "صالح حتى", "revoke_token": "سحب", - "panelRadius": "", + "panelRadius": "لوحات", "pause_on_unfocused": "", "presets": "النماذج", - "profile_background": "خلفية الصفحة الشخصية", + "profile_background": "خلفية الملف التعريفي", "profile_banner": "رأسية الصفحة الشخصية", - "profile_tab": "الملف الشخصي", + "profile_tab": "الملف التعريفي", "radii_help": "", - "replies_in_timeline": "الردود على الخيط الزمني", - "reply_visibility_all": "عرض كافة الردود", - "reply_visibility_following": "", - "reply_visibility_self": "", + "replies_in_timeline": "المشاركات في الخيط الزمني", + "reply_visibility_all": "أظهر كل المشاركات", + "reply_visibility_following": "أظهر الردود الموجهة إلي أو لمتابَعي فقط", + "reply_visibility_self": "أظهر الردود الموجهة إلي فقط", "saving_err": "خطأ أثناء حفظ الإعدادات", - "saving_ok": "تم حفظ الإعدادات", + "saving_ok": "حُفظت الإعدادات", "security_tab": "الأمان", "set_new_avatar": "اختيار صورة رمزية جديدة", "set_new_profile_background": "اختيار خلفية جديدة للملف الشخصي", "set_new_profile_banner": "اختيار رأسية جديدة للصفحة الشخصية", "settings": "الإعدادات", - "stop_gifs": "", - "streaming": "", - "text": "النص", - "theme": "المظهر", + "stop_gifs": "إيقاف الصور المتحركة مالم يُمرر فوقها", + "streaming": "إظهار المنشورات الجديدة عند التمرير لأعلى", + "text": "نص", + "theme": "السمة", "theme_help": "", "tooltipRadius": "", "user_settings": "إعدادات المستخدم", "values": { "false": "لا", "true": "نعم" - } + }, + "emoji_reactions_scale": "معامل تحجيم التفاعلات", + "app_name": "اسم تطبيق", + "security": "الأمن", + "enter_current_password_to_confirm": "أدخل كلمة السر الحالية لتيقن من هويتك", + "mfa": { + "title": "الاستيثاق بعاملين", + "generate_new_recovery_codes": "ولّد رموز استعادة جديدة", + "warning_of_generate_new_codes": "عند توليد رموز استعادة جديدة ستزال القديمة.", + "recovery_codes": "رموز الاستعادة.", + "recovery_codes_warning": "خزن هذه الرموز في مكان آمن. إذا فقدت هذه الرموز وتعذر عليك الوصول إلى تطبيق الاستيثاق بعاملين، لن تتمكن من الوصول لحسابك.", + "authentication_methods": "طرق الاستيثاق", + "scan": { + "title": "مسح", + "desc": "امسح رمز الاستجابة السريعة QR من تطبيق الاستيثاق أو أدخل المفتاح:", + "secret_code": "مفتاح" + }, + "verify": { + "desc": "لتفعيل الاستيثاق بعاملين أدخل الرمز من تطبيق الاستيثاق:" + } + }, + "block_import": "استيراد المحجوبين", + "import_mutes_from_a_csv_file": "استورد قائمة الخُرس من ملف csv", + "account_backup": "نسخ احتياطي للحساب", + "download_backup": "نزّل", + "account_backup_table_head": "نسخ احتياطي", + "backup_not_ready": "هذا النسخ الاحتياطي ليس جاهزًا.", + "backup_failed": "فشل النسخ الاحتياطي.", + "remove_backup": "أزل", + "list_backups_error": "خطأ أثناء حلب قائمة النُسخ الاحتياطية: {error}", + "added_backup": "أُضيفت نسخة احتياطية جديدة.", + "blocks_tab": "المحجوبون", + "confirm_dialogs_block": "حجب مستخدم", + "confirm_dialogs_mute": "إخراس مستخدم", + "confirm_dialogs_delete": "حذف حالة", + "confirm_dialogs_logout": "خروج", + "confirm_dialogs_approve_follow": "قبول متابِع", + "confirm_dialogs_deny_follow": "رفض متابِع", + "list_aliases_error": "خطأ أثناء جلب الكنيات: {error}", + "hide_list_aliases_error_action": "أغلق", + "remove_alias": "أزل هذه الكنية", + "add_alias_error": "حدث خطأ أثناء إضافة الكنية: {error}", + "confirm_dialogs": "أطلب تأكيدًا عند", + "confirm_dialogs_repeat": "مشاركة حالة", + "mutes_and_blocks": "الخُرس والمحجوبون", + "move_account_target": "الحساب المستهدف (مثل {example})", + "wordfilter": "ترشيح الكلمات", + "always_show_post_button": "أظهر الزر العائم لإنشاء منشور جديد دائمًا", + "hide_wallpaper": "اخف خلفية المثيل", + "save": "احفظ التعديلات", + "lists_navigation": "أظهر القوائم في شريط التنقل", + "mute_export_button": "صدّر قائمة الخرس إلى ملف csv", + "blocks_imported": "اُستورد المحجوبون! معالجة القائمة ستستغرق وقتًا.", + "mute_export": "تصدير الخُرس", + "mute_import": "استيراد الخُرس", + "mute_import_error": "خطأ أثناء استيراد الخُرس", + "change_email_error": "حدثت خلل أثناء تغيير بريدك الإلكتروني.", + "change_email": "غيّر البريد الإلكتروني", + "changed_email": "نجح تغيير البريد الإلكتروني!", + "account_alias_table_head": "الكنية", + "account_alias": "كنيات الحساب", + "move_account": "أنقل الحساب", + "moved_account": "نُقل الحساب.", + "hide_media_previews": "اخف معاينات الوسائط", + "hide_muted_posts": "اخف منشورات المستخدمين الخُرس", + "confirm_dialogs_unfollow": "الغاء متابعة مستخدم", + "confirm_dialogs_remove_follower": "إزالة متابع", + "new_alias_target": "أضف كنية جديدة (مثل {example})", + "added_alias": "أُضيفت الكنية.", + "move_account_error": "خطأ أثناء نقل الحساب: {error}", + "emoji_reactions_on_timeline": "أظهر التفاعلات في الخط الزمني", + "mutes_imported": "اُستورد الخُرس! معالجة القائمة ستستغرق وقتًا.", + "remove_language": "أزل", + "primary_language": "اللغة الرئيسية:", + "expert_mode": "أظهر الإعدادات المتقدمة", + "block_import_error": "خطأ أثناء استيراد قائمة المحجوبين", + "add_backup": "أنشئ نسخة احتياطية جديدة", + "add_backup_error": "خطأ أثناء إضافة نسخ احتياطي جديد: {error}", + "move_account_notes": "إذا أردت نقل حسابك عليك إضافة كنية تشير إلى هنا في الحساب المستهدف.", + "avatar_size_instruction": "أدنى حجم مستحسن للصورة الرمزية هو 150x150 بيكسل.", + "word_filter_and_more": "مرشح الكلمات والمزيد...", + "hide_all_muted_posts": "اخف المنشورات المكتومة", + "max_thumbnails": "أقصى عدد للصور المصغرة لكل منشور (فارغ = غير محدود)", + "block_export_button": "صدّر قائمة المحجوبين إلى ملف csv", + "block_export": "تصدير المحجوبين", + "use_one_click_nsfw": "افتح المرفقات ذات المحتوى الحساس NSFW بنقرة واحدة", + "account_privacy": "خصوصية", + "use_contain_fit": "لا تقتص الصور المصغرة للمرفقات", + "import_blocks_from_a_csv_file": "استورد المحجوبين من ملف csv", + "instance_default_simple": "(افتراضي)", + "interface": "واجهة", + "birthday": { + "label": "تاريخ الميلاد", + "show_birthday": "اظهر تاريخ ميلادي" + }, + "profile_fields": { + "add_field": "أضف حقل", + "value": "محتوى", + "label": "البيانات الوصفية للملف الشخصي", + "name": "لصيقة" + }, + "posts": "منشورات", + "user_profiles": "ملفات المستخدمين الشخصية", + "notification_visibility_emoji_reactions": "تفاعلات", + "notification_visibility_polls": "انتهاء استطلاعات اشتركت بها", + "file_export_import": { + "restore_settings": "استرجع الإعدادات من ملف", + "backup_restore": "نسخ احتياطي للإعدادات", + "backup_settings_theme": "احفظ النسخ الاحتياطي للإعدادات والسمة في ملف", + "backup_settings": "احفظ النسخ الاحتياطي للإعدادات في ملف" + }, + "mutes_tab": "خُرس", + "no_mutes": "لا يوجد خُرس", + "hide_followers_count_description": "لا تظهر عدد المتابِعين", + "show_moderator_badge": "أظهر شارة \"مشرف\" في ملفي التعريفي", + "hide_follows_count_description": "لا تظهر عدد المتابَعين", + "hide_muted_threads": "اخف النقاشات المكتومة", + "no_blocks": "لا يوجد محجوبون", + "show_admin_badge": "أظهر شارة \"مدير\" في ملفي التعريفي", + "conversation_display_tree": "تفرعات", + "notification_setting_block_from_strangers": "احجب اشعارات من لا تتابعهم", + "style": { + "switcher": { + "clear_all": "امسح الكل", + "keep_as_is": "أبقه على حاله", + "use_snapshot": "النسخة القديمة", + "use_source": "النسخة الحديثة", + "load_theme": "حمِّل سمة", + "help": { + "upgraded_from_v2": "PleromaFE حُدث، وعليه ربما ستجد اختلافًا في السمة." + }, + "keep_color": "أبق الألوان", + "keep_opacity": "أبق الشفافية", + "keep_fonts": "أبق الخطوط", + "keep_shadows": "أبق الظلال", + "clear_opacity": "امسح الشفافية" + }, + "common": { + "color": "اللون", + "opacity": "الشافافية" + }, + "advanced_colors": { + "top_bar": "شريط العلوي", + "icons": "أيقونات", + "poll": "منحنى الاستطلاع", + "_tab_label": "متقدم", + "badge_notification": "الإشعارات", + "selectedPost": "منشور محدد", + "selectedMenu": "عنصر محدد من قائمة", + "highlight": "عناصر بارزة", + "disabled": "معطل", + "tabs": "ألسنة", + "chat": { + "border": "حدود", + "incoming": "وارد", + "outgoing": "صادر" + }, + "alert_warning": "تحذير", + "alert_error": "خطأ", + "buttons": "أزرار", + "borders": "الحدود", + "wallpaper": "خلفية", + "pressed": "مضغوط", + "inputs": "حقول إدخال" + }, + "shadows": { + "components": { + "button": "زر", + "input": "حقل إدخال", + "topBar": "شريط العلوي", + "avatar": "الصورة الرمزية لمستخدم (في الملف الشخصي)", + "avatarStatus": "الصورة الرمزية لمستخدم (في منشور)" + }, + "_tab_label": "الظلال والإضاءة", + "shadow_id": "ظل #{value}", + "blur": "طمس", + "spread": "توزع" + }, + "fonts": { + "size": "حجم (بالبكسل)", + "_tab_label": "خطوط", + "components": { + "interface": "واجهة", + "input": "حقول الإدخال", + "post": "نص المنشور" + }, + "family": "اسم الخط", + "custom": "مخصص" + }, + "preview": { + "header": "معاينة", + "content": "محتوى", + "header_faint": "جيد", + "mono": "محتوى", + "button": "زر", + "input": "وصلت للتوّ إلى لوس أنجلس.", + "fine_print": "طالع {0} لتعلّم ما لا ينفعك!", + "error": "مثال خطأ", + "faint_link": "دليل للمساعدة" + }, + "radii": { + "_tab_label": "الانحناء" + } + }, + "notification_setting_privacy": "الخصوصية", + "notification_mutes": "لوقف استلام إشعارات من مستخدم، أخرسه.", + "search_user_to_mute": "جِد من تريد إخراسه", + "subject_input_always_show": "أظهر حقل الموضوع دائمًا", + "subject_line_noop": "لا تنسخ", + "auto_update": "أظهر المنشورات الجديدة تلقائيًا", + "mention_link_display": "اعرض روابط الذكر", + "more_settings": "إعدادات إضافية", + "user_mutes": "مستخدمون", + "mention_link_show_avatar": "أظهر الصورة الرمزية للمستخدم بجانب الرابط", + "preview": "معاينة", + "show_scrollbars": "أظهر شريط التمرير للعمود الجانبي", + "third_column_mode": "أظهر محتوى العمود الثالث إذا توفرت المساحة", + "third_column_mode_none": "لا تظهر العمود الثالث", + "third_column_mode_notifications": "عمود الإشعارات", + "columns": "الأعمدة", + "column_sizes": "حجم الأعمدة", + "column_sizes_sidebar": "الشريط الجانبي", + "type_domains_to_mute": "جِد نطاقًا لكتمه", + "upload_a_photo": "ارفع صورة", + "virtual_scrolling": "حسن تصيير الخيط الزمني", + "user_popover_avatar_action_zoom": "كبر صورة الرمزية", + "fun": "متعة", + "column_sizes_content": "المحتوى", + "column_sizes_notifs": "الإشعارات", + "search_user_to_block": "جِد من تريد حجبه", + "url": "رابط", + "subject_line_behavior": "انسخ الموضوع عند الرد", + "conversation_display": "اسلوب عرض المحادثة", + "mention_link_show_avatar_quick": "أظهر الصورة الرمزية للمستخدم عند ذكره", + "user_popover_avatar_action_open": "افتح الملف الشخصي", + "notifications": "الإشعارات", + "notification_setting_filters": "مرشح", + "notification_setting_hide_notification_contents": "اخف محتوى الإشعارات ومرسليها", + "mention_link_display_short": "اسماء قصيرة (مثل {'@'}foo)", + "mention_link_display_full_for_remote": "اسماء كاملة للمستخدمين من الخوادم البعاد ({'@'}foo{'@'}example.org)", + "version": { + "title": "نسخة" + }, + "commit_value": "احفظ", + "mention_link_display_full": "اسماء كاملة دايمًا (مثل {'@'}foo{'@'}example.org)", + "mute_bot_posts": "اكتم مشاركات الحسابات الآلية", + "mention_links": "روابط الذِكر", + "email_language": "لغة رسائل البريد الإلكتروني المرسلة إلي من الخادم", + "bot": "هذا الحساب آلي", + "discoverable": "اسمح بالعثور على هذا الحساب من خلال البحث وخِدمات أخرى", + "right_sidebar": "عكس ترتيب الأعمدة", + "setting_changed": "الإعدادات مغيّرة", + "setting_server_side": "هذا الإعداد مرتبط بحسابك وسيأثر على كل الجلسات والعملاء", + "allow_following_move": "اسمح بالمتابعة التلقائية عند انتقال حساب متابَع", + "chatMessageRadius": "رسائل", + "domain_mutes": "نطاقات", + "new_email": "البريد إلكتروني الجديد", + "notification_visibility_moves": "هجرة مستخدم", + "subject_line_mastodon": "مثل ماستودون: انسخ الأصلي", + "hide_follows_description": "لا تظهر متابَعي", + "conversation_other_replies_button_inside": "داخل الحالات", + "autohide_floating_post_button": "اخفاء زر النشر تلقائيا (هاتف)", + "conversation_other_replies_button_below": "تحت الحالات", + "reply_visibility_following_short": "أظهر الردود الموجهة إلى متابَعي", + "conversation_display_linear": "خطي", + "conversation_other_replies_button": "أظهر زر \"ردود أخرى\"", + "hide_followers_description": "لا تظهر متابِعي" }, "timeline": { - "collapse": "", + "collapse": "طوي", "conversation": "محادثة", "error_fetching": "خطأ أثناء جلب التحديثات", - "load_older": "تحميل المنشورات القديمة", + "load_older": "حمل الحالات القديمة", "no_retweet_hint": "", - "repeated": "", - "show_new": "عرض الجديد", - "up_to_date": "تم تحديثه" + "repeated": "شورِك", + "show_new": "اعرض الجديد", + "up_to_date": "محدث", + "no_more_statuses": "لا مزيد من الحالات", + "error": "خطأ أثناء جلب الخيط الزمني: {0}", + "reload": "أعد التحميل", + "no_statuses": "لا توجد حالات" }, "user_card": { "approve": "قبول", "block": "حظر", - "blocked": "تم حظره!", + "blocked": "حُظر!", "deny": "رفض", - "follow": "اتبع", - "followees": "", + "follow": "تابع", + "followees": "متابَعون", "followers": "مُتابِعون", - "following": "", + "following": "متابَع!", "follows_you": "يتابعك!", - "mute": "كتم", - "muted": "تم كتمه", + "mute": "أخرِس", + "muted": "أخرَس", "per_day": "في اليوم", "remote_follow": "مُتابَعة عن بُعد", - "statuses": "المنشورات" + "statuses": "المنشورات", + "approve_confirm_accept_button": "قبول", + "approve_confirm_title": "تأكيد القبول", + "edit_profile": "عدّل الملف الشخصي", + "deny_confirm": "أتريد رفض طلب المتابعة من {user} ؟", + "unfollow_confirm_title": "تأكيد إلغاء المتابعة", + "follow_progress": "الطلب جارٍ…", + "hidden": "مخفي", + "its_you": "أنت!", + "approve_confirm_cancel_button": "لا تقبل", + "approve_confirm": "أتريد قبول طلب المتابعة من {user} ؟", + "block_confirm_title": "تأكيد الحظر", + "block_confirm_accept_button": "حظر", + "block_confirm_cancel_button": "لا تحظر", + "deactivated": "عُطل", + "deny_confirm_title": "تأكيد الرفض", + "deny_confirm_accept_button": "رفض", + "deny_confirm_cancel_button": "لا ترفض", + "favorites": "المفضلة", + "follow_cancel": "ألغ الطلب", + "follow_sent": "أُرسل الطلب!", + "follow_unfollow": "ألغ المتابعة", + "unfollow_confirm": "أتريد إلغاء متابعة {user}؟", + "unfollow_confirm_accept_button": "ألغ المتابعة", + "unfollow_confirm_cancel_button": "لا تلغ المتابعة", + "media": "وسائط", + "block_confirm": "أتريد حظر {user} ؟", + "mute_confirm_cancel_button": "لا تخرِس", + "mute_confirm_title": "تأكيد الإخراس", + "message": "راسل", + "mute_confirm": "أتريد إخراس {user}؟", + "mute_confirm_accept_button": "أخرِس", + "mention": "أذكر", + "mute_duration_prompt": "أخرِس هذا الشخص لـ (ضع 0 لكتمه دائمًا):", + "admin_menu": { + "moderation": "الإشراف", + "grant_admin": "امنحه الإدارة", + "revoke_admin": "اخلعه من الإدارة", + "delete_user": "احذف مستخدم", + "deactivate_account": "عطِّل الحساب", + "grant_moderator": "امنحه الإشراف", + "revoke_moderator": "اخلعه من الإشراف", + "activate_account": "فعُّل الحساب", + "delete_account": "احذف الحساب", + "strip_media": "أزل الوسائط من المشاركات", + "delete_user_data_and_deactivate_confirmation": "هذا الإجراء سيحذف بيانات الحساب وسيعطله، هل أنت متيقن؟" + }, + "note": "ملاحظة", + "note_blank": "(لاشيء)", + "edit_note": "حرر الملاحظة", + "edit_note_apply": "طبِّق", + "edit_note_cancel": "ألغِ", + "report": "بلّغ", + "subscribe": "اشترك", + "unsubscribe": "ألغِ الاشتراك", + "unblock_progress": "يرفع الحجب…", + "block_progress": "يحجب…", + "unblock": "ارفع الحجب", + "remove_follower": "أزل متابِع", + "remove_follower_confirm_title": "تأكيد إزالة متابِع", + "remove_follower_confirm_accept_button": "أزِل", + "remove_follower_confirm_cancel_button": "أبق", + "hide_repeats": "اخف المشاركات", + "show_repeats": "أظهر المشاركات", + "bot": "آلي", + "unmute": "ارفع عنه الخرَس", + "unmute_progress": "يرفع الخرَس…", + "mute_progress": "يُخرِس…", + "remove_follower_confirm": "متيقن من إزالة {user} من متابِعيك؟", + "birthday": "وُلد في {birthday}" }, "user_profile": { - "timeline_title": "الخيط الزمني للمستخدم" + "timeline_title": "الخيط الزمني للمستخدم", + "profile_loading_error": "عذرًا، حدث خطأ أثناء تحميل هذا الملف الشخصي.", + "profile_does_not_exist": "عذرًا، هذا الملف الشخصي ليس موجودًا." }, "who_to_follow": { "more": "المزيد", @@ -211,11 +663,355 @@ "keyword_policies": "سياسة الكلمات الدلالية" }, "simple": { - "simple_policies": "سياسات الخادم" + "simple_policies": "سياسات الخادم", + "instance": "مثيل", + "reason": "السبب", + "accept": "قبول", + "reject": "رفض", + "ftl_removal": "أُزيل من الخط الزمني «الشبكات المعروفة»" }, "federation": "الاتحاد", "mrf_policies": "تفعيل سياسات إعادة كتابة المنشور", "mrf_policies_desc": "خاصية إعادة كتابة المناشير تقوم بتعديل تفاعل الاتحاد مع هذا الخادم. السياسات التالية مفعّلة:" } + }, + "announcements": { + "page_header": "إعلانات", + "title": "إعلان", + "mark_as_read_action": "علّمه كمقروء", + "post_form_header": "انشر إعلانًا", + "post_placeholder": "اكتب محتوى الاعلان هنا...", + "post_action": "انشر", + "post_error": "خطأ: {error}", + "close_error": "أغلاق", + "delete_action": "احذف", + "start_time_prompt": "وقت البدأ: ", + "end_time_prompt": "وقت النهاية: ", + "all_day_prompt": "هذا حدث يوم كامل", + "start_time_display": "يبدأ في {time}", + "end_time_display": "ينتهي في {time}", + "edit_action": "حرر", + "submit_edit_action": "أرسل", + "cancel_edit_action": "ألغِ", + "inactive_message": "هذا الاعلان غير نشط", + "published_time_display": "نُشر في {time}" + }, + "polls": { + "votes": "أصوات", + "vote": "صوّت", + "type": "نوع الاستطلاع", + "single_choice": "خيار واحد", + "multiple_choices": "متعدد الخيارات", + "expiry": "عمر الاستطلاع", + "expires_in": "ينتهي الاستطلاع في {0}", + "expired": "انتهى الاستطلاع منذ {0}", + "add_poll": "أضف استطلاعًا", + "add_option": "أضف خيارًا", + "option": "خيار", + "people_voted_count": "{count} شخص صوّت| {count} شخص صوّت", + "votes_count": "{count} صوت | {count} صوت" + }, + "emoji": { + "stickers": "ملصقات", + "emoji": "إيموجي", + "search_emoji": "ابحث عن إيموجي", + "unicode_groups": { + "animals-and-nature": "حيوانات وطبيعة", + "food-and-drink": "أطعمة ومشروبات", + "symbols": "رموز", + "activities": "نشاطات", + "flags": "أعلام", + "smileys-and-emotion": "ابتسامات وانفعالات", + "travel-and-places": "سفر وأماكن" + }, + "add_emoji": "أدخل إيموجي", + "custom": "إيموجي مخصص", + "keep_open": "أبق المنتقي مفتوحًا" + }, + "interactions": { + "emoji_reactions": "تفاعلات بالإيموجي", + "reports": "البلاغات", + "follows": "المتابعات الجديدة" + }, + "report": { + "state_closed": "مغلق", + "state_resolved": "عولج", + "reported_statuses": "الحالة المبلغة عنها:", + "state_open": "مفتوح", + "notes": "ملاحظة:", + "state": "الحالة:", + "reporter": "المبلِّغ:", + "reported_user": "المُبلغ عنه:" + }, + "selectable_list": { + "select_all": "اختر الكل" + }, + "image_cropper": { + "save": "احفظ", + "cancel": "ألغ", + "crop_picture": "اقتصاص الصورة", + "save_without_cropping": "احفظ دون اقتصاص" + }, + "importer": { + "submit": "أرسل", + "success": "نجح الاستيراد.", + "error": "حدث خطأ أثناء الاستيراد." + }, + "domain_mute_card": { + "mute": "أخرِس", + "mute_progress": "يُخرس…", + "unmute": "ارفع عنه الخرس", + "unmute_progress": "يرفع الخرس…" + }, + "exporter": { + "export": "صدر", + "processing": "يُعالج. سيُطلب منك تنزيل الملف قريباً" + }, + "media_modal": { + "previous": "السابق", + "next": "التالي", + "hide": "أغلق عارض الوسائط", + "counter": "{current}\\{total}" + }, + "remote_user_resolver": { + "searching_for": "يبحث عن", + "error": "لم يُعثر عليه." + }, + "admin_dash": { + "nodb": { + "documentation": "التوثيق", + "text2": "اغلب خيارات الضبط لن تتوفر." + }, + "window_title": "الإدارة", + "wip_notice": "لوحة المدير لا زالت تجريبية ولا تزال قيد للتطوير، {adminFeLink}.", + "old_ui_link": "واجهة المدير القديمة هنا", + "commit_all": "احفظ الكل", + "tabs": { + "instance": "مثيل" + }, + "instance": { + "instance": "معلومات المثيل", + "registrations": "تسجيل المستخدمين", + "restrict": { + "header": "قيّد وصول الزواروالمجهولين", + "timelines": "وصول الخط الزمني", + "profiles": "وصول الملفات الشخصية", + "activities": "وصول النشاطات/الحالات" + } + }, + "limits": { + "posts": "حد النشر", + "uploads": "حد المرفقات", + "profile_fields": "حد حقول الملف الشخصي", + "user_uploads": "حد وسائط الملف الشخصي" + }, + "frontend": { + "repository": "رابط المستودع", + "versions": "النسخ المتوفرة", + "build_url": "رابط البناء", + "reinstall": "أعد التثبيت", + "is_default": "(افتراضي)", + "is_default_custom": "(افتراضي، النسخة: {version})", + "install": "ثبّت", + "install_version": "ثبت النسخة {version}", + "more_install_options": "مزيد من خيارات التثبيت", + "set_default": "عينه كافتراضي", + "set_default_version": "عين النسخة {version} كافتراضية", + "available_frontends": "متوفر للتثبيت" + }, + "temp_overrides": { + ":pleroma": { + ":instance": { + ":public": { + "label": "المثيل علني", + "description": "تعطيله سيحصر الوصول إلى API للمستخدمين الوالجين، ولن يقدر الزوار على الوصول إلى الخط الزمني العلني والموحد." + }, + ":description_limit": { + "description": "حد عدد المحارف لوصف المرفق" + }, + ":background_image": { + "label": "صورة الخلفية" + }, + ":limit_to_local_content": { + "label": "اقتصار البحث على المحتوى المحلي" + } + } + } + } + }, + "time": { + "in_past": "منذ {0}", + "unit": { + "hours_short": "{0}سا", + "minutes": "{0} دقيقة | {0} دقائق", + "days_short": "{0}ي", + "minutes_short": "{0}د", + "hours": "{0} ساعة | {0} ساعات", + "weeks": "{0} أسبوع | {0} أسابيع", + "months_short": "{0}ش", + "seconds": "{0} ثانية | {0} ثانية", + "seconds_short": "{0}ثا", + "years": "{0} سنة | {0} سنوات", + "years_short": "{0}سن", + "days": "{0} يوم | {0} أيام", + "months": "{0} شهر | {0} أشهر", + "weeks_short": "{0}أس" + }, + "in_future": "في {0}", + "now": "هذه اللحظة", + "now_short": "الآن" + }, + "status": { + "delete_confirm": "أتريد حذف هذه الحالة؟", + "delete_error": "خطأ أثناء حذف الحالة: {0}", + "plus_more": "+{number} أخرون", + "many_attachments": "المنشور يحوي {number} مرفقات", + "repeat_confirm": "أتريد مشاركة هذه الحالة؟", + "edited_at": "(آخر تعديل {time})", + "repeat_confirm_title": "تأكيد المشاركة", + "repeat_confirm_accept_button": "شارك", + "repeat_confirm_cancel_button": "لا تشارك", + "edit": "حرر الحالة", + "pin": "ثبته على الملف الشخصي", + "unpin": "ألغ تثبيته من الملف الشخصي", + "delete_confirm_cancel_button": "أبقه", + "replies_list": "الردود:", + "status_deleted": "هذا المنشور محذوف", + "favorites": "المفضلة", + "pinned": "مثبت", + "hide_full_subject": "اخف كامل الموضوع", + "repeats": "المشاركات", + "delete": "اخذف الحالة", + "delete_confirm_title": "تأكيد الحذف", + "reply_to": "رد على", + "mentions": "ذكرَ", + "unmute_conversation": "ارفع الكتم عن المحادثة", + "status_unavailable": "الحالة غير متوفرة", + "copy_link": "انسخ رابط الحالة", + "show_full_subject": "أظهر الموضوع كاملا", + "show_content": "أظهر المحتوى", + "hide_content": "اخف المحتوى", + "you": "(أنت)", + "show_all_attachments": "أظهر كل المرفقات", + "hide_attachment": "اخف المرفق", + "move_down": "حرك المرفق لليمين", + "thread_hide": "اخف هذا النقاش", + "thread_muted": "النقاش مكتوم", + "delete_confirm_accept_button": "احذف", + "mute_conversation": "اكتم المحادثة", + "external_source": "مصدر خارجي", + "expand": "وسّع", + "collapse_attachments": "طوي المرفقات", + "remove_attachment": "أزل المرفق", + "move_up": "حرك المرفق لليسار", + "open_gallery": "افتح المعرض", + "thread_show": "أظهر هذا النقاس", + "nsfw": "محتوى حساس NSFW", + "status_history": "تأريخ الحالة", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}", + "ancestor_follow_with_icon": "{icon} {text}", + "show_only_conversation_under_this": "أظهر الردود على هذه الحالة فقط", + "reaction_count_label": "تفاعل {num} شخص | تفاعل {num} أشخاص", + "replies_list_with_others": "رد (+ {numReplies} آخر): | رد (+ {numReplies} آخرون):", + "show_attachment_in_modal": "أظهر الوسائط في منبثقات", + "show_attachment_description": "معاينة الوصف ( افتح المرفق لقراءة الوصف الكامل)" + }, + "lists": { + "creating_list": "إنشاء قائمة جديدة", + "update_title": "احفظ العنوان", + "add_members": "ابحث عن مزيد من المستخدمين", + "really_delete": "أمتيقن من حذف القائمة؟", + "lists": "قوائم", + "new": "قائمة جديدة", + "title": "عنوان القائمة", + "search": "ابحث عن مستخدم", + "remove_from_list": "أزل من القائمة", + "add_to_list": "أضف للقائمة", + "editing_list": "تحرير القائمة {listTitle}", + "create": "أنشئ", + "save": "احفظ التعديلات", + "delete": "احذف القائمة", + "manage_lists": "أدِر القوائم", + "manage_members": "أدِر أعضاء القائمة", + "is_in_list": "موجود في القائمة سلفًا" + }, + "file_type": { + "audio": "صوت", + "image": "صورة", + "file": "ملف", + "video": "فيديو" + }, + "user_reporting": { + "add_comment_description": "سيرسل البلاغ إلى مشرف المثيل، يمكنك شرح سبب البلاغ أدناه:", + "title": "بلاغ عن {0}", + "additional_comments": "تعليقات إضافية", + "forward_description": "هذا المستخدم من خادم آخر. هل تريد إرسال نسخة منه إلى مشرفه؟", + "forward_to": "وجّهه إلى {0}", + "submit": "أرسل", + "generic_error": "حدث خطأ أثناء معالجة طلبك." + }, + "tool_tip": { + "media_upload": "ارفع وسائط", + "favorite": "فضّل", + "add_reaction": "أضف تفاعل", + "user_settings": "إعدادات المستخدم", + "accept_follow_request": "اقبل طلب المتابعة", + "reject_follow_request": "ارفض طلب المتابعة", + "repeat": "شارك", + "reply": "ردّ" + }, + "upload": { + "error": { + "base": "فشل الرفع.", + "message": "فشل الرفع: {0}", + "default": "حاو لاحقًا", + "file_too_big": "حجم الملف كبير [{filesize}{filesizeunit}\\{allowedsize}{allowedsizeunit}]" + }, + "file_size_units": { + "B": "بايت", + "MiB": "مب", + "TiB": "تب", + "GiB": "غب", + "KiB": "كب" + } + }, + "search": { + "person_talking": "{count} شخص يتكلم", + "people_talking": "{count} شخص يتكلم", + "no_results": "لا نتائج", + "no_more_results": "لا مزيد من النتائج", + "people": "أشخاص", + "hashtags": "وسوم", + "load_more": "حمّل مزيدًا من النتائج" + }, + "password_reset": { + "forgot_password": "أنسيت كلمة السر؟", + "placeholder": "البريد الإلكتروني أو اسم المستخدم", + "return_home": "عُد للصفحة الرئيسية", + "too_many_requests": "وصلت سقف المحاولات، حاول لاحقًا." + }, + "chats": { + "chats": "محادثات", + "delete_confirm": "أتريد حذف هذه الرسالة؟", + "you": "أنت:", + "message_user": "راسل {nickname}", + "delete": "احذف", + "new": "محادثة جديدة", + "empty_message_error": "يستحيل إرسال رسالة فارغة", + "more": "مزيد", + "empty_chat_list_placeholder": "ليس لديك محادثات. ابدأ واحدة جديدة!" + }, + "display_date": { + "today": "اليوم" + }, + "update": { + "big_update_content": "نظرًا لطول المدة التي استغرقها تطوير هذا الاصدار فسترى اختلافات كبيرة عن ما اعتدت عليه.", + "update_bugs": "نظرًا لهذا لكبر هذا التحديث فقد نكون قد سهينى عن بعض الاخطاء لذا يرجى التبليغ عن أي علّة أو مشكلة. نحن نرحب بقتراحاتك وتعليقاتكم لتحسين بليروما وواجهها الأمامية وطرح المشاكل المتعلقة بهما.", + "update_changelog": "لمزيد من المعلومات، راجع {theFullChangelog}.", + "update_changelog_here": "سجل التغييرات الكامل", + "art_by": "رَسمُ {linkToArtist}", + "big_update_title": "رجاءً تعاون معنا" } } diff --git a/src/i18n/en.json b/src/i18n/en.json index b051f088..2358a4ce 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -261,6 +261,8 @@ "post_status": { "edit_status": "Edit status", "new_status": "Post new status", + "reply_option": "Reply to this status", + "quote_option": "Quote this status", "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.", "account_not_locked_warning_link": "locked", "attachments_sensitive": "Mark attachments as sensitive", @@ -519,6 +521,8 @@ "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", "mutes_tab": "Mutes", "play_videos_in_modal": "Play videos in a popup frame", + "url": "URL", + "preview": "Preview", "file_export_import": { "backup_restore": "Settings backup", "backup_settings": "Backup settings to file", @@ -830,6 +834,98 @@ "title": "Version", "backend_version": "Backend version", "frontend_version": "Frontend version" + }, + "commit_value": "Save", + "commit_value_tooltip": "Value is not saved, press this button to commit your changes", + "reset_value": "Reset", + "reset_value_tooltip": "Reset draft", + "hard_reset_value": "Hard reset", + "hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value" + }, + "admin_dash": { + "window_title": "Administration", + "wip_notice": "This admin dashboard is experimental and WIP, {adminFeLink}.", + "old_ui_link": "old admin UI available here", + "reset_all": "Reset all", + "commit_all": "Save all", + "tabs": { + "nodb": "No DB Config", + "instance": "Instance", + "limits": "Limits", + "frontends": "Front-ends" + }, + "nodb": { + "heading": "Database config is disabled", + "text": "You need to change backend config files so that {property} is set to {value}, see more in {documentation}.", + "documentation": "documentation", + "text2": "Most configuration options will be unavailable." + }, + "captcha": { + "native": "Native", + "kocaptcha": "KoCaptcha" + }, + "instance": { + "instance": "Instance information", + "registrations": "User sign-ups", + "captcha_header": "CAPTCHA", + "kocaptcha": "KoCaptcha settings", + "access": "Instance access", + "restrict": { + "header": "Restrict access for anonymous visitors", + "description": "Detailed setting for allowing/disallowing access to certain aspects of API. By default (indeterminate state) it will disallow if instance is not public, ticked checkbox means disallow access even if instance is public, unticked means allow access even if instance is private. Please note that unexpected behavior might happen if some settings are set, i.e. if profile access is disabled posts will show without profile information.", + "timelines": "Timelines access", + "profiles": "User profiles access", + "activities": "Statues/activities access" + } + }, + "limits": { + "arbitrary_limits": "Arbitrary limits", + "posts": "Post limits", + "uploads": "Attachments limits", + "users": "User profile limits", + "profile_fields": "Profile fields limits", + "user_uploads": "Profile media limits" + }, + "frontend": { + "repository": "Repository link", + "versions": "Available versions", + "build_url": "Build URL", + "reinstall": "Reinstall", + "is_default": "(Default)", + "is_default_custom": "(Default, version: {version})", + "install": "Install", + "install_version": "Install version {version}", + "more_install_options": "More install options", + "more_default_options": "More default setting options", + "set_default": "Set default", + "set_default_version": "Set version {version} as default", + "wip_notice": "Please note that this section is a WIP and lacks certain features as backend implementation of front-end management is incomplete.", + "default_frontend": "Default front-end", + "default_frontend_tip": "Default front-end will be shown to all users. Currently there's no way to for a user to select personal front-end. If you switch away from PleromaFE you'll most likely have to use old and buggy AdminFE to do instance configuration until we replace it.", + "default_frontend_tip2": "WIP: Since Pleroma backend doesn't properly list all installed frontends you'll have to enter name and reference manually. List below provides shortcuts to fill the values.", + "available_frontends": "Available for install" + }, + "temp_overrides": { + ":pleroma": { + ":instance": { + ":public": { + "label": "Instance is public", + "description": "Disabling this will make all API accessible only for logged-in users, this will make Public and Federated timelines inaccessible to anonymous visitors." + }, + ":limit_to_local_content": { + "label": "Limit search to local content", + "description": "Disables global network search for unauthenticated (default), all users or none" + }, + ":description_limit": { + "label": "Limit", + "description": "Character limit for attachment descriptions" + }, + ":background_image": { + "label": "Background image", + "description": "Background image (primarily used by PleromaFE)" + } + } + } } }, "time": { @@ -933,7 +1029,11 @@ "show_all_conversation_with_icon": "{icon} {text}", "show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)", "show_only_conversation_under_this": "Only show replies to this status", - "status_history": "Status history" + "status_history": "Status history", + "reaction_count_label": "{num} person reacted | {num} people reacted", + "hide_quote": "Hide the quoted status", + "display_quote": "Display the quoted status", + "invisible_quote": "Quoted status unavailable: {link}" }, "user_card": { "approve": "Approve", diff --git a/src/i18n/eo.json b/src/i18n/eo.json index e013edee..249a0aab 100644 --- a/src/i18n/eo.json +++ b/src/i18n/eo.json @@ -55,8 +55,8 @@ "undo": "Malfari", "yes": "Jes", "no": "Ne", - "unpin": "Malfiksi eron", - "pin": "Fiksi eron", + "unpin": "Malfiksi", + "pin": "Fiksi", "scroll_to_top": "Rulumi supren" }, "image_cropper": { @@ -81,7 +81,11 @@ "recovery_code": "Rehava kodo", "enter_two_factor_code": "Enigu kodon de duobla aŭtentikigo", "enter_recovery_code": "Enigu rehavan kodon", - "authentication_code": "Aŭtentikiga kodo" + "authentication_code": "Aŭtentikiga kodo", + "logout_confirm_title": "Konfirmo de adiaŭo", + "logout_confirm": "Ĉu vi certe volas adiaŭi?", + "logout_confirm_accept_button": "Adiaŭi", + "logout_confirm_cancel_button": "Ne adiaŭi" }, "media_modal": { "previous": "Antaŭa", @@ -90,7 +94,7 @@ "hide": "Fermi vidilon de vidaŭdaĵoj" }, "nav": { - "about": "Pri", + "about": "Prio", "back": "Reen", "chat": "Loka babilejo", "friend_requests": "Petoj pri abono", @@ -115,7 +119,9 @@ "edit_finish": "Fini redakton", "mobile_notifications": "Malfermi sciigojn (estas nelegitaj)", "mobile_notifications_close": "Fermi sciigojn", - "announcements": "Anoncoj" + "announcements": "Anoncoj", + "search_close": "Fermi serĉujon", + "mobile_sidebar": "(Mal)ŝalti flankan breton por telefonoj" }, "notifications": { "broken_favorite": "Nekonata afiŝo, serĉante ĝin…", @@ -169,7 +175,9 @@ "post": "Afiŝo", "edit_remote_warning": "Aliaj foraj nodoj eble ne subtenas redaktadon, kaj ne povos ricevi pli novan version de via afiŝo.", "edit_unsupported_warning": "Pleroma ne subtenas redaktadon de mencioj aŭ enketoj.", - "edit_status": "Redakti afiŝon" + "edit_status": "Redakti afiŝon", + "content_type_selection": "Formo de afiŝo", + "scope_notice_dismiss": "Fermi ĉi tiun avizon" }, "registration": { "bio": "Priskribo", @@ -189,14 +197,18 @@ "email_required": "ne povas resti malplena", "password_required": "ne povas resti malplena", "password_confirmation_required": "ne povas resti malplena", - "password_confirmation_match": "samu la pasvorton" + "password_confirmation_match": "samu la pasvorton", + "birthday_min_age": "ne povas esti post {date}", + "birthday_required": "ne povas resti malplena" }, "reason_placeholder": "Ĉi-node oni aprobas registriĝojn permane.\nSciigu la administrantojn kial vi volas registriĝi.", "reason": "Kialo registriĝi", "register": "Registriĝi", "bio_optional": "Prio (malnepra)", "email_optional": "Retpoŝtadreso (malnepra)", - "email_language": "En kiu lingvo vi volus ricevi retleterojn de la servilo?" + "email_language": "En kiu lingvo vi volus ricevi retleterojn de la servilo?", + "birthday": "Naskiĝtago:", + "birthday_optional": "Naskiĝtago (malnepra):" }, "settings": { "app_name": "Nomo de aplikaĵo", @@ -666,7 +678,28 @@ "user_popover_avatar_overlay": "Aperigi ŝprucaĵon pri uzanto sur profilbildo", "show_yous": "Montri la markon «(Vi)»", "user_popover_avatar_action_zoom": "Zomi la profilbildon", - "third_column_mode": "Kun sufiĉo da spaco, montri trian kolumnon kun" + "third_column_mode": "Kun sufiĉo da spaco, montri trian kolumnon kun", + "birthday": { + "show_birthday": "Montri mian naskiĝtagon", + "label": "Naskiĝtago" + }, + "confirm_dialogs_delete": "forigo de afiŝo", + "backup_running": "Ĉi tiu savkopiado progresas, traktis {number} datumon. | Ĉi tiu savkopiado progresas, traktis {number} datumojn.", + "backup_failed": "Ĉi tiu savkopiado malsukcesis.", + "autocomplete_select_first": "Memage elekti unuan kandidaton kiam rezultoj de memaga konjektado disponeblas", + "confirm_dialogs_logout": "adiaŭo", + "user_popover_avatar_action": "Post klako sur profilbildon en ŝprucaĵo", + "remove_language": "Forigi", + "primary_language": "Ĉefa lingvo:", + "confirm_dialogs": "Peti konfirmon je", + "confirm_dialogs_repeat": "ripeto de afiŝo", + "confirm_dialogs_unfollow": "malabono de uzanto", + "confirm_dialogs_block": "blokado de uzanto", + "confirm_dialogs_mute": "silentigo de uzanto", + "confirm_dialogs_approve_follow": "aprobo de abonanto", + "confirm_dialogs_deny_follow": "malaprobo de abonanto", + "confirm_dialogs_remove_follower": "forigo de abonanto", + "tree_fade_ancestors": "Montri responditojn de la nuna afiŝo per teksto malvigla" }, "timeline": { "collapse": "Maletendi", @@ -753,7 +786,33 @@ "note_blank": "(Neniu)", "edit_note_apply": "Apliki", "edit_note_cancel": "Nuligi", - "edit_note": "Redakti noton" + "edit_note": "Redakti noton", + "block_confirm": "Ĉu vi certe volas bloki uzanton {user}?", + "block_confirm_accept_button": "Bloki", + "remove_follower_confirm": "Ĉu vi certe volas forigi uzanton {user} de viaj abonantoj?", + "approve_confirm_accept_button": "Aprobi", + "approve_confirm_cancel_button": "Ne aprobi", + "approve_confirm": "Ĉu vi certe volas aprobi abonan peton de {user}?", + "block_confirm_title": "Konfirmo de blokado", + "approve_confirm_title": "Konfirmo de aprobo", + "block_confirm_cancel_button": "Ne bloki", + "deny_confirm_accept_button": "Malaprobi", + "deny_confirm_cancel_button": "Ne malaprobi", + "mute_confirm_title": "Silentigi konfirmon", + "deny_confirm_title": "Konfirmo de malaprobo", + "mute_confirm": "Ĉu vi certe volas silentigi uzanton {user}?", + "mute_confirm_accept_button": "Silentigi", + "mute_confirm_cancel_button": "Ne silentigi", + "mute_duration_prompt": "Silentigi ĉi tiun uzanton por (0 signifas senliman silentigon):", + "remove_follower_confirm_accept_button": "Forigi", + "remove_follower_confirm_title": "Konfirmo de forigo de abonanto", + "birthday": "Naskita je {birthday}", + "deny_confirm": "Ĉu vi certe volas malaprobi abonan peton de {user}?", + "unfollow_confirm_cancel_button": "Ne malaboni", + "unfollow_confirm_title": "Konfirmo de malabono", + "unfollow_confirm": "Ĉu vi certe volas malaboni uzanton {user}?", + "unfollow_confirm_accept_button": "Malaboni", + "remove_follower_confirm_cancel_button": "Ne forigi" }, "user_profile": { "timeline_title": "Historio de uzanto", @@ -775,7 +834,8 @@ "accept_follow_request": "Akcepti abonpeton", "add_reaction": "Aldoni reagon", "toggle_expand": "Etendi aŭ maletendi sciigon por montri plenan afiŝon", - "toggle_mute": "Etendi aŭ maletendi afiŝon por montri silentigitan enhavon" + "toggle_mute": "Etendi aŭ maletendi afiŝon por montri silentigitan enhavon", + "autocomplete_available": "{number} rezulto disponeblas. Uzu la sagajn klavojn supren kaj suben por foliumi ilin. | {number} rezulto disponeblas. Uzu la sagajn klavojn supren kaj suben por foliumi ilin." }, "upload": { "error": { @@ -951,7 +1011,14 @@ "show_all_conversation_with_icon": "{icon} {text}", "show_only_conversation_under_this": "Montri nur respondojn al ĉi tiu afiŝo", "status_history": "Historio de afiŝo", - "open_gallery": "Malfermi galerion" + "open_gallery": "Malfermi galerion", + "delete_confirm_title": "Konfirmo de forigo", + "delete_confirm_accept_button": "Forigi", + "repeat_confirm": "Ĉu vi certe volas ripeti ĉi tiun afiŝon?", + "repeat_confirm_title": "Konfirmo de ripeto", + "repeat_confirm_accept_button": "Ripeti", + "repeat_confirm_cancel_button": "Ne ripeti", + "delete_confirm_cancel_button": "Ne forigi" }, "time": { "years_short": "{0}j", @@ -1119,6 +1186,7 @@ "edit_action": "Redakti", "submit_edit_action": "Afiŝi", "cancel_edit_action": "Nuligi", - "inactive_message": "Ĉi tiu anonco estas neaktiva" + "inactive_message": "Ĉi tiu anonco estas neaktiva", + "post_form_header": "Afiŝi anoncon" } } diff --git a/src/i18n/id.json b/src/i18n/id.json index 73cc2a71..1b5fee5a 100644 --- a/src/i18n/id.json +++ b/src/i18n/id.json @@ -214,7 +214,8 @@ "domain_mutes": "Domain", "composing": "Menulis", "no_blocks": "Tidak ada yang diblokir", - "no_mutes": "Tidak ada yang dibisukan" + "no_mutes": "Tidak ada yang dibisukan", + "remove_language": "Hapus" }, "about": { "mrf": { @@ -230,7 +231,9 @@ "accept_desc": "Instansi ini hanya menerima pesan dari instansi-instansi berikut:", "accept": "Terima", "media_removal": "Penghapusan Media", - "media_removal_desc": "Instansi ini menghapus media dari postingan yang berasal dari instansi-instansi berikut:" + "media_removal_desc": "Instansi ini menghapus media dari postingan yang berasal dari instansi-instansi berikut:", + "instance": "Instance", + "reason": "Alasan" }, "federation": "Federasi", "mrf_policies": "Kebijakan MRF yang diaktifkan" @@ -437,7 +440,10 @@ "password_required": "tidak boleh kosong", "email_required": "tidak boleh kosong", "fullname_required": "tidak boleh kosong", - "username_required": "tidak boleh kosong" + "username_required": "tidak boleh kosong", + "password_confirmation_match": "wajib sama dengan sandi", + "birthday_required": "tidak boleh kosong", + "birthday_min_age": "wajib sama dengan atau sebelum {date}" }, "register": "Daftar", "fullname_placeholder": "contoh. Lain Iwakura", @@ -450,7 +456,12 @@ "bio": "Bio", "reason_placeholder": "Instansi ini menerima pendaftaran secara manual.\nBeritahu administrasinya mengapa Anda ingin mendaftar.", "reason": "Alasan mendaftar", - "registration": "Pendaftaran" + "registration": "Pendaftaran", + "email_language": "Dalam bahasa apa kamu ingin menerima surel dari server ini?", + "email_optional": "Surel (opsional)", + "birthday": "Ulang tahun:", + "birthday_optional": "Ulang tahun (opsional):", + "bio_optional": "Bio (opsional)" }, "post_status": { "preview_empty": "Kosong", @@ -482,7 +493,8 @@ "empty_status_error": "Tidak dapat memposting status kosong tanpa berkas", "account_not_locked_warning_link": "terkunci", "account_not_locked_warning": "Akun Anda tidak {0}. Siapapun dapat mengikuti Anda untuk melihat postingan hanya-pengikut Anda.", - "new_status": "Posting status baru" + "new_status": "Posting status baru", + "edit_status": "Sunting status" }, "general": { "apply": "Terapkan", @@ -508,7 +520,15 @@ "generic_error": "Terjadi kesalahan", "loading": "Memuat…", "more": "Lebih banyak", - "submit": "Kirim" + "submit": "Kirim", + "yes": "Ya", + "no": "Tidak", + "scope_in_timeline": { + "direct": "Langsung", + "private": "Hanya pengikut", + "public": "Publik" + }, + "generic_error_message": "Terjadi kesalahan: {0}" }, "remote_user_resolver": { "error": "Tidak ditemukan." @@ -522,7 +542,18 @@ "emoji": "Emoji", "stickers": "Stiker", "keep_open": "Tetap buka pemilih", - "custom": "Emoji kustom" + "custom": "Emoji kustom", + "unicode_groups": { + "activities": "Aktivitas", + "animals-and-nature": "Hewan & Alam", + "flags": "Bendera", + "food-and-drink": "Makanan & Minuman", + "objects": "Objek", + "people-and-body": "Orang & Tubuh", + "smileys-and-emotion": "Emosi", + "symbols": "Simbol", + "travel-and-places": "Perjalanan & Tempat-tempat" + } }, "polls": { "expired": "Japat berakhir {0} yang lalu", @@ -553,11 +584,17 @@ "timelines": "Linimasa", "chats": "Obrolan", "dms": "Pesan langsung", - "friend_requests": "Ingin mengikuti" + "friend_requests": "Ingin mengikuti", + "twkn": "Jaringan Dikenal", + "mobile_notifications_close": "Tutup notifikasi", + "announcements": "Pengumuman", + "mobile_notifications": "Buka notifikasi (ada yang belum dibaca)" }, "media_modal": { "next": "Selanjutnya", - "previous": "Sebelum" + "previous": "Sebelum", + "counter": "{current} / {total}", + "hide": "Tutup penampil media" }, "login": { "recovery_code": "Kode pemulihan", @@ -574,7 +611,10 @@ "heading": { "totp": "Otentikasi dua-faktor" }, - "enter_two_factor_code": "Masukkan kode dua-faktor" + "enter_two_factor_code": "Masukkan kode dua-faktor", + "logout_confirm": "Apa kamu yakin ingin keluar?", + "logout_confirm_accept_button": "Keluar", + "logout_confirm_cancel_button": "Jangan keluar" }, "importer": { "error": "Terjadi kesalahan ketika mnengimpor berkas ini.", @@ -597,7 +637,8 @@ "gopher": "Gopher", "pleroma_chat_messages": "Pleroma Obrolan", "chat": "Obrolan", - "upload_limit": "Batas unggahan" + "upload_limit": "Batas unggahan", + "media_proxy": "Proxy media" }, "exporter": { "processing": "Memproses, Anda akan segera diminta untuk mengunduh berkas Anda", @@ -619,12 +660,41 @@ "moves": "Pengguna yang bermigrasi", "follows": "Pengikut baru", "favs_repeats": "Ulangan dan favorit", - "load_older": "Muat interaksi yang lebih tua" + "load_older": "Muat interaksi yang lebih tua", + "emoji_reactions": "Reaksi Emoji", + "reports": "Laporan" }, "errors": { "storage_unavailable": "Pleroma tidak dapat mengakses penyimpanan browser. Login Anda atau pengaturan lokal Anda tidak akan tersimpan dan masalah yang tidak terduga dapat terjadi. Coba mengaktifkan kuki." }, "shoutbox": { "title": "Kotak Suara" + }, + "report": { + "state_closed": "Ditutup", + "reporter": "Pelapor:", + "reported_statuses": "Status yang dilaporkan:", + "reported_user": "Pengguna yang dilaporkan:", + "notes": "Catatan:", + "state": "Status:", + "state_open": "Terbuka", + "state_resolved": "Selesai" + }, + "announcements": { + "end_time_prompt": "Waktu berakhir: ", + "published_time_display": "Diterbitkan pada {time}", + "page_header": "Pengumuman", + "title": "Pengumuman", + "mark_as_read_action": "Tandai telah dibaca", + "post_placeholder": "Ketik isi pengumumanmu di sini...", + "close_error": "Tutup", + "delete_action": "Hapus", + "start_time_prompt": "Waktu mulai: ", + "post_error": "Kesalahan: {error}", + "start_time_display": "Dimulai pada {time}", + "end_time_display": "Berakhir pada {time}", + "edit_action": "Sunting", + "submit_edit_action": "Kirim", + "cancel_edit_action": "Batal" } } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 657bb079..1e7e58c3 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -75,7 +75,11 @@ "enter_two_factor_code": "2단계인증 코드를 입력하십시오", "enter_recovery_code": "복구 코드를 입력하십시오", "authentication_code": "인증 코드", - "hint": "로그인해서 대화에 참여" + "hint": "로그인해서 대화에 참여", + "logout_confirm_title": "로그아웃 확인", + "logout_confirm": "정말 로그아웃 하시겠습니까?", + "logout_confirm_accept_button": "로그아웃", + "logout_confirm_cancel_button": "로그아웃 안 함" }, "nav": { "about": "인스턴스 소개", @@ -104,7 +108,8 @@ "edit_finish": "편집 종료", "mobile_notifications_close": "알림 닫기", "mobile_sidebar": "모바일 사이드바 토글", - "announcements": "공지사항" + "announcements": "공지사항", + "search_close": "검색 바 닫기" }, "notifications": { "broken_favorite": "알 수 없는 게시물입니다, 검색합니다…", @@ -158,7 +163,9 @@ "edit_status": "수정", "edit_remote_warning": "수정 기능이 없는 다른 인스턴스에서는 수정한 사항이 반영되지 않을 수 있습니다.", "post": "게시", - "direct_warning_to_first_only": "맨 앞에 멘션한 사용자들에게만 보여집니다." + "direct_warning_to_first_only": "맨 앞에 멘션한 사용자들에게만 보여집니다.", + "content_type_selection": "게시물 형태", + "scope_notice_dismiss": "알림 닫기" }, "registration": { "bio": "소개", @@ -175,7 +182,9 @@ "email_required": "공백으로 둘 수 없습니다", "password_required": "공백으로 둘 수 없습니다", "password_confirmation_required": "공백으로 둘 수 없습니다", - "password_confirmation_match": "패스워드와 일치해야 합니다" + "password_confirmation_match": "패스워드와 일치해야 합니다", + "birthday_required": "공백으로 둘 수 없습니다", + "birthday_min_age": "{date} 또는 그 이전 출생만 가능합니다" }, "fullname_placeholder": "예: 김례인", "username_placeholder": "예: lain", @@ -185,7 +194,9 @@ "reason": "가입하려는 이유", "reason_placeholder": "이 인스턴스는 수동으로 가입을 승인하고 있습니다.\n왜 가입하고 싶은지 관리자에게 알려주세요.", "register": "가입", - "email_language": "무슨 언어로 이메일을 받길 원하시나요?" + "email_language": "무슨 언어로 이메일을 받길 원하시나요?", + "birthday": "생일:", + "birthday_optional": "생일 (선택):" }, "settings": { "attachmentRadius": "첨부물", @@ -383,7 +394,8 @@ "highlight": "강조 요소", "pressed": "눌렸을 때", "toggled": "토글됨", - "tabs": "탭" + "tabs": "탭", + "underlay": "밑배경" }, "radii": { "_tab_label": "둥글기" @@ -652,7 +664,29 @@ "post_status_content_type": "게시물 내용 형식", "list_aliases_error": "별칭을 가져오는 중 에러 발생: {error}", "add_alias_error": "별칭을 추가하는 중 에러 발생: {error}", - "mention_link_show_avatar_quick": "멘션 옆에 유저 프로필 사진을 보임" + "mention_link_show_avatar_quick": "멘션 옆에 유저 프로필 사진을 보임", + "backup_running": "백업 중입니다, {number}개 처리 완료. | 백업 중입니다, {number}개 처리 완료.", + "confirm_dialogs": "하기 전에 다시 물어보기", + "autocomplete_select_first": "자동완성이 가능하면 자동으로 첫 번째 후보를 선택", + "backup_failed": "백업에 실패했습니다.", + "emoji_reactions_scale": "리액션 크기", + "birthday": { + "label": "생일", + "show_birthday": "내 생일 보여주기" + }, + "add_language": "보조 언어 추가", + "confirm_dialogs_repeat": "리핏", + "confirm_dialogs_unfollow": "언팔로우", + "confirm_dialogs_block": "차단", + "confirm_dialogs_mute": "뮤트", + "confirm_dialogs_delete": "게시물 삭제", + "confirm_dialogs_approve_follow": "팔로워 승인", + "confirm_dialogs_deny_follow": "팔로워 거절", + "confirm_dialogs_remove_follower": "팔로워 제거", + "remove_language": "삭제", + "primary_language": "주 언어:", + "fallback_language": "보조 언어 {index}:", + "confirm_dialogs_logout": "로그아웃" }, "timeline": { "collapse": "접기", @@ -735,7 +769,12 @@ "striped": "줄무늬 배경", "solid": "단색 배경", "side": "옆트임" - } + }, + "approve_confirm_title": "승인 확인", + "approve_confirm_accept_button": "승인", + "approve_confirm_cancel_button": "승인 안 함", + "approve_confirm": "{user}의 팔로우 요청을 승인할까요?", + "block_confirm_title": "차단 확인" }, "user_profile": { "timeline_title": "사용자 타임라인", @@ -1069,7 +1108,14 @@ "ancestor_follow_with_icon": "{icon} {text}", "show_all_conversation_with_icon": "{icon} {text}", "ancestor_follow": "이 게시물 아래 {numReplies}개 답글 더 보기 | 이 게시물 아래 {numReplies}개 답글 더 보기", - "show_only_conversation_under_this": "이 게시물의 답글만 보기" + "show_only_conversation_under_this": "이 게시물의 답글만 보기", + "repeat_confirm": "리핏할까요?", + "repeat_confirm_title": "리핏 확인", + "repeat_confirm_accept_button": "리핏", + "repeat_confirm_cancel_button": "리핏 안 함", + "delete_confirm_title": "삭제 확인", + "delete_confirm_accept_button": "삭제", + "delete_confirm_cancel_button": "냅두기" }, "errors": { "storage_unavailable": "Pleroma가 브라우저 저장소에 접근할 수 없습니다. 로그인이 풀리거나 로컬 설정이 초기화 되는 등 예상치 못한 문제를 겪을 수 있습니다. 쿠키를 활성화 해보세요." diff --git a/src/i18n/languages.js b/src/i18n/languages.js index 250b3b1a..33a98f8e 100644 --- a/src/i18n/languages.js +++ b/src/i18n/languages.js @@ -1,4 +1,3 @@ - const languages = [ 'ar', 'ca', @@ -18,6 +17,7 @@ const languages = [ 'ja', 'ja_easy', 'ko', + 'nan-TW', 'nb', 'nl', 'oc', diff --git a/src/i18n/nan-TW.json b/src/i18n/nan-TW.json new file mode 100644 index 00000000..491018ad --- /dev/null +++ b/src/i18n/nan-TW.json @@ -0,0 +1,806 @@ +{ + "about": { + "mrf": { + "federation": "聯邦", + "keyword": { + "keyword_policies": "關鍵字政策", + "ftl_removal": "Tuì「知影 ê 網路」時間線除掉", + "reject": "拒絕", + "replace": "取代", + "is_replaced_by": "→" + }, + "mrf_policies": "啟用 ê MRF 政策", + "mrf_policies_desc": "MRF 政策操作本站 ê 對外通信行為。以下ê政策啟用 ah:", + "simple": { + "simple_policies": "站臺特有 ê 政策", + "instance": "站", + "reason": "理由", + "accept": "接受", + "accept_desc": "本站干焦接受下跤 ê 站 ê 短 phue:", + "reject": "拒絕", + "reject_desc": "本站 buē 接受 tuì 以下 ê 站 ê 短 phue:", + "quarantine": "隔離", + "quarantine_desc": "針對下跤 ê 站,本站干焦送出公開ê PO文:", + "ftl_removal": "Tuì「知影 ê 網路」時間線thâi掉", + "ftl_removal_desc": "本站buē 佇「知影 ê 網路」刊下跤 ê 站 ê PO文:", + "media_removal": "Thâi除媒體", + "media_removal_desc": "本站 kā 下跤 ê 站臺送 ê PO文 ê 媒體 lóng thâi 除:", + "media_nsfw": "媒體 lóng 標做「敏感內容」", + "media_nsfw_desc": "本站 kā 下跤 ê 站 ê 媒體,lóng 標做敏感內容:", + "not_applicable": "N/A" + } + }, + "staff": "工作人員" + }, + "announcements": { + "page_header": "公告", + "title": "公告", + "mark_as_read_action": "標做讀過", + "post_form_header": "貼公告", + "post_placeholder": "佇 tsia 拍你 ê 公告……", + "post_action": "貼", + "post_error": "錯誤:{error}", + "close_error": "關", + "start_time_prompt": "開始時間: ", + "end_time_prompt": "結束時間: ", + "all_day_prompt": "Tse 是 kui 工 ê 事件", + "published_time_display": "公告佇 {time}", + "start_time_display": "有效 tuì:{time}", + "end_time_display": "中止佇:{time}", + "edit_action": "編輯", + "submit_edit_action": "送出", + "cancel_edit_action": "取消", + "inactive_message": "這个公告 tsit-má 無效力", + "delete_action": "Thâi掉" + }, + "shoutbox": { + "title": "留話枋" + }, + "domain_mute_card": { + "mute": "消音", + "mute_progress": "Teh 消音……", + "unmute": "予有聲", + "unmute_progress": "Teh 予有聲……" + }, + "exporter": { + "export": "匯出", + "processing": "Teh 處理,較停仔指示你下載檔案" + }, + "features_panel": { + "shout": "留話枋", + "pleroma_chat_messages": "Pleroma 開講", + "media_proxy": "媒體代理伺侯器", + "scope_options": "公開範圍選項", + "text_limit": "字數限制", + "title": "有效 ê 功能", + "who_to_follow": "啥儂通綴", + "upload_limit": "檔案 sài-suh 限制", + "gopher": "Gopher" + }, + "finder": { + "error_fetching_user": "Tshuē 用者 ê 時起錯誤", + "find_user": "Tshuē 用者" + }, + "general": { + "apply": "應用", + "submit": "送出", + "more": "Koh 較 tsē", + "loading": "Leh 載入……", + "generic_error": "起錯誤 ah", + "generic_error_message": "起錯誤:{0}", + "error_retry": "請 koh 試一 kái", + "retry": "Koh 試", + "optional": "非必要", + "show_more": "展示較 tsē", + "show_less": "展示較少", + "never_show_again": "Mài koh 展示", + "dismiss": "無視", + "cancel": "取消", + "disable": "無愛用", + "enable": "啟用", + "confirm": "確認", + "verify": "驗證", + "close": "關掉", + "undo": "復原", + "yes": "是", + "no": "毋是", + "peek": "先看 māi", + "scroll_to_top": "捲 kàu 頂懸", + "role": { + "admin": "行政員", + "moderator": "管理員" + }, + "unpin": "無愛 kā 釘", + "pin": "Kā釘起來", + "flash_content": "Ji̍h tsia,用 Ruffle(iáu teh 試驗,可能 buē 紡)看 Flash ê 內容。", + "flash_sepcurity": "注意 tse 可能有危險,因為 Flash 內容猶原是任意 ê 程式碼。", + "flash_fail": "載入 flash 內容失敗,詳細ē當看控制臺。", + "scope_in_timeline": { + "direct": "私人 phue", + "private": "干焦 hōo 綴 lí ê 看", + "public": "公開佇公共時間線", + "unlisted": "無愛公開佇公共時間線" + }, + "flash_security": "Flash內容通藏任何ê指令,所以可能有危險。" + }, + "image_cropper": { + "crop_picture": "裁相片", + "save": "儲存", + "save_without_cropping": "無裁就儲存", + "cancel": "取消" + }, + "importer": { + "submit": "送出", + "success": "匯入成功。", + "error": "佇匯入 ê 時起錯誤。" + }, + "login": { + "login": "登入", + "description": "用 OAuth 登入", + "logout": "登出", + "logout_confirm_title": "登出確認", + "logout_confirm": "Lí 敢真正 beh 登出?", + "logout_confirm_accept_button": "登出", + "logout_confirm_cancel_button": "mài 登出", + "password": "密碼", + "placeholder": "例:lain", + "register": "註冊", + "username": "用者 ê 名", + "hint": "登入,參與討論", + "authentication_code": "認證碼", + "enter_recovery_code": "輸入恢復碼", + "enter_two_factor_code": "輸入兩階段認證碼", + "recovery_code": "恢復碼", + "heading": { + "totp": "兩階段認證", + "recovery": "兩階段恢復" + } + }, + "media_modal": { + "previous": "頂一 ê", + "next": "後一个", + "counter": "{current} / {total}", + "hide": "關掉媒體瀏覽" + }, + "nav": { + "about": "關係本站", + "administration": "管理", + "back": "轉去", + "friend_requests": "跟綴請求", + "mentions": "The̍h起", + "interactions": "互動", + "dms": "私人 phue", + "public_tl": "公共時間線", + "timeline": "時間線", + "home_timeline": "Tshù ê 時間線", + "twkn": "知影 ê 網路", + "bookmarks": "冊籤", + "user_search": "Tshuē 用者", + "search_close": "關掉 tshiau-tshuē liâu", + "who_to_follow": "Siáng ē當綴", + "preferences": "個人 ê 設定", + "timelines": "時間線", + "chats": "開講", + "lists": "列單", + "edit_nav_mobile": "自訂導覽條", + "edit_pinned": "編輯釘起來 ê 項目", + "edit_finish": "編輯 suah", + "mobile_sidebar": "切換行動版 ê 邊 á liâu", + "mobile_notifications": "拍開通知(有無讀ê)", + "mobile_notifications_close": "關掉通知", + "announcements": "公告", + "search": "Tshuē" + }, + "notifications": { + "broken_favorite": "狀態毋知影,leh tshiau-tshuē……", + "error": "佇取得通知 ê 時起錯誤:{0}", + "favorited_you": "kah 意 lí ê 狀態", + "followed_you": "綴 lí", + "follow_request": "想 beh 綴 lí", + "load_older": "載入 khah 早 ê 通知", + "notifications": "通知", + "read": "有讀ah!", + "repeated_you": "轉送 lí ê 狀態", + "no_more_notifications": "無別 ê 通知", + "migrated_to": "移民到", + "reacted_with": "顯出{0} ê 反應", + "submitted_report": "送出檢舉", + "poll_ended": "投票結束" + }, + "polls": { + "add_poll": "開投票", + "add_option": "加選項", + "option": "選項", + "votes": "票", + "people_voted_count": "{count} 位有投", + "votes_count": "{count} 票", + "vote": "投票", + "type": "投票 ê 形式", + "single_choice": "孤選", + "multiple_choices": "Tsē 選", + "expiry": "投票期限", + "expires_in": "投票 tī {0} 以後結束", + "expired": "投票佇 {0} 以前結束", + "not_enough_options": "投票 ê 選項傷少" + }, + "emoji": { + "stickers": "貼圖", + "emoji": "繪文字", + "keep_open": "Hōo 揀選仔開 leh", + "search_emoji": "Tshuē 繪文字", + "add_emoji": "插繪文字", + "custom": "定製 ê 繪文字", + "unpacked": "拍開 ê 繪文字", + "unicode": "Unicode 繪文字", + "unicode_groups": { + "activities": "活動", + "animals-and-nature": "動物 kap 自然", + "flags": "旗 á", + "food-and-drink": "食物 kap 飲料", + "objects": "物體", + "people-and-body": "Lâng kap 身軀", + "smileys-and-emotion": "笑面 kap 情緒", + "symbols": "符號", + "travel-and-places": "旅遊 kap 所在" + }, + "load_all_hint": "載入頭前 {saneAmount} ê 繪文字,規个攏載入效能可能 ē khah 食力。", + "load_all": "Kā {emojiAmount} ê 繪文字攏載入", + "regional_indicator": "地區指引 {letter}" + }, + "errors": { + "storage_unavailable": "Pleroma buē-tàng the̍h 著瀏覽器儲存 ê。Lí ê 登入狀態抑是局部設定 buē 儲存,mā 凡勢 tú 著意料外 ê 問題。拍開 cookie 看覓。" + }, + "interactions": { + "favs_repeats": "轉送 kap kah 意", + "follows": "最近綴 lí ê", + "emoji_reactions": "繪文字 ê 回應", + "reports": "檢舉", + "moves": "用者 ê 移民", + "load_older": "載入 koh khah 早 ê 互動" + }, + "post_status": { + "edit_status": "編輯狀態", + "new_status": "PO 新 ê 狀態", + "account_not_locked_warning": "Lín 口座毋是 {0} ê。見 nā 有 lâng 綴--lí,ē-tàng 看著 lí ê 限定跟綴者 ê PO 文。.", + "account_not_locked_warning_link": "鎖起來 ê 口座", + "attachments_sensitive": "Kā 附件標做敏感內容", + "media_description": "媒體說明", + "content_type": { + "text/plain": "純 ê 文字", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" + }, + "content_type_selection": "貼 ê 形式", + "content_warning": "主旨(毋是必要)", + "default": "Tú 正 kàu 高雄 ah。", + "direct_warning_to_all": "Tsit ê PO 文通 hōo 逐 ê 提起 ê 用者看見。", + "direct_warning_to_first_only": "Tsit ê PO 文,kan-ta 短信 tú 開始提起 ê 用者,tsiah 通看見。", + "edit_remote_warning": "別 ê 站臺可能無支援編輯,無法度收著 PO 文上新 ê 版本。", + "edit_unsupported_warning": "Pleroma 無支持編輯 the̍h 起 hām 投票。", + "posting": "PO 文", + "preview": "Sing 看覓", + "preview_empty": "空 ê", + "empty_status_error": "無法度 PO 無檔案 koh 空 ê 狀態", + "media_description_error": "更新媒體失敗,請 koh 試一 kái", + "scope_notice": { + "public": "Tsit ê PO 文通予逐 ê 儂看著", + "private": "Tsit ê PO 文 kan-ta 予綴 lí ê 看著", + "unlisted": "Tsit ê PO 文 buē 公開 tī 公共時間線 kap 知影 ê 網路" + }, + "scope_notice_dismiss": "關掉 tsit ê 通知", + "scope": { + "direct": "私人 phue - PO 文干焦予提起 ê 用者看著", + "private": "限定綴 ê 儂 - PO 文干焦予綴 lí ê 儂看著", + "public": "公開 - PO kàu 公開時間線", + "unlisted": "Mài 列出來 - Mài PO tī 公開時間線" + }, + "post": "PO 上去" + }, + "registration": { + "bio_optional": "介紹(毋是必要)", + "email_optional": "Email(毋是必要)", + "fullname": "顯示 ê 名", + "password_confirm": "確認密碼", + "registration": "註冊", + "token": "邀請碼", + "captcha": "驗證碼", + "new_captcha": "Ji̍h 圖片,the̍h 新 ê 驗證碼", + "fullname_placeholder": "e.g. 岩倉 Lain", + "bio_placeholder": "e.g.\nLí 好,我是 Lain。\n我是日本動畫 ê 角色,tuà tī 日本 ê 郊區。Lí 凡勢 bat tī Wired 知影我。", + "reason": "註冊 ê 理由", + "reason_placeholder": "本站靠人工審核註冊。\n介紹管理者 lí beh tī tsia 註冊 ê 理由。", + "register": "註冊", + "validations": { + "username_required": "著愛添", + "fullname_required": "著愛添", + "email_required": "著愛添", + "password_required": "著愛添", + "password_confirmation_required": "著愛添", + "password_confirmation_match": "密碼著相 kâng", + "birthday_required": "著愛添", + "birthday_min_age": "Buē-tàng tī {date} 以後" + }, + "email_language": "Lí想 beh 服侍器用 siánn 物語言寄批 hōo lí?", + "birthday": "生日:", + "birthday_optional": "生日(毋是必要):", + "email": "電子 phue 箱", + "username_placeholder": "比如:lain" + }, + "remote_user_resolver": { + "remote_user_resolver": "別站用者 ê 解析器", + "error": "Tshuē無。", + "searching_for": "Tshuē:" + }, + "report": { + "reporter": "檢舉人:", + "reported_user": "Beh 檢舉 ê 用者:", + "reported_statuses": "Beh 檢舉 ê 狀態:", + "state_open": "開 ê", + "state_closed": "關 ê", + "state_resolved": "解決了 ê", + "notes": "註:", + "state": "狀態:" + }, + "selectable_list": { + "select_all": "攏總揀" + }, + "settings": { + "add_language": "加一 ê 備用 ê 語言", + "remove_language": "Ni 掉", + "primary_language": "主要語言:", + "fallback_language": "備用語言 {index}:", + "app_name": "App ê 名", + "expert_mode": "進階模式", + "save": "保存改變", + "security": "安全", + "setting_changed": "設定 kap 預先 ê 有 tsing 差", + "style": { + "common": { + "color": "色彩", + "opacity": "無透明度", + "contrast": { + "hint": "色彩ê對比率:{ratio}。{level}、 {context}" + } + }, + "switcher": { + "keep_shadows": "保持陰影", + "keep_color": "保持色彩", + "keep_opacity": "保持無透明度", + "keep_roundness": "保留邊á角ê khà-buh", + "keep_fonts": "保持字型", + "reset": "重頭設定", + "clear_all": "攏清掉", + "clear_opacity": "清掉無透明度", + "load_theme": "載入主題", + "keep_as_is": "Mài振動", + "use_snapshot": "舊ê版本", + "use_source": "新ê版本", + "help": { + "upgraded_from_v2": "PleromaFE升級ah,主題huân-sè kap lí知影ê無kâng。", + "v2_imported": "Lí輸入ê檔案是舊版本ê前端用ê。Guán盡量予版本相通,毋過可能有所在buē-tàng。", + "older_version_imported": "Lí輸入ê檔案是予舊ê前端用ê。", + "future_version_imported": "Lí輸入ê檔案是新ê前端所用ê。" + } + } + }, + "upload": { + "error": { + "base": "上傳 ê 時失敗。", + "message": "傳 buē 起去:{0}", + "file_too_big": "檔案 sài-suh 傷大 [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Koh 試一 kái。" + } + }, + "search": { + "people": "用戶", + "hashtags": "主題標籤", + "person_talking": "{count} ê leh 論", + "people_talking": "{count} ê leh 論", + "no_results": "無半 ê 結果", + "no_more_results": "無其他 ê 結果", + "load_more": "載入 koh 較 tsē 結果" + }, + "password_reset": { + "forgot_password": "Buē 記得密碼?", + "password_reset": "重頭設密碼", + "instruction": "拍 lí ê email 地址 iah 是用者 ê 名。Guán 會送 lí 連結,重頭設定密碼。", + "placeholder": "Lí ê email 地址 iah 是用者 ê 名。", + "check_email": "檢查電子 phue 箱,看有重頭設密碼 ê 連結無。", + "return_home": "轉來頭頁", + "too_many_requests": "Lí kā 請求 ê khòo-tah 用了 ah。等一時仔,閣試一 pái。", + "password_reset_disabled": "密碼重頭設定無開放。請聯絡本站 ê 行政員。", + "password_reset_required": "Beh 登入,著重頭設 lí ê 密碼。", + "password_reset_required_but_mailer_is_disabled": "Lí 需要重頭設密碼,毋 koh tsia 無開放密碼 koh 再設定。請聯絡本站 ê 行政員。" + }, + "chats": { + "message_user": "傳私人 phue:{nickname}", + "delete": "Thâi 掉", + "chats": "開講", + "new": "發起開講", + "empty_message_error": "無法度 PO 空 ê phue", + "more": "Koh較濟……", + "delete_confirm": "Lí 敢真 ê beh thâi tsit 張 phue?", + "error_loading_chat": "載入開講 ê 時,出箠 ah。", + "error_sending_message": "送 phue ê 時,出箠 ah。", + "empty_chat_list_placeholder": "Lí 猶無佇 tsia 開講過,來開講 lah!" + }, + "lists": { + "lists": "列單", + "new": "新 ê 列單", + "title": "列單標題", + "search": "Tshuē 用者", + "create": "開新 ê", + "save": "保存改變", + "delete": "刣列單", + "following_only": "限定 lí 所關注 ê", + "manage_lists": "管理列單", + "manage_members": "管理列單成員", + "add_members": "Tshiau 閣較 tsē ê 用者", + "remove_from_list": "對列單刣掉", + "add_to_list": "加入去列單", + "is_in_list": "列單已經有 ah ", + "editing_list": "編輯列單 {listTitle}", + "creating_list": "開新 ê 列單", + "update_title": "保存標題", + "really_delete": "敢真正 beh 刣掉列單?", + "error": "操作列單 ê 時陣出重耽:{0}" + }, + "file_type": { + "audio": "音訊", + "video": "影片", + "image": "影像", + "file": "檔案" + }, + "display_date": { + "today": "今 á 日" + }, + "update": { + "big_update_title": "敬請體諒", + "big_update_content": "因為 guán 有一站 á 無發行新版本,所以這个版本會 kap lí 以早慣 sì ê 無仝。", + "update_bugs": "請佇 {pleromaGitlab} 報告任何問題 kap bug,因為 Pleroma 改變真 tsē。雖罔 guán 徹底 leh 試,mā 家 kī 用開發版,伊凡勢有一寡重耽。Guán 歡迎 lín 提供關係所拄著 ê 問題 ê 意見、建議,或者是改進 Pleroma kap Pleroma-FE ê 法度。", + "update_changelog": "Nā beh 知影改變 ê 詳細,請看:{theFullChangelog}.", + "update_changelog_here": "Kui ê 改變日誌", + "art_by": "美編:{linkToArtist}" + }, + "unicode_domain_indicator": { + "tooltip": "這 ê 域名包含毋是 ascii ê 字元。" + }, + "setting_server_side": "Tsit-ê設定縛佇lí ê個人資料,mā 影響逐ê連線階段kap用者端", + "post_look_feel": "PO 文ê外貌kap感受", + "mention_links": "提起 ê 連結", + "mfa": { + "otp": "OTP", + "setup_otp": "設 OTP", + "wait_pre_setup_otp": "kā OTP 預設", + "title": "兩階段認證", + "generate_new_recovery_codes": "產生新ê恢復碼", + "warning_of_generate_new_codes": "產生新 ê 恢復碼ê時,舊 ê tio̍h 變無效。", + "recovery_codes": "恢復碼。", + "waiting_a_recovery_codes": "當leh收備份碼……", + "authentication_methods": "認證方法", + "scan": { + "title": "掃一 ē", + "secret_code": "鎖匙", + "desc": "The̍h lí个兩階段app,掃 tsit ê QR code,抑是拍文字鎖匙:" + }, + "verify": { + "desc": "Nā beh開兩階段認證,請拍兩階段認證app內底ê碼:" + }, + "confirm_and_enable": "確定,拍開 OTP", + "recovery_codes_warning": "著 kā tsiah ê 號碼抄落來,抑是儲存佇安全ê所在,因為號碼 buē koh 再出現。若是 lí 袂當用 lí 个兩階段認證app,而且恢復碼拍 ka-la̍uh,lí就永永buē當登入lí个口座。" + }, + "lists_navigation": "佇導覽中顯示列單", + "allow_following_move": "若是綴ê口座徙位ê時,允准自動綴新ê", + "attachmentRadius": "附件", + "avatar": "標頭", + "avatarAltRadius": "標頭(通知)", + "avatarRadius": "標頭", + "background": "背景", + "bio": "紹介", + "block_export": "輸出封鎖名單", + "block_export_button": "封鎖名單輸出kàu csv檔", + "block_import_error": "佇輸入封鎖名單ê時出tshê", + "block_import": "輸入封鎖名單", + "mute_export": "輸出消音名單", + "mute_export_button": "輸出消音名單kàu csv檔", + "mute_import": "輸入消音名單", + "blocks_imported": "成功輸入封鎖名單!較停仔tsiah ē處理suah。", + "mutes_imported": "成功輸入消音名單!較停仔tsiah ē處理suah。", + "import_mutes_from_a_csv_file": "輸入封鎖名單ê csv檔", + "account_backup": "備份口座", + "mutes_and_blocks": "消音kap封鎖", + "delete_account": "Thâi口座", + "delete_account_error": "佇刣掉lí ê 口座ê時出問題。若是問題一直佇leh,請聯絡 lín 站臺 ê 行政員。", + "account_alias": "口座 ê 別名", + "account_alias_table_head": "別名", + "list_aliases_error": "佇the̍h別名ê時出tshê:{error}", + "hide_list_aliases_error_action": "關掉", + "remove_alias": "Thâi 掉tsit ê別名", + "new_alias_target": "加新ê別名(比如: {example}))", + "added_alias": "別名加入去ah。", + "add_alias_error": "佇加別名ê時出tshê:{error}", + "move_account": "徙口座", + "move_account_target": "目標口座(比如:{example})", + "moved_account": "口座徙過去ah。", + "move_account_error": "佇徙口座ê時出tshê:{error}", + "attachments": "附件", + "email_language": "服侍器送ê email 所用 ê 語言", + "enter_current_password_to_confirm": "輸入lí tsit-má ê 密碼,確認lí ê身份", + "mute_import_error": "佇輸入消音名單ê時出tshê", + "delete_account_description": "Ē 永永刣掉lí个資料,hōo lí 个口座bē當用。", + "delete_account_instructions": "佇佇下跤拍lí个密碼,確認 kā 口座 thâi掉。", + "move_account_notes": "若是欲徙tsit ê口座,著去lí ê目標口座hia,加一ê指tsia ê別名。", + "account_backup_table_head": "備份", + "download_backup": "下載", + "backup_not_ready": "備份猶 buē tshuân 予好勢。", + "backup_running": "備份leh處理,其中 {number} 筆記錄處理 suah--ah。", + "backup_failed": "備份失敗。", + "remove_backup": "Thâi 掉", + "list_backups_error": "佇 the̍h 備份列單ê時出tshê: {error}", + "add_backup": "開新ê備份", + "added_backup": "新ê備份開好 ah。", + "add_backup_error": "佇開新ê備份ê時出tshê:{error}", + "blocks_tab": "封鎖", + "bot": "Tse 是機器 lâng ê 口座", + "btnRadius": "鈕仔", + "cBlue": "藍色(回應,跟綴)", + "cGreen": "綠色(轉送)", + "cOrange": "柑仔色(kah 意)", + "cRed": "紅色(取消)", + "change_email": "換電子 phue 箱", + "changed_email": "電子 phue 箱變換成功!", + "change_password": "改密碼", + "change_password_error": "佇改密碼ê時出問題。", + "changed_password": "改密碼成功!", + "chatMessageRadius": "開講ê訊息", + "composing": "編寫ê設定", + "confirm_new_password": "確認新ê密碼", + "current_password": "Tann ê 密碼", + "confirm_dialogs": "問確認佇", + "confirm_dialogs_repeat": "轉送狀態", + "confirm_dialogs_unfollow": "無愛綴用者", + "confirm_dialogs_block": "封鎖用者", + "confirm_dialogs_mute": "kā用者消音", + "confirm_dialogs_delete": "thâi掉狀態", + "confirm_dialogs_logout": "登出", + "confirm_dialogs_approve_follow": "允准跟綴", + "confirm_dialogs_deny_follow": "無允准跟綴", + "confirm_dialogs_remove_follower": "徙走綴 lí ê", + "data_import_export_tab": "資料輸入/出", + "default_vis": "預設ê公開範圍", + "discoverable": "允准用tshiau-tshuē kap 其他ê服務tshuē著 tsit ê口座", + "domain_mutes": "域名", + "avatar_size_instruction": "建議ê標頭影像sài-suh 是150x150畫素。", + "pad_emoji": "Tuì 揀選器揀繪文字以後,佇繪文字雙 pîng 邊加空白", + "emoji_reactions_on_timeline": "佇時間線頂,顯示繪文字ê反應", + "emoji_reactions_scale": "反應ê規模係數", + "export_theme": "保存主題", + "filtering": "過濾", + "wordfilter": "詞語過濾器", + "word_filter_and_more": "詞語過濾器 kap 其他……", + "follow_export": "輸出 lí 所綴ê", + "follow_export_button": "輸出lí所綴ê kàu csv 檔", + "follow_import": "輸入lí所綴ê", + "follow_import_error": "佇輸入跟綴 ê 資料 ê 時出tshê", + "accent": "強調", + "foreground": "前景", + "general": "一般", + "hide_attachments_in_convo": "佇對話ê時,khàm附件", + "hide_attachments_in_tl": "Khàm掉時間線內ê附件", + "hide_media_previews": "Khàm掉媒體ê預展", + "hide_muted_posts": "Khàm掉消音ê用者ê PO文", + "hide_bot_indication": "Khàm 掉PO文內底ê機器lâng ê指示", + "hide_all_muted_posts": "Khàm掉消音êPO文", + "max_thumbnails": "PO文ê縮小圖ê khòo-tah(無寫=無限制)", + "hide_isp": "Khàm 站臺特有ê面 pang", + "right_sidebar": "Kā 邊á liâu徙kah正手pîng", + "navbar_column_stretch": "伸導覽liâu,kah 欄平闊", + "always_show_post_button": "一直顯示「新ê PO文」ê鈕仔", + "hide_wallpaper": "Khàm站臺ê壁紙", + "use_one_click_nsfw": "Tshi̍h 一ê就會當拍開敏感內容", + "hide_post_stats": "Khàm PO文ê統計數據(比如:kah 意ê額數)", + "hide_filtered_statuses": "Khàm 逐ê過濾掉êPO文", + "hide_wordfiltered_statuses": "Khàm詞語過濾掉ê狀態", + "hide_muted_threads": "Khàm消音ê討論線", + "import_blocks_from_a_csv_file": "Tuì csv 檔輸入封鎖名單", + "import_followers_from_a_csv_file": "Uì csv 檔輸入跟綴ê資料", + "import_theme": "載入主題", + "inputRadius": "輸入ê格仔", + "checkboxRadius": "選擇框仔", + "instance_default": "(預設:{value})", + "instance_default_simple": "(預設)", + "interface": "界面", + "column_sizes_sidebar": "邊 á liâu", + "auto_update": "自動顯示新ê PO文", + "user_mutes": "用者", + "useStreamingApi": "連鞭收著PO文kap通知", + "use_websockets": "用websockets(實ê時間ê更新)", + "text": "文字", + "theme": "主題", + "theme_help": "用16進位ê碼(#rrggbb)來訂做家己ê色彩主題。", + "change_email_error": "佇換電子phue箱ê時出問題。", + "collapse_subject": "Kā 有主旨ê PO 文 khàm 起來", + "autocomplete_select_first": "若是有自動完成ê結果,自動揀頭一ê侯選ê", + "filtering_explanation": "見若有下跤ê詞語ê狀態,會hőng消音。一tsuā寫一ê", + "follows_imported": "Lí所綴ê輸入去ah!較停仔tsiah ē處理suah。", + "mute_bot_posts": "Kā 機器lâng ê PO文消音", + "hide_shoutbox": "Khàm 站臺ê留話pang", + "account_backup_description": "Tse 予 lí ē當 kā lín 口座 ê 資訊 kap PO 文載落來,毋過 in 猶無法度輸入kàu Pleroma口座 ê 內底。", + "theme_help_v2_1": "拍開選擇框á就 ē 當改掉一寡組件ê色彩kap無透明度。Ji̍h「清掉所有ê」,ē 恢復原來ê款。", + "preload_images": "Kā 圖片先載入", + "hide_user_stats": "Khàm 掉用者ê統計數據(比如:綴ê lâng額)", + "interfaceLanguage": "界面ê語言", + "invalid_theme_imported": "Lí 所揀ê主題檔案,Pleroma 無支援,所以主題無改。", + "limited_availability": "你ê瀏覽器內底buē當用", + "links": "連結", + "lock_account_description": "Kan-ta lí 同意,別儂tsiah通綴lí", + "loop_video": "循環播出ê影片", + "loop_video_silent_only": "Kan-ta無聲ê影片tsiah通循環播出(比如:Mastodon ê \"gif\")", + "mutes_tab": "消音", + "play_videos_in_modal": "佇跳出來ê框仔播出影片", + "url": "URL", + "preview": "預展", + "file_export_import": { + "backup_restore": "備份設定", + "backup_settings": "Kā 設定備份kàu檔案", + "backup_settings_theme": "Kā設定kap主題備份kàu檔案", + "restore_settings": "對檔案回復設定", + "errors": { + "file_too_old": "無接受ê主要版本:{fileMajor},檔案ê版本siūnn舊,buē當處理({feMajor} 版以後ê tsiah支援)", + "file_slightly_new": "檔案ê次版本無仝,一寡設定可能buē當載入去", + "invalid_file": "選擇ê檔案毋是Pleroma支援ê設定備份,設定無振動。", + "file_too_new": "無接受ê主要版本:{fileMajor},本 PleromaFE(設定版本 {feMajor})siūnn舊,buē當處理" + } + }, + "profile_fields": { + "label": "個人資料ê meta資料", + "add_field": "加格仔", + "name": "標簽", + "value": "內容" + }, + "birthday": { + "label": "生日", + "show_birthday": "顯示我ê生日" + }, + "account_privacy": "隱私", + "use_contain_fit": "Mài裁附件ê縮小圖", + "name_bio": "名kah介紹", + "new_password": "新ê密碼", + "posts": "PO文", + "name": "名", + "new_email": "新ê電子phue箱", + "notification_visibility_likes": "收藏", + "hide_favorites_description": "Mài 顯示阮收藏ê列單(別儂uân-á ē收著通知)", + "user_profiles": "用者ê資料", + "notification_visibility": "Beh顯示啥款ê通知", + "notification_visibility_follows": "綴ê儂", + "notification_visibility_mentions": "提起", + "notification_visibility_repeats": "轉送", + "notification_visibility_moves": "用者suá位", + "notification_visibility_emoji_reactions": "回應", + "notification_visibility_polls": "Lí參與ê選舉辦suah佇", + "no_rich_text_description": "Po文mài用RTF格式", + "no_blocks": "無封鎖", + "no_mutes": "無消音", + "hide_follows_description": "Mài顯示我綴ê儂", + "hide_followers_description": "Mài顯示綴我ê儂", + "hide_follows_count_description": "Mài顯示我跟綴ê儂額", + "hide_followers_count_description": "Mài顯示綴我ê儂額", + "show_moderator_badge": "佇我ê個人資料顯示「管理員」證章", + "nsfw_clickthrough": "Khàm掉敏感ê媒體內容", + "oauth_tokens": "OAuth token", + "refresh_token": "對頭the̍h token", + "valid_until": "到期佇", + "revoke_token": "撤回", + "panelRadius": "面pang", + "presets": "代先ê設定", + "profile_background": "個人資料ê背景", + "profile_banner": "個人資料ê條á", + "profile_tab": "個人資料", + "radii_help": "設定界面邊á ê khà-buh (curve) ê 半徑(單位:畫素)", + "replies_in_timeline": "佇時間線內底ê回應", + "reply_visibility_all": "顯示所有ê回應", + "reply_visibility_following": "Kan-ta顯示送予我抑是我綴ê儂ê回應", + "reply_visibility_self": "Kan-ta顯示送予我ê回應", + "reply_visibility_following_short": "顯示予我所綴ê儂ê回應", + "reply_visibility_self_short": "Kan-ta顯示予我ka-kī ê回應", + "autohide_floating_post_button": "自動khàm掉「新êPO文」ê鈕仔(行動版)", + "saving_err": "佇保存設定ê時出tshê", + "saving_ok": "設定保存好ah", + "search_user_to_block": "Tshuē lí beh封鎖ê", + "search_user_to_mute": "Tshuē lí beh 消音ê", + "security_tab": "安全", + "scope_copy": "回應ê時ē khóo-pih ê範圍(私人phue 定著ē hőng khóo-pih)", + "minimal_scopes_mode": "Kā PO文ê公開範圍ê選項,kiu kah上細", + "set_new_avatar": "設定新ê標頭", + "set_new_profile_background": "設定新ê個人資料ê背景", + "set_new_profile_banner": "設定新ê個人資料ê條á", + "reset_avatar": "Tuì頭設定標頭", + "reset_profile_background": "Tuì頭設個人資料ê背景", + "reset_profile_banner": "Tuì頭設個人資料ê條á", + "reset_avatar_confirm": "Lí敢確實beh tuì頭設定標頭?", + "reset_banner_confirm": "Lí敢確實beh tuì頭設定條á?", + "reset_background_confirm": "Lí敢確實beh tuì頭設定背景?", + "settings": "設定", + "subject_input_always_show": "一直顯示主旨ê格á", + "subject_line_behavior": "回應ê時,khóo-pih主旨", + "subject_line_email": "電子phue風格:「re: 主旨」", + "subject_line_mastodon": "Mastodon風格:主旨無變", + "subject_line_noop": "Mài khóo-pih", + "conversation_display": "顯示對話ê風格", + "conversation_display_tree": "樹á ê形", + "disable_sticky_headers": "Mài 予欄位ê頭牢佇螢幕頂懸", + "show_scrollbars": "展示邊á liâu ê giú-á", + "third_column_mode": "空間夠額ê時,展示第三ê欄位", + "third_column_mode_none": "不管時mài顯示第三ê欄位", + "third_column_mode_notifications": "通知ê欄位", + "third_column_mode_postform": "主要êPO文表kah導覽", + "show_admin_badge": "佇我ê個人資料顯示「行政員」證章", + "pause_on_unfocused": "若是 Pleroma ê分頁無點開,tiō 暫停更新", + "conversation_display_tree_quick": "樹á形ê展示", + "columns": "欄位", + "column_sizes": "欄位sài-suh", + "column_sizes_content": "內容", + "column_sizes_notifs": "通知", + "tree_advanced": "允准用較活動ê方式導覽佇樹á形ê展示", + "tree_fade_ancestors": "用較淺ê色水顯示目前狀態ê前文", + "conversation_display_linear": "線á形ê風格", + "conversation_display_linear_quick": "線á形ê展示", + "conversation_other_replies_button": "顯示「其他ê回應」鈕仔", + "conversation_other_replies_button_below": "佇狀態下kha", + "conversation_other_replies_button_inside": "佇狀態內底", + "max_depth_in_thread": "預設ê討論線顯示層數ê上限", + "post_status_content_type": "Po文狀態ê內容類型", + "sensitive_by_default": "預設內,kā po文標做敏感內容", + "stop_gifs": "Kā滑鼠ê指標khǹg佇面頂ê時,動畫圖片tsiah振動", + "streaming": "Giú kàu頂懸ê時,自動展示新ê po文", + "theme_help_v2_2": "一寡圖片下kha ê標á,是背景/圖片ê對比指示,滑鼠指標khǹg佇面頂ê時,ē當看詳細。請記lit,若是用透明ê,對比指示顯示上bái ê情況。", + "tooltipRadius": "提醒", + "type_domains_to_mute": "揣beh愛消音ê域名", + "upload_a_photo": "Kā相片傳上去", + "user_settings": "用者ê設定", + "values": { + "false": "無", + "true": "是" + }, + "mention_link_display_short": "一直顯示短ê名(比如: {'@'}foo)", + "mention_link_display_full": "一直用全名顯示(比如:{'@'}foo{'@'}example.org)", + "virtual_scrolling": "Kā時間線ê算畫最佳化", + "mention_link_display_full_for_remote": "Kan-ta kā其他域名ê用者,用全名顯示(比如:{'@'}foo{'@'}example.org)", + "token": "Token", + "use_at_icon": "用標á顯示 {'@'} 符號,mài用文字", + "mention_link_display": "顯示提起ê連結", + "mention_link_use_tooltip": "佇tshi̍h提起ê連結ê時,顯示用者ê卡片", + "mention_link_show_avatar": "佇連結邊á顯示用者ê標頭", + "mention_link_show_avatar_quick": "佇提起ê隔壁,顯示用者ê標頭", + "mention_link_fade_domain": "用較淺ê色水顯示域名(比如:{'@'}foo{'@'}example.org ê {'@'}example.org)", + "mention_link_bolden_you": "佇lí hőng提起ê時,強調對lí ê提起文字", + "user_popover_avatar_action": "Tshi̍h跳出來ê標頭ê動作", + "user_popover_avatar_action_zoom": "放大/縮小標頭", + "user_popover_avatar_action_close": "關掉跳出來ê框á", + "user_popover_avatar_action_open": "拍開個人資料", + "user_popover_avatar_overlay": "佇用者ê跳出來ê框仔面頂,顯示用者ê標頭", + "fun": "趣味ê", + "greentext": "Meme ê箭頭", + "show_yous": "顯示(Lí)", + "notifications": "通知", + "notification_setting_filters": "過濾ê", + "notification_setting_block_from_strangers": "關lí bô綴ê lâng 送ê通知", + "notification_setting_privacy": "隱私", + "notification_setting_hide_notification_contents": "Kā sak通知ê lâng kap伊ê內容khàm掉", + "notification_mutes": "若tsún無愛收tuì指定用者來ê通知,著用消音。", + "notification_blocks": "封鎖用者ē停止所有i hia來ê通知,mā取消訂伊。", + "enable_web_push_notifications": "拍開網頁sak通知ê功能", + "more_settings": "Koh較tsē ê設定" + }, + "status": { + "favorites": "收藏" + }, + "user_card": { + "favorites": "收藏" + }, + "tool_tip": { + "favorite": "收藏" + } +} diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 8ea4c1e0..69266caa 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -699,7 +699,7 @@ "hide_muted_threads": "不显示已隐藏的同主题帖子", "notification_visibility_polls": "你所投的投票的结束于", "tree_advanced": "允许在树状视图中进行更灵活的导航", - "tree_fade_ancestors": "以模糊的文字显示当前状态的原型", + "tree_fade_ancestors": "以模糊的文字显示当前状态的上级", "conversation_display_linear": "线性样式", "mention_link_fade_domain": "淡化域名(例如:{'@'}example.org 中的 {'@'}foo{'@'}example.org)", "mention_link_bolden_you": "当你被提及时突出显示提及你", @@ -738,7 +738,16 @@ "mention_link_show_avatar": "在链接旁边显示用户头像", "mention_link_show_avatar_quick": "在提及内容旁边显示用户头像", "user_popover_avatar_action_open": "打开个人资料", - "autocomplete_select_first": "当有自动完成的结果时,自动选择第一个候选项" + "autocomplete_select_first": "当有自动完成的结果时,自动选择第一个候选项", + "url": "URL", + "preview": "预览", + "commit_value": "保存", + "commit_value_tooltip": "当前值未保存,请按此按钮以提交你的修改", + "reset_value": "重置", + "reset_value_tooltip": "重置草稿", + "hard_reset_value": "硬重置", + "hard_reset_value_tooltip": "从存储中移除设置,强制使用默认值", + "emoji_reactions_scale": "表情回应比例系数" }, "time": { "day": "{0} 天", @@ -869,7 +878,9 @@ "delete_confirm_accept_button": "删除", "delete_confirm_cancel_button": "保留", "show_attachment_in_modal": "在媒体模式中显示", - "status_history": "状态历史" + "status_history": "状态历史", + "delete_error": "删除状态时出错:{0}", + "reaction_count_label": "{num} 人作出了表情回应" }, "user_card": { "approve": "核准", @@ -1063,7 +1074,7 @@ "smileys-and-emotion": "表情与情感" }, "regional_indicator": "地区指示符 {letter}", - "unpacked": "拆分的表情符号" + "unpacked": "未分组的表情符号" }, "about": { "mrf": { @@ -1195,5 +1206,91 @@ "lists": "列表", "new": "新的列表", "title": "列表标题" + }, + "admin_dash": { + "window_title": "管理员", + "old_ui_link": "旧的管理界面在此处", + "reset_all": "重置全部", + "commit_all": "保存全部", + "tabs": { + "nodb": "无数据库配置", + "instance": "实例", + "limits": "限制", + "frontends": "前端" + }, + "nodb": { + "heading": "数据库配置已禁用", + "documentation": "文档", + "text2": "大多数配置选项将不可用。", + "text": "你需要修改后端配置文件,以便将 {property} 设置为 {value},更多内容请参见 {documentation}。" + }, + "captcha": { + "native": "本地", + "kocaptcha": "KoCaptcha" + }, + "instance": { + "instance": "实例信息", + "registrations": "用户注册", + "captcha_header": "验证码", + "kocaptcha": "KoCaptcha 设置", + "access": "实例访问", + "restrict": { + "header": "限制匿名访客的访问", + "timelines": "时间线访问", + "profiles": "用户个人资料访问", + "activities": "状态/活动访问", + "description": "允许/不允许访问特定 API 的详细设置。默认情况下(不确定状态),如果实例不是公开的,它将拒绝访问;勾选复选框意味着即使实例是公开的,也拒绝访问;不勾选意味着即使实例是私有的,也允许访问。请注意,如果某些设置被设定,可能会发生意想不到的行为,例如,如果个人资料访问被禁用,显示的帖文将不包含个人资料信息。" + } + }, + "limits": { + "arbitrary_limits": "任意限制", + "posts": "帖文限制", + "uploads": "附件限制", + "users": "用户个人资料限制", + "profile_fields": "个人资料字段限制", + "user_uploads": "个人资料媒体限制" + }, + "frontend": { + "repository": "存储库链接", + "versions": "可用版本", + "build_url": "构建产物 URL", + "reinstall": "重新安装", + "is_default": "(默认)", + "is_default_custom": "(默认,版本:{version})", + "install": "安装", + "install_version": "安装版本 {version}", + "more_install_options": "更多安装选项", + "more_default_options": "更多默认设置选项", + "set_default": "设为默认", + "set_default_version": "将版本 {version} 设为默认", + "wip_notice": "请注意,此部分是一个WIP,缺乏某些功能,因为前端管理的后台实现并不完整。", + "default_frontend": "默认前端", + "default_frontend_tip": "默认的前端将显示给所有用户。目前还没有办法让用户选择个人的前端。如果你不使用 PleromaFE,你很可能不得不使用旧的和有问题的 AdminFE 来进行实例配置,直到我们替换它。", + "default_frontend_tip2": "WIP: 由于 Pleroma 后端没有正确列出所有已安装的前端,你必须手动输入名称和引用。下面的列表提供了填写这些值的快捷方式。", + "available_frontends": "可供安装" + }, + "temp_overrides": { + ":pleroma": { + ":instance": { + ":public": { + "label": "实例是公开的", + "description": "禁用此功能将使所有的 API 只能被已登录用户访问,这将使公共和联邦时间线无法被匿名访客访问。" + }, + ":limit_to_local_content": { + "label": "将搜索限于本地内容", + "description": "禁用未认证用户(默认)、所有用户或无人的全局网络搜索" + }, + ":description_limit": { + "label": "限制", + "description": "附件描述的字数限制" + }, + ":background_image": { + "label": "背景图片", + "description": "背景图片(主要使用于 PleromaFE)" + } + } + } + }, + "wip_notice": "此管理仪表板是实验性和 WIP 的,{adminFeLink}。" } } diff --git a/src/i18n/zh_Hant.json b/src/i18n/zh_Hant.json index 96c75215..2baed898 100644 --- a/src/i18n/zh_Hant.json +++ b/src/i18n/zh_Hant.json @@ -546,7 +546,7 @@ "backend_version": "後端版本", "frontend_version": "前端版本" }, - "virtual_scrolling": "優化時間線渲染", + "virtual_scrolling": "最佳化時間軸算繪", "import_mutes_from_a_csv_file": "從CSV文件導入靜音", "mutes_imported": "靜音導入了!處理它們將需要一段時間。", "mute_import": "靜音導入", @@ -576,7 +576,10 @@ }, "sensitive_by_default": "默認標記發文為敏感內容", "right_sidebar": "在右側顯示側邊欄", - "hide_shoutbox": "隱藏實例留言框" + "hide_shoutbox": "隱藏實例留言框", + "mention_link_display_short": "總是使用短名(如: {'@'}foo)", + "mention_link_display": "顯式提及連結", + "use_at_icon": "將{'@'}改用圖標顯示,不用文字" }, "chats": { "more": "更多", diff --git a/src/main.js b/src/main.js index 9ca7eb91..0b7c7674 100644 --- a/src/main.js +++ b/src/main.js @@ -10,8 +10,9 @@ import listsModule from './modules/lists.js' import usersModule from './modules/users.js' import apiModule from './modules/api.js' import configModule from './modules/config.js' -import serverSideConfigModule from './modules/serverSideConfig.js' +import profileConfigModule from './modules/profileConfig.js' import serverSideStorageModule from './modules/serverSideStorage.js' +import adminSettingsModule from './modules/adminSettings.js' import shoutModule from './modules/shout.js' import oauthModule from './modules/oauth.js' import authFlowModule from './modules/auth_flow.js' @@ -80,8 +81,9 @@ const persistedStateOptions = { lists: listsModule, api: apiModule, config: configModule, - serverSideConfig: serverSideConfigModule, + profileConfig: profileConfigModule, serverSideStorage: serverSideStorageModule, + adminSettings: adminSettingsModule, shout: shoutModule, oauth: oauthModule, authFlow: authFlowModule, diff --git a/src/modules/adminSettings.js b/src/modules/adminSettings.js new file mode 100644 index 00000000..cad9c0ca --- /dev/null +++ b/src/modules/adminSettings.js @@ -0,0 +1,230 @@ +import { set, get, cloneDeep, differenceWith, isEqual, flatten } from 'lodash' + +export const defaultState = { + frontends: [], + loaded: false, + needsReboot: null, + config: null, + modifiedPaths: null, + descriptions: null, + draft: null, + dbConfigEnabled: null +} + +export const newUserFlags = { + ...defaultState.flagStorage +} + +const adminSettingsStorage = { + state: { + ...cloneDeep(defaultState) + }, + mutations: { + setInstanceAdminNoDbConfig (state) { + state.loaded = false + state.dbConfigEnabled = false + }, + setAvailableFrontends (state, { frontends }) { + state.frontends = frontends.map(f => { + if (f.name === 'pleroma-fe') { + f.refs = ['master', 'develop'] + } else { + f.refs = [f.ref] + } + return f + }) + }, + updateAdminSettings (state, { config, modifiedPaths }) { + state.loaded = true + state.dbConfigEnabled = true + state.config = config + state.modifiedPaths = modifiedPaths + }, + updateAdminDescriptions (state, { descriptions }) { + state.descriptions = descriptions + }, + updateAdminDraft (state, { path, value }) { + const [group, key, subkey] = path + const parent = [group, key, subkey] + + set(state.draft, path, value) + + // force-updating grouped draft to trigger refresh of group settings + if (path.length > parent.length) { + set(state.draft, parent, cloneDeep(get(state.draft, parent))) + } + }, + resetAdminDraft (state) { + state.draft = cloneDeep(state.config) + } + }, + actions: { + loadFrontendsStuff ({ state, rootState, dispatch, commit }) { + rootState.api.backendInteractor.fetchAvailableFrontends() + .then(frontends => commit('setAvailableFrontends', { frontends })) + }, + loadAdminStuff ({ state, rootState, dispatch, commit }) { + rootState.api.backendInteractor.fetchInstanceDBConfig() + .then(backendDbConfig => { + if (backendDbConfig.error) { + if (backendDbConfig.error.status === 400) { + backendDbConfig.error.json().then(errorJson => { + if (/configurable_from_database/.test(errorJson.error)) { + commit('setInstanceAdminNoDbConfig') + } + }) + } + } else { + dispatch('setInstanceAdminSettings', { backendDbConfig }) + } + }) + if (state.descriptions === null) { + rootState.api.backendInteractor.fetchInstanceConfigDescriptions() + .then(backendDescriptions => dispatch('setInstanceAdminDescriptions', { backendDescriptions })) + } + }, + setInstanceAdminSettings ({ state, commit, dispatch }, { backendDbConfig }) { + const config = state.config || {} + const modifiedPaths = new Set() + backendDbConfig.configs.forEach(c => { + const path = [c.group, c.key] + if (c.db) { + // Path elements can contain dot, therefore we use ' -> ' as a separator instead + // Using strings for modified paths for easier searching + c.db.forEach(x => modifiedPaths.add([...path, x].join(' -> '))) + } + const convert = (value) => { + if (Array.isArray(value) && value.length > 0 && value[0].tuple) { + return value.reduce((acc, c) => { + return { ...acc, [c.tuple[0]]: convert(c.tuple[1]) } + }, {}) + } else { + return value + } + } + set(config, path, convert(c.value)) + }) + console.log(config[':pleroma']) + commit('updateAdminSettings', { config, modifiedPaths }) + commit('resetAdminDraft') + }, + setInstanceAdminDescriptions ({ state, commit, dispatch }, { backendDescriptions }) { + const convert = ({ children, description, label, key = '<ROOT>', group, suggestions }, path, acc) => { + const newPath = group ? [group, key] : [key] + const obj = { description, label, suggestions } + if (Array.isArray(children)) { + children.forEach(c => { + convert(c, newPath, obj) + }) + } + set(acc, newPath, obj) + } + + const descriptions = {} + backendDescriptions.forEach(d => convert(d, '', descriptions)) + console.log(descriptions[':pleroma']['Pleroma.Captcha']) + commit('updateAdminDescriptions', { descriptions }) + }, + + // This action takes draft state, diffs it with live config state and then pushes + // only differences between the two. Difference detection only work up to subkey (third) level. + pushAdminDraft ({ rootState, state, commit, dispatch }) { + // TODO cleanup paths in modifiedPaths + const convert = (value) => { + if (typeof value !== 'object') { + return value + } else if (Array.isArray(value)) { + return value.map(convert) + } else { + return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] })) + } + } + + // Getting all group-keys used in config + const allGroupKeys = flatten( + Object + .entries(state.config) + .map( + ([group, lv1data]) => Object + .keys(lv1data) + .map((key) => ({ group, key })) + ) + ) + + // Only using group-keys where there are changes detected + const changedGroupKeys = allGroupKeys.filter(({ group, key }) => { + return !isEqual(state.config[group][key], state.draft[group][key]) + }) + + // Here we take all changed group-keys and get all changed subkeys + const changed = changedGroupKeys.map(({ group, key }) => { + const config = state.config[group][key] + const draft = state.draft[group][key] + + // We convert group-key value into entries arrays + const eConfig = Object.entries(config) + const eDraft = Object.entries(draft) + + // Then those entries array we diff so only changed subkey entries remain + // We use the diffed array to reconstruct the object and then shove it into convert() + return ({ group, key, value: convert(Object.fromEntries(differenceWith(eDraft, eConfig, isEqual))) }) + }) + + rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: changed + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + }, + pushAdminSetting ({ rootState, state, commit, dispatch }, { path, value }) { + const [group, key, ...rest] = Array.isArray(path) ? path : path.split(/\./g) + const clone = {} // not actually cloning the entire thing to avoid excessive writes + set(clone, rest, value) + + // TODO cleanup paths in modifiedPaths + const convert = (value) => { + if (typeof value !== 'object') { + return value + } else if (Array.isArray(value)) { + return value.map(convert) + } else { + return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] })) + } + } + + rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: [{ + group, + key, + value: convert(clone) + }] + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + }, + resetAdminSetting ({ rootState, state, commit, dispatch }, { path }) { + const [group, key, subkey] = path.split(/\./g) + + state.modifiedPaths.delete(path) + + return rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: [{ + group, + key, + delete: true, + subkeys: [subkey] + }] + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + } + } +} + +export default adminSettingsStorage diff --git a/src/modules/config.js b/src/modules/config.js index 7597886e..56f8cba5 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,6 +1,7 @@ import Cookies from 'js-cookie' import { setPreset, applyTheme, applyConfig } from '../services/style_setter/style_setter.js' import messages from '../i18n/messages' +import { set } from 'lodash' import localeService from '../services/locale/locale.service.js' const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage' @@ -148,7 +149,7 @@ const config = { }, mutations: { setOption (state, { name, value }) { - state[name] = value + set(state, name, value) }, setHighlight (state, { user, color, type }) { const data = this.state.config.highlight[user] @@ -178,32 +179,52 @@ const config = { commit('setHighlight', { user, color, type }) }, setOption ({ commit, dispatch, state }, { name, value }) { - commit('setOption', { name, value }) - switch (name) { - case 'theme': - setPreset(value) - break - case 'sidebarColumnWidth': - case 'contentColumnWidth': - case 'notifsColumnWidth': - case 'emojiReactionsScale': - applyConfig(state) - break - case 'customTheme': - case 'customThemeSource': - applyTheme(value) - break - case 'interfaceLanguage': - messages.setLanguage(this.getters.i18n, value) - dispatch('loadUnicodeEmojiData', value) - Cookies.set( - BACKEND_LANGUAGE_COOKIE_NAME, - localeService.internalToBackendLocaleMulti(value) - ) - break - case 'thirdColumnMode': - dispatch('setLayoutWidth', undefined) - break + const exceptions = new Set([ + 'useStreamingApi' + ]) + + if (exceptions.has(name)) { + switch (name) { + case 'useStreamingApi': { + const action = value ? 'enableMastoSockets' : 'disableMastoSockets' + + dispatch(action).then(() => { + commit('setOption', { name: 'useStreamingApi', value }) + }).catch((e) => { + console.error('Failed starting MastoAPI Streaming socket', e) + dispatch('disableMastoSockets') + dispatch('setOption', { name: 'useStreamingApi', value: false }) + }) + } + } + } else { + commit('setOption', { name, value }) + switch (name) { + case 'theme': + setPreset(value) + break + case 'sidebarColumnWidth': + case 'contentColumnWidth': + case 'notifsColumnWidth': + case 'emojiReactionsScale': + applyConfig(state) + break + case 'customTheme': + case 'customThemeSource': + applyTheme(value) + break + case 'interfaceLanguage': + messages.setLanguage(this.getters.i18n, value) + dispatch('loadUnicodeEmojiData', value) + Cookies.set( + BACKEND_LANGUAGE_COOKIE_NAME, + localeService.internalToBackendLocaleMulti(value) + ) + break + case 'thirdColumnMode': + dispatch('setLayoutWidth', undefined) + break + } } } } diff --git a/src/modules/instance.js b/src/modules/instance.js index bb0292da..1ee64552 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -128,6 +128,7 @@ const defaultState = { mediaProxyAvailable: false, suggestionsEnabled: false, suggestionsWeb: '', + quotingAvailable: false, // Html stuff instanceSpecificPanelContent: '', diff --git a/src/modules/interface.js b/src/modules/interface.js index a86193ea..f8d62d87 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -1,7 +1,9 @@ const defaultState = { settingsModalState: 'hidden', - settingsModalLoaded: false, + settingsModalLoadedUser: false, + settingsModalLoadedAdmin: false, settingsModalTargetTab: null, + settingsModalMode: 'user', settings: { currentSaveStateNotice: null, noticeClearTimeout: null, @@ -54,10 +56,17 @@ const interfaceMod = { throw new Error('Illegal minimization state of settings modal') } }, - openSettingsModal (state) { + openSettingsModal (state, value) { + state.settingsModalMode = value state.settingsModalState = 'visible' - if (!state.settingsModalLoaded) { - state.settingsModalLoaded = true + if (value === 'user') { + if (!state.settingsModalLoadedUser) { + state.settingsModalLoadedUser = true + } + } else if (value === 'admin') { + if (!state.settingsModalLoadedAdmin) { + state.settingsModalLoadedAdmin = true + } } }, setSettingsModalTargetTab (state, value) { @@ -92,8 +101,8 @@ const interfaceMod = { closeSettingsModal ({ commit }) { commit('closeSettingsModal') }, - openSettingsModal ({ commit }) { - commit('openSettingsModal') + openSettingsModal ({ commit }, value = 'user') { + commit('openSettingsModal', value) }, togglePeekSettingsModal ({ commit }) { commit('togglePeekSettingsModal') @@ -103,7 +112,7 @@ const interfaceMod = { }, openSettingsModalTab ({ commit }, value) { commit('setSettingsModalTargetTab', value) - commit('openSettingsModal') + commit('openSettingsModal', 'user') }, pushGlobalNotice ( { commit, dispatch, state }, diff --git a/src/modules/postStatus.js b/src/modules/postStatus.js index 638c1fb2..d3bea137 100644 --- a/src/modules/postStatus.js +++ b/src/modules/postStatus.js @@ -10,6 +10,9 @@ const postStatus = { }, closePostStatusModal (state) { state.modalActivated = false + }, + resetPostStatusModal (state) { + state.params = null } }, actions: { @@ -18,6 +21,9 @@ const postStatus = { }, closePostStatusModal ({ commit }) { commit('closePostStatusModal') + }, + resetPostStatusModal ({ commit }) { + commit('resetPostStatusModal') } } } diff --git a/src/modules/serverSideConfig.js b/src/modules/profileConfig.js index 476263bc..2cb2014a 100644 --- a/src/modules/serverSideConfig.js +++ b/src/modules/profileConfig.js @@ -22,9 +22,9 @@ const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => { .updateNotificationSettings({ settings }) .then(result => { if (result.status === 'success') { - commit('confirmServerSideOption', { name, value }) + commit('confirmProfileOption', { name, value }) } else { - commit('confirmServerSideOption', { name, value: oldValue }) + commit('confirmProfileOption', { name, value: oldValue }) } }) } @@ -94,16 +94,16 @@ export const settingsMap = { export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null])) -const serverSideConfig = { +const profileConfig = { state: { ...defaultState }, mutations: { - confirmServerSideOption (state, { name, value }) { + confirmProfileOption (state, { name, value }) { set(state, name, value) }, - wipeServerSideOption (state, { name }) { + wipeProfileOption (state, { name }) { set(state, name, null) }, - wipeAllServerSideOptions (state) { + wipeAllProfileOptions (state) { Object.keys(settingsMap).forEach(key => { set(state, key, null) }) @@ -118,23 +118,23 @@ const serverSideConfig = { } }, actions: { - setServerSideOption ({ rootState, state, commit, dispatch }, { name, value }) { + setProfileOption ({ rootState, state, commit, dispatch }, { name, value }) { const oldValue = get(state, name) const map = settingsMap[name] if (!map) throw new Error('Invalid server-side setting') const { set: path = map, api = defaultApi } = map - commit('wipeServerSideOption', { name }) + commit('wipeProfileOption', { name }) api({ rootState, commit }, { path, value, oldValue }) .catch((e) => { console.warn('Error setting server-side option:', e) - commit('confirmServerSideOption', { name, value: oldValue }) + commit('confirmProfileOption', { name, value: oldValue }) }) }, logout ({ commit }) { - commit('wipeAllServerSideOptions') + commit('wipeAllProfileOptions') } } } -export default serverSideConfig +export default profileConfig diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 93a4a957..186bba3c 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -229,6 +229,10 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us timelineObject.newStatusCount += 1 } + if (status.quote) { + addStatus(status.quote, /* showImmediately = */ false, /* addToTimeline = */ false) + } + return status } @@ -757,7 +761,7 @@ const statuses = { ) }, fetchEmojiReactionsBy ({ rootState, commit }, id) { - rootState.api.backendInteractor.fetchEmojiReactions({ id }).then( + return rootState.api.backendInteractor.fetchEmojiReactions({ id }).then( emojiReactions => { commit('addEmojiReactionsBy', { id, emojiReactions, currentUser: rootState.users.currentUser }) } diff --git a/src/modules/users.js b/src/modules/users.js index 7b41fab6..50b4cb84 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -577,6 +577,7 @@ const users = { loginUser (store, accessToken) { return new Promise((resolve, reject) => { const commit = store.commit + const dispatch = store.dispatch commit('beginLogin') store.rootState.api.backendInteractor.verifyCredentials(accessToken) .then((data) => { @@ -591,57 +592,57 @@ const users = { commit('setServerSideStorage', user) commit('addNewUsers', [user]) - store.dispatch('fetchEmoji') + dispatch('fetchEmoji') getNotificationPermission() .then(permission => commit('setNotificationPermission', permission)) // Set our new backend interactor commit('setBackendInteractor', backendInteractorService(accessToken)) - store.dispatch('pushServerSideStorage') + dispatch('pushServerSideStorage') if (user.token) { - store.dispatch('setWsToken', user.token) + dispatch('setWsToken', user.token) // Initialize the shout socket. - store.dispatch('initializeSocket') + dispatch('initializeSocket') } const startPolling = () => { // Start getting fresh posts. - store.dispatch('startFetchingTimeline', { timeline: 'friends' }) + dispatch('startFetchingTimeline', { timeline: 'friends' }) // Start fetching notifications - store.dispatch('startFetchingNotifications') + dispatch('startFetchingNotifications') // Start fetching chats - store.dispatch('startFetchingChats') + dispatch('startFetchingChats') } - store.dispatch('startFetchingLists') + dispatch('startFetchingLists') if (user.locked) { - store.dispatch('startFetchingFollowRequests') + dispatch('startFetchingFollowRequests') } if (store.getters.mergedConfig.useStreamingApi) { - store.dispatch('fetchTimeline', { timeline: 'friends', since: null }) - store.dispatch('fetchNotifications', { since: null }) - store.dispatch('enableMastoSockets', true).catch((error) => { + dispatch('fetchTimeline', { timeline: 'friends', since: null }) + dispatch('fetchNotifications', { since: null }) + dispatch('enableMastoSockets', true).catch((error) => { console.error('Failed initializing MastoAPI Streaming socket', error) }).then(() => { - store.dispatch('fetchChats', { latest: true }) - setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) + dispatch('fetchChats', { latest: true }) + setTimeout(() => dispatch('setNotificationsSilence', false), 10000) }) } else { startPolling() } // Get user mutes - store.dispatch('fetchMutes') + dispatch('fetchMutes') - store.dispatch('setLayoutWidth', windowWidth()) - store.dispatch('setLayoutHeight', windowHeight()) + dispatch('setLayoutWidth', windowWidth()) + dispatch('setLayoutHeight', windowHeight()) // Fetch our friends store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) @@ -650,6 +651,12 @@ const users = { const response = data.error // Authentication failed commit('endLogin') + + // remove authentication token on client/authentication errors + if ([400, 401, 403, 422].includes(response.status)) { + commit('clearToken') + } + if (response.status === 401) { reject(new Error('Wrong username or password')) } else { diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index e90723a1..c6bca10b 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -108,6 +108,11 @@ const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements' const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}` const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}` +const PLEROMA_ADMIN_CONFIG_URL = '/api/pleroma/admin/config' +const PLEROMA_ADMIN_DESCRIPTIONS_URL = '/api/pleroma/admin/config/descriptions' +const PLEROMA_ADMIN_FRONTENDS_URL = '/api/pleroma/admin/frontends' +const PLEROMA_ADMIN_FRONTENDS_INSTALL_URL = '/api/pleroma/admin/frontends/install' + const oldfetch = window.fetch const fetch = (url, options) => { @@ -822,6 +827,7 @@ const postStatus = ({ poll, mediaIds = [], inReplyToStatusId, + quoteId, contentType, preview, idempotencyKey @@ -854,6 +860,9 @@ const postStatus = ({ if (inReplyToStatusId) { form.append('in_reply_to_id', inReplyToStatusId) } + if (quoteId) { + form.append('quote_id', quoteId) + } if (preview) { form.append('preview', 'true') } @@ -1668,6 +1677,94 @@ const setReportState = ({ id, state, credentials }) => { }) } +// ADMIN STUFF // EXPERIMENTAL +const fetchInstanceDBConfig = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_CONFIG_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const fetchInstanceConfigDescriptions = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_DESCRIPTIONS_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const fetchAvailableFrontends = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_FRONTENDS_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const pushInstanceDBConfig = ({ credentials, payload }) => { + return fetch(PLEROMA_ADMIN_CONFIG_URL, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...authHeaders(credentials) + }, + method: 'POST', + body: JSON.stringify(payload) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const installFrontend = ({ credentials, payload }) => { + return fetch(PLEROMA_ADMIN_FRONTENDS_INSTALL_URL, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...authHeaders(credentials) + }, + method: 'POST', + body: JSON.stringify(payload) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -1781,7 +1878,12 @@ const apiService = { postAnnouncement, editAnnouncement, deleteAnnouncement, - adminFetchAnnouncements + adminFetchAnnouncements, + fetchInstanceDBConfig, + fetchInstanceConfigDescriptions, + fetchAvailableFrontends, + pushInstanceDBConfig, + installFrontend } export default apiService diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index adefc5a5..610ba1ab 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -325,6 +325,10 @@ export const parseStatus = (data) => { output.thread_muted = pleroma.thread_muted output.emoji_reactions = pleroma.emoji_reactions output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible + output.quote = pleroma.quote ? parseStatus(pleroma.quote) : undefined + output.quote_id = pleroma.quote_id ? pleroma.quote_id : (output.quote ? output.quote.id : undefined) + output.quote_url = pleroma.quote_url + output.quote_visible = pleroma.quote_visible } else { output.text = data.content output.summary = data.spoiler_text diff --git a/src/services/file_type/file_type.service.js b/src/services/file_type/file_type.service.js index 5182ecd1..b92c6c64 100644 --- a/src/services/file_type/file_type.service.js +++ b/src/services/file_type/file_type.service.js @@ -1,7 +1,7 @@ // TODO this func might as well take the entire file and use its mimetype // or the entire service could be just mimetype service that only operates // on mimetypes and not files. Currently the naming is confusing. -const fileType = mimetype => { +export const fileType = mimetype => { if (mimetype.match(/flash/)) { return 'flash' } @@ -25,11 +25,25 @@ const fileType = mimetype => { return 'unknown' } -const fileMatchesSomeType = (types, file) => +export const fileTypeExt = url => { + if (url.match(/\.(png|jpe?g|gif|webp|avif)$/)) { + return 'image' + } + if (url.match(/\.(ogv|mp4|webm|mov)$/)) { + return 'video' + } + if (url.match(/\.(it|s3m|mod|umx|mp3|aac|m4a|flac|alac|ogg|oga|opus|wav|ape|midi?)$/)) { + return 'audio' + } + return 'unknown' +} + +export const fileMatchesSomeType = (types, file) => types.some(type => fileType(file.mimetype) === type) const fileTypeService = { fileType, + fileTypeExt, fileMatchesSomeType } diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js index f1042971..f8e62dfe 100644 --- a/src/services/html_converter/utility.service.js +++ b/src/services/html_converter/utility.service.js @@ -5,7 +5,7 @@ * @return {String} - tagname, i.e. "div" */ export const getTagName = (tag) => { - const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag) + const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gis.exec(tag) return result && (result[1] || result[2]) } @@ -22,7 +22,7 @@ export const getAttrs = (tag, filter) => { .replace(new RegExp('^' + getTagName(tag)), '') .replace(/\/?$/, '') .trim() - const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi)) + const attrs = Array.from(innertag.matchAll(/([a-z]+[a-z0-9-]*)(?:=("[^"]+?"|'[^']+?'))?/gi)) .map(([trash, key, value]) => [key, value]) .map(([k, v]) => { if (!v) return [k, true] diff --git a/src/services/locale/locale.service.js b/src/services/locale/locale.service.js index a4af8b90..24ed3cdb 100644 --- a/src/services/locale/locale.service.js +++ b/src/services/locale/locale.service.js @@ -19,6 +19,7 @@ const internalToBackendLocaleMulti = codes => { const getLanguageName = (code) => { const specialLanguageNames = { ja_easy: 'やさしいにほんご', + 'nan-TW': '臺語(閩南語)', zh: '简体中文', zh_Hant: '繁體中文' } diff --git a/src/services/matcher/matcher.service.js b/src/services/matcher/matcher.service.js index b6c4e909..54f02d31 100644 --- a/src/services/matcher/matcher.service.js +++ b/src/services/matcher/matcher.service.js @@ -14,8 +14,11 @@ export const mentionMatchesUrl = (attention, url) => { * @param {string} url */ export const extractTagFromUrl = (url) => { - const regex = /tag[s]*\/(\w+)$/g - const result = regex.exec(url) + const decoded = decodeURI(url) + // https://git.pleroma.social/pleroma/elixir-libraries/linkify/-/blob/master/lib/linkify/parser.ex + // https://www.pcre.org/original/doc/html/pcrepattern.html + const regex = /tag[s]*\/([\p{L}\p{N}_]*[\p{Alphabetic}_·\u{200c}][\p{L}\p{N}_·\p{M}\u{200c}]*)$/ug + const result = regex.exec(decoded) if (!result) { return false } diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index 1eb10bb6..aaef5a7a 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -10,6 +10,7 @@ const postStatus = ({ poll, media = [], inReplyToStatusId = undefined, + quoteId = undefined, contentType = 'text/plain', preview = false, idempotencyKey = '' @@ -24,6 +25,7 @@ const postStatus = ({ sensitive, mediaIds, inReplyToStatusId, + quoteId, contentType, poll, preview, |
