diff --git a/src/common/__tests__/pins.test.js b/src/common/__tests__/pins.test.js new file mode 100644 index 000000000..88fd025b7 --- /dev/null +++ b/src/common/__tests__/pins.test.js @@ -0,0 +1,42 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { getRepoPinOwner, getRepoPinTarget, canUserPinFor } = await import('../pins.js') + +const member = {username: 'tester', subscribed_orgs: [{id: 'CIEL'}]} + +test('getRepoPinOwner maps the repo owner to a pin owner', () => { + assert.deepEqual(getRepoPinOwner({owner: 'CIEL', owner_type: 'Organization'}), {type: 'org', id: 'CIEL'}) + assert.deepEqual(getRepoPinOwner({owner: 'tester', owner_type: 'User'}), {type: 'user', id: 'tester'}) + assert.equal(getRepoPinOwner({}), false) + assert.equal(getRepoPinOwner(undefined), false) +}) + +test('getRepoPinTarget pins the repo, also from one of its versions', () => { + const url = '/orgs/CIEL/sources/CIEL/' + assert.deepEqual(getRepoPinTarget({url, type: 'Source', version: 'HEAD', uuid: '42'}), {url, type: 'Source', uuid: '42'}) + assert.deepEqual(getRepoPinTarget({url, type: 'Source Version', version: 'v2026-09-24', uuid: '8358'}), {url, type: 'Source', uuid: undefined}) + assert.deepEqual(getRepoPinTarget({url, type: 'Collection Version', version: 'v1', uuid: '9'}), {url, type: 'Collection', uuid: undefined}) +}) + +test('org members can pin for their org only', () => { + assert.equal(canUserPinFor(member, {type: 'org', id: 'CIEL'}), true) + assert.equal(canUserPinFor(member, {type: 'org', id: 'WHO'}), false) +}) + +test('users can pin for themselves only', () => { + assert.equal(canUserPinFor(member, {type: 'user', id: 'tester'}), true) + assert.equal(canUserPinFor(member, {type: 'user', id: 'bob'}), false) + assert.equal(canUserPinFor({username: 'CIEL'}, {type: 'org', id: 'CIEL'}), false) +}) + +test('staff can pin for any owner', () => { + assert.equal(canUserPinFor({username: 'ops', is_staff: true}, {type: 'org', id: 'WHO'}), true) + assert.equal(canUserPinFor({username: 'root', is_superuser: true}, {type: 'user', id: 'bob'}), true) +}) + +test('nobody pins without a signed-in user or an owner', () => { + assert.equal(canUserPinFor(null, {type: 'org', id: 'CIEL'}), false) + assert.equal(canUserPinFor({is_staff: true}, {type: 'org', id: 'CIEL'}), false) + assert.equal(canUserPinFor(member, false), false) +}) diff --git a/src/common/pins.js b/src/common/pins.js new file mode 100644 index 000000000..d8c70613f --- /dev/null +++ b/src/common/pins.js @@ -0,0 +1,26 @@ +// Pins are what a user or an org highlights on its own overview page. +// An owner is {type: 'user'|'org', id: }. + +export const getRepoPinOwner = repo => { + if(!repo?.owner || !repo?.owner_type) + return false + return {type: repo.owner_type === 'Organization' ? 'org' : 'user', id: repo.owner} +} + +// A pin highlights a repo, never one of its versions. A version's url is its repo's url, but its uuid is +// the version's own id, so a version page pins by url alone. +export const getRepoPinTarget = repo => { + const isVersion = Boolean(repo?.version && repo.version !== 'HEAD') + return {url: repo?.url, type: repo?.type?.replace(' Version', ''), uuid: isVersion ? undefined : repo?.uuid} +} + +// The owning user, the org's members and staff can change an owner's pins. +export const canUserPinFor = (user, owner) => { + if(!owner?.id || !user?.username) + return false + if(user.is_staff || user.is_superuser) + return true + return owner.type === 'org' ? + (user.subscribed_orgs || []).some(org => org?.id === owner.id) : + user.username === owner.id +} diff --git a/src/common/utils.js b/src/common/utils.js index b62272c67..25adbdc64 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -303,7 +303,7 @@ export const arrayToCSV = objArray => { } export const refreshCurrentUserCache = (callback, onFailure) => { - APIService.user().get(null, null, {includeSubscribedOrgs: true, includeAuthGroups: true, includePins: true, includeFollowing: true}).then(response => { + APIService.user().get(null, null, {includeSubscribedOrgs: true, includeAuthGroups: true, includeFollowing: true}).then(response => { if(response?.status === 200) { localStorage.setItem('user', JSON.stringify(response.data)); if(callback) callback(response); diff --git a/src/components/common/PinActionButton.jsx b/src/components/common/PinActionButton.jsx deleted file mode 100644 index 70e8ad0f7..000000000 --- a/src/components/common/PinActionButton.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import PinIcon from './PinIcon'; - -// Row-level pin toggle for pinnable listings (repos, orgs). -const PinActionButton = ({ item, pin, onToggle, disabled, size, sx }) => { - const { t } = useTranslation() - const pinned = Boolean(pin?.id) - const onClick = event => { - event.preventDefault() - event.stopPropagation() - if(!disabled) - onToggle(item) - } - - return ( - - - - - - - - ) -} - -export default PinActionButton; diff --git a/src/components/orgs/OrgHome.jsx b/src/components/orgs/OrgHome.jsx index d17d43bd6..751153019 100644 --- a/src/components/orgs/OrgHome.jsx +++ b/src/components/orgs/OrgHome.jsx @@ -28,7 +28,7 @@ const OrgHome = () => { const [tab, setTab] = React.useState(findTab) const [defaultFiltersOpen, onFiltersOpenChange] = useDefaultFiltersOpen() const { setAlert } = React.useContext(OperationsContext); - const { pins: bookmarks, canPin, togglePin, deletePin } = usePins(params?.org ? {type: 'org', id: params.org} : false) + const { pins: bookmarks, canPin, deletePin } = usePins(params?.org ? {type: 'org', id: params.org} : false) const TABS = [ {key: 'overview', label: t('common.overview')}, @@ -107,9 +107,6 @@ const OrgHome = () => { { } -
{ diff --git a/src/components/repos/RepoHeader.jsx b/src/components/repos/RepoHeader.jsx index 80d9dad11..2b1fb4d17 100644 --- a/src/components/repos/RepoHeader.jsx +++ b/src/components/repos/RepoHeader.jsx @@ -10,12 +10,16 @@ import ProcessingFlag from './ProcessingFlag'; import RepoChip from './RepoChip' import OwnerChip from '../common/OwnerChip'; import { copyURL, currentUserHasAccess, toFullAPIURL } from '../../common/utils'; +import { getRepoPinOwner, getRepoPinTarget } from '../../common/pins'; +import usePins, { canActOnPins } from '../../hooks/usePins'; import RepoManagementList from './RepoManagementList'; import { createSimilarRepoHref } from './utils'; import FollowActionButton from '../common/FollowActionButton' +import { OperationsContext } from '../app/LayoutContext'; const RepoHeader = ({repo, owner, versions, versionsLoading, previewVersions, hasMoreVersions, onLoadMoreVersions, onVersionChange, repoHref, onCreateConceptClick, onCreateMappingClick, onVersionEditClick, onCreateVersionClick, onDeleteRepoClick, isVersion, onReleaseVersionClick, hideActions}) => { const { t } = useTranslation() + const { setAlert } = React.useContext(OperationsContext) const [menu, setMenu] = React.useState(false) const [menuAnchorEl, setMenuAnchorEl] = React.useState(false) const onMenuOpen = event => { @@ -27,6 +31,16 @@ const RepoHeader = ({repo, owner, versions, versionsLoading, previewVersions, ha setMenu(false) } + // Pins highlight the repo on its owner's overview, for those who can pin for that owner. + const pinOwner = hideActions ? false : getRepoPinOwner(repo) + const { pins, canPin, findPin, togglePin } = usePins(canActOnPins(pinOwner) ? pinOwner : false) + const pinTarget = getRepoPinTarget(repo) + const pinned = Boolean(findPin(pinTarget)) + const onPinToggle = () => togglePin(pinTarget).then(saved => { + if(saved) + setAlert({severity: 'success', message: t(pinned ? 'bookmarks.unpinned_from_owner_overview' : 'bookmarks.pinned_to_owner_overview', {owner: pinOwner.id, interpolation: {escapeValue: false}})}) + }) + const onManageOptionClick = option => { onMenuClose() if(option === 'addConcept') @@ -43,6 +57,8 @@ const RepoHeader = ({repo, owner, versions, versionsLoading, previewVersions, ha onReleaseVersionClick() else if (option === 'copyURL') copyURL(toFullAPIURL(repo.url)) + else if (option === 'pin') + onPinToggle() } const hasAccess = currentUserHasAccess() @@ -71,7 +87,7 @@ const RepoHeader = ({repo, owner, versions, versionsLoading, previewVersions, ha - + } diff --git a/src/components/repos/RepoManagementList.jsx b/src/components/repos/RepoManagementList.jsx index 3b5904c83..4f9d7b411 100644 --- a/src/components/repos/RepoManagementList.jsx +++ b/src/components/repos/RepoManagementList.jsx @@ -9,8 +9,9 @@ import ReleaseIcon from '@mui/icons-material/NewReleases'; import RepeatIcon from '@mui/icons-material/Repeat'; import CopyIcon from '@mui/icons-material/ContentCopy'; import GAService from '../../services/GAService'; +import PinIcon from '../common/PinIcon'; -const RepoManagementList = ({ anchorEl, open, onClose, onClick, repo, id, isVersion, hasAccess, createSimilarHref }) => { +const RepoManagementList = ({ anchorEl, open, onClose, onClick, repo, id, isVersion, hasAccess, createSimilarHref, pin }) => { const { t } = useTranslation() const url = isVersion ? repo.version_url : repo.url let editParams = isVersion ? {onClick: () => onClick('editVersion')} : {href: `${url}edit`} @@ -83,6 +84,15 @@ const RepoManagementList = ({ anchorEl, open, onClose, onClick, repo, id, isVers } + { + Boolean(pin) && + onClick('pin')} sx={{padding: '8px 12px'}}> + + + + + + } { hasAccess && diff --git a/src/components/repos/RepoTooltip.jsx b/src/components/repos/RepoTooltip.jsx index cbeb9d57a..e774f90b3 100644 --- a/src/components/repos/RepoTooltip.jsx +++ b/src/components/repos/RepoTooltip.jsx @@ -7,7 +7,6 @@ import has from 'lodash/has' import startCase from 'lodash/startCase' import MUIBasicTooltip from '@mui/material/Tooltip' import HTMLTooltip from '../common/HTMLTooltip' -import FollowActionButton from '../common/FollowActionButton' import OwnerButton from '../common/OwnerButton' import RepoVersionButton from './RepoVersionButton' import { URIToOwnerParams, formatDate } from '../../common/utils' @@ -82,7 +81,6 @@ const TooltipTitle = ({ repo }) => { }} /> -
history.push(url)}> diff --git a/src/components/search/Search.jsx b/src/components/search/Search.jsx index 96a461e12..b5a39c06d 100644 --- a/src/components/search/Search.jsx +++ b/src/components/search/Search.jsx @@ -17,9 +17,7 @@ import TransformIcon from '@mui/icons-material/Transform'; import DownIcon from '@mui/icons-material/KeyboardArrowDown'; import { forEach, keys, pick, pickBy, isEmpty, find, uniq, has, orderBy as sortBy, uniqBy, omit, max, isEqual, isBoolean } from 'lodash'; import { COLORS } from '../../common/colors'; -import { dropVersion, highlightTexts, isLoggedIn, getCurrentUserUsername } from '../../common/utils'; -import usePins, { PIN_RESOURCES, MAX_PINS_ALLOWED } from '../../hooks/usePins'; -import PinActionButton from '../common/PinActionButton'; +import { dropVersion, highlightTexts, isLoggedIn } from '../../common/utils'; import APIService from '../../services/APIService'; import RepoIcon from '../repos/RepoIcon'; import ConceptIcon from '../concepts/ConceptIcon'; @@ -56,14 +54,6 @@ const getCollectionLookupUrl = url => (url || '').replace(/\/(concepts|mappings| const Search = props => { const { setAlert, contextRepo } = React.useContext(OperationsContext); - - // Pins: the owning page (org/user home) supplies them; a standalone search pins to the current user. - const hasPinsFromProps = Boolean(props.onPinToggle) - const currentUsername = getCurrentUserUsername() - const ownPins = usePins((hasPinsFromProps || props.nested || !currentUsername) ? false : {type: 'user', id: currentUsername}) - const pins = hasPinsFromProps ? props.pins : ownPins.pins - const canPin = hasPinsFromProps ? props.canPin : ownPins.canPin - const onPinToggle = hasPinsFromProps ? props.onPinToggle : ownPins.togglePin const { t } = useTranslation() const history = useHistory(); const location = useLocation(); @@ -714,27 +704,6 @@ const Search = props => { setShowItem(false) }, [location.search]) - const findPinFor = item => (pins || []).find(pin => pin.resource_uri === item?.url) - const pinLimitReached = (pins || []).length >= MAX_PINS_ALLOWED - const pinColumn = (canPin && PIN_RESOURCES.includes(resource)) ? { - id: 'pin', - label: '', - sortable: false, - align: 'right', - sx: {width: '48px', padding: '0 8px'}, - renderer: item => { - const pin = findPinFor(item) - return ( - - ) - } - } : null - return (
@@ -812,7 +781,7 @@ const Search = props => { resultContainerStyle={props.resultContainerStyle} resultSize={props.resultSize} excludedColumns={props.excludedColumns} - extraColumns={[...(props.extraColumns || []), pinColumn].filter(Boolean)} + extraColumns={props.extraColumns} properties={props.properties} propertyDefinition={props.propertyDefinition} propertyFilters={props.propertyFilters} diff --git a/src/components/search/browseConfig.js b/src/components/search/browseConfig.js index 8ae6e5b97..d637d9aee 100644 --- a/src/components/search/browseConfig.js +++ b/src/components/search/browseConfig.js @@ -32,11 +32,6 @@ export const BROWSE_CONFIG = { titleKey: 'user.my_organizations', getItems: () => getCurrentUserOrgs(), }, - { - key: 'bookmarked', - titleKey: 'search.bookmarked_organizations', - getItems: () => filter(getCurrentUser()?.pins, pin => isOrg(pin.resource)).map(pin => pin.resource), - }, { key: 'following', titleKey: 'search.following_organizations', @@ -54,11 +49,6 @@ export const BROWSE_CONFIG = { titleKey: 'user.my_repositories', getItems: fetchMyRepos, }, - { - key: 'bookmarked', - titleKey: 'search.bookmarked_repositories', - getItems: () => filter(getCurrentUser()?.pins, pin => isRepo(pin.resource)).map(pin => pin.resource), - }, { key: 'following', titleKey: 'search.following_repositories', @@ -71,11 +61,6 @@ export const BROWSE_CONFIG = { emptyMessageKey: 'search.no_users_found', getSubtitle: item => item.username, sections: [ - { - key: 'bookmarked', - titleKey: 'search.bookmarked_users', - getItems: () => filter(getCurrentUser()?.pins, pin => isUser(pin.resource)).map(pin => pin.resource), - }, { key: 'following', titleKey: 'search.following_users', diff --git a/src/components/users/UserHome.jsx b/src/components/users/UserHome.jsx index 78a9572f1..1b3e42e6c 100644 --- a/src/components/users/UserHome.jsx +++ b/src/components/users/UserHome.jsx @@ -23,7 +23,7 @@ const UserHome = () => { const [defaultFiltersOpen, onFiltersOpenChange] = useDefaultFiltersOpen() const [user, setUser] = React.useState({}) const [status, setStatus] = React.useState(false) - const { pins: bookmarks, canPin, togglePin, deletePin } = usePins(params?.user ? {type: 'user', id: params.user} : false) + const { pins: bookmarks, canPin, deletePin } = usePins(params?.user ? {type: 'user', id: params.user} : false) const [events, setEvents] = React.useState(false) const [eventsPage, setEventsPage] = React.useState(0) const [haveMoreEvents, setHaveMoreEvents] = React.useState(false) @@ -116,9 +116,6 @@ const UserHome = () => { { @@ -38,7 +37,6 @@ const TooltipTitle = ({ user }) => { } -
history.push(entity?.url)}> diff --git a/src/hooks/usePins.js b/src/hooks/usePins.js index 61a6a9a42..7302ecedf 100644 --- a/src/hooks/usePins.js +++ b/src/hooks/usePins.js @@ -1,13 +1,12 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import APIService from '../services/APIService'; -import { - getCurrentUserUsername, isAdminUser, isCurrentUserMemberOf -} from '../common/utils'; +import { getCurrentUser } from '../common/utils'; +import { canUserPinFor } from '../common/pins'; import { OperationsContext } from '../components/app/LayoutContext'; // Pins (a.k.a. bookmarks) are owned either by a user or by an org. // owner is {type: 'user'|'org', id: } or false when nothing is pinnable. -export const PIN_RESOURCES = ['repos', 'orgs']; export const MAX_PINS_ALLOWED = 4; const getService = (owner, pinId) => { @@ -17,15 +16,7 @@ const getService = (owner, pinId) => { return service.pins(pinId) } -export const canActOnPins = owner => { - if(!owner?.id || !getCurrentUserUsername()) - return false - if(isAdminUser()) - return true - return owner.type === 'org' ? - isCurrentUserMemberOf(owner.id) : - getCurrentUserUsername() === owner.id -} +export const canActOnPins = owner => canUserPinFor(getCurrentUser(), owner) // The pin API needs the resource's numeric PK, which list payloads don't carry. const resolveResourceId = item => { @@ -47,6 +38,7 @@ const getErrorMessage = response => { } const usePins = owner => { + const { t } = useTranslation() const [pins, setPins] = React.useState(false) const [error, setError] = React.useState(false) const { setAlert } = React.useContext(OperationsContext) || {} @@ -72,33 +64,43 @@ const usePins = owner => { const findPin = item => (pins || []).find(pin => pin.resource_uri === item?.url) + const fail = message => { + setError(message || t('common.generic_error')) + return false + } + const limitMessage = () => t('bookmarks.max_pins_reached', {max: MAX_PINS_ALLOWED, owner: ownerId, interpolation: {escapeValue: false}}) + + // createPin and deletePin resolve to true once the change is saved. const createPin = item => { if(!canPin || !item?.url) - return + return Promise.resolve(false) if((pins || []).length >= MAX_PINS_ALLOWED) - return setError(`Can only keep max ${MAX_PINS_ALLOWED} items pinned`) - resolveResourceId(item).then(resourceId => { + return Promise.resolve(fail(limitMessage())) + return resolveResourceId(item).then(resourceId => { if(!resourceId) - return setError('Could not resolve the resource to pin') - getService({type: ownerType, id: ownerId}) + return fail() + return getService({type: ownerType, id: ownerId}) .post({resource_type: item.type || item.repo_type, resource_id: resourceId}) .then(response => { - if(response?.status === 201 && response?.data?.id) + if(response?.status === 201 && response?.data?.id) { setPins(existing => [...(existing || []), response.data]) - else - setError(getErrorMessage(response) || 'Something went wrong while pinning') + return true + } + // The API answers {error: [...]} only when the owner already has the most pins allowed. + return fail(response?.error ? limitMessage() : getErrorMessage(response)) }) }) } const deletePin = pinId => { if(!canPin || !pinId) - return - getService({type: ownerType, id: ownerId}, pinId).delete().then(response => { - if(response?.status === 204) + return Promise.resolve(false) + return getService({type: ownerType, id: ownerId}, pinId).delete().then(response => { + if(response?.status === 204) { setPins(existing => (existing || []).filter(pin => pin.id !== pinId)) - else - setError(getErrorMessage(response) || 'Something went wrong while removing the pin') + return true + } + return fail(getErrorMessage(response)) }) } diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index ca52c13b0..b3ebef4ee 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -241,9 +241,12 @@ "name": "Bookmarks", "pinned": "Pinned", "pinned_repos": "Pinned repositories", - "pin": "Pin", "unpin": "Remove pin", - "max_pins_reached": "Maximum 4 items can be pinned" + "max_pins_reached": "You can pin up to {{max}} repositories. Remove a pin from the {{owner}} overview first.", + "pin_to_owner_overview": "Pin to {{owner}} overview", + "unpin_from_owner_overview": "Unpin from {{owner}} overview", + "pinned_to_owner_overview": "Pinned to the {{owner}} overview", + "unpinned_from_owner_overview": "Unpinned from the {{owner}} overview" }, "auth": { "sign_in": "Sign in", @@ -988,13 +991,10 @@ "filtered_by": "filtered by", "class": "Class", "map_type": "Map Type", - "bookmarked_organizations": "Bookmarked organizations", "following_organizations": "Following organizations", "no_organizations_found": "No organizations found", - "bookmarked_repositories": "Bookmarked repositories", "following_repositories": "Following repositories", "no_repositories_found": "No repositories found", - "bookmarked_users": "Bookmarked users", "following_users": "Following users", "no_users_found": "No users found", "table": "Table", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 1c0566faf..e9b0729f9 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -134,7 +134,12 @@ "create_repository": "Crear Repositorio" }, "bookmarks": { - "name": "Marcadores" + "name": "Marcadores", + "max_pins_reached": "Puede fijar hasta {{max}} repositorios. Primero quite un elemento fijado de la vista general de {{owner}}.", + "pin_to_owner_overview": "Fijar en la vista general de {{owner}}", + "unpin_from_owner_overview": "Quitar de la vista general de {{owner}}", + "pinned_to_owner_overview": "Fijado en la vista general de {{owner}}", + "unpinned_from_owner_overview": "Quitado de la vista general de {{owner}}" }, "auth": { "sign_in": "Iniciar sesión", @@ -593,13 +598,10 @@ "filtered_by": "filtrado por", "class": "Clase", "map_type": "Tipo de mapeo", - "bookmarked_organizations": "Organizaciones guardadas", "following_organizations": "Organizaciones que sigues", "no_organizations_found": "No se encontraron organizaciones", - "bookmarked_repositories": "Repositorios guardados", "following_repositories": "Repositorios que sigues", "no_repositories_found": "No se encontraron repositorios", - "bookmarked_users": "Usuarios guardados", "following_users": "Usuarios que sigues", "no_users_found": "No se encontraron usuarios", "table": "Tabla", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index 0b0bf28fe..3ca81797f 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -240,9 +240,12 @@ "name": "Favoritos", "pinned": "Fixados", "pinned_repos": "Repositórios fixados", - "pin": "Fixar", "unpin": "Remover fixação", - "max_pins_reached": "Máximo de 4 itens podem ser fixados" + "max_pins_reached": "Você pode fixar até {{max}} repositórios. Remova primeiro uma fixação da visão geral de {{owner}}.", + "pin_to_owner_overview": "Fixar na visão geral de {{owner}}", + "unpin_from_owner_overview": "Remover fixação da visão geral de {{owner}}", + "pinned_to_owner_overview": "Fixado na visão geral de {{owner}}", + "unpinned_from_owner_overview": "Fixação removida da visão geral de {{owner}}" }, "auth": { "sign_in": "Entrar", @@ -978,13 +981,10 @@ "filtered_by": "filtrados por", "class": "Classe", "map_type": "Tipo de mapeamento", - "bookmarked_organizations": "Organizações favoritas", "following_organizations": "Organizações que segue", "no_organizations_found": "Nenhuma organização encontrada", - "bookmarked_repositories": "Repositórios favoritos", "following_repositories": "Repositórios que segue", "no_repositories_found": "Nenhum repositório encontrado", - "bookmarked_users": "Usuários favoritos", "following_users": "Usuários que segue", "no_users_found": "Nenhum usuário encontrado", "table": "Tabela", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 50beea191..a6872b274 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -168,7 +168,12 @@ "bookmarks": { "name": "书签", "pinned": "已钉住", - "pinned_repos": "已钉住的存储库" + "pinned_repos": "已钉住的存储库", + "max_pins_reached": "最多可以钉住 {{max}} 个存储库。请先从 {{owner}} 概述中移除一个已钉住的项。", + "pin_to_owner_overview": "钉到 {{owner}} 概述", + "unpin_from_owner_overview": "从 {{owner}} 概述中取消钉住", + "pinned_to_owner_overview": "已钉到 {{owner}} 概述", + "unpinned_from_owner_overview": "已从 {{owner}} 概述中取消钉住" }, "auth": { "sign_in": "登录", @@ -698,13 +703,10 @@ "filtered_by": "筛选条件为", "class": "类别", "map_type": "映射类型", - "bookmarked_organizations": "已收藏的组织架构", "following_organizations": "关注的组织架构", "no_organizations_found": "未找到组织架构", - "bookmarked_repositories": "已收藏的存储库", "following_repositories": "关注的存储库", "no_repositories_found": "未找到存储库", - "bookmarked_users": "已收藏的用户", "following_users": "关注的用户", "no_users_found": "未找到用户", "table": "表格",