You've already forked AstralRinth
forked from didirus/AstralRinth
695 lines
19 KiB
Vue
695 lines
19 KiB
Vue
<template>
|
|
<div class="normal-page__content">
|
|
<Modal ref="editLinksModal" header="Edit links">
|
|
<div class="universal-modal links-modal">
|
|
<p>
|
|
Any links you specify below will be overwritten on each of the selected projects. Any you
|
|
leave blank will be ignored. You can clear a link from all selected projects using the
|
|
trash can button.
|
|
</p>
|
|
<section class="links">
|
|
<label
|
|
for="issue-tracker-input"
|
|
title="A place for users to report bugs, issues, and concerns about your project."
|
|
>
|
|
<span class="label__title">Issue tracker</span>
|
|
</label>
|
|
<div class="input-group shrink-first">
|
|
<input
|
|
id="issue-tracker-input"
|
|
v-model="editLinks.issues.val"
|
|
:disabled="editLinks.issues.clear"
|
|
type="url"
|
|
:placeholder="
|
|
editLinks.issues.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
|
"
|
|
maxlength="2048"
|
|
/>
|
|
<Button
|
|
v-tooltip="'Clear link'"
|
|
aria-label="Clear link"
|
|
class="square-button label-button"
|
|
:data-active="editLinks.issues.clear"
|
|
icon-only
|
|
@click="editLinks.issues.clear = !editLinks.issues.clear"
|
|
>
|
|
<TrashIcon />
|
|
</Button>
|
|
</div>
|
|
<label
|
|
for="source-code-input"
|
|
title="A page/repository containing the source code for your project"
|
|
>
|
|
<span class="label__title">Source code</span>
|
|
</label>
|
|
<div class="input-group shrink-first">
|
|
<input
|
|
id="source-code-input"
|
|
v-model="editLinks.source.val"
|
|
:disabled="editLinks.source.clear"
|
|
type="url"
|
|
maxlength="2048"
|
|
:placeholder="
|
|
editLinks.source.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
|
"
|
|
/>
|
|
<Button
|
|
v-tooltip="'Clear link'"
|
|
aria-label="Clear link"
|
|
:data-active="editLinks.source.clear"
|
|
icon-only
|
|
@click="editLinks.source.clear = !editLinks.source.clear"
|
|
>
|
|
<TrashIcon />
|
|
</Button>
|
|
</div>
|
|
<label
|
|
for="wiki-page-input"
|
|
title="A page containing information, documentation, and help for the project."
|
|
>
|
|
<span class="label__title">Wiki page</span>
|
|
</label>
|
|
<div class="input-group shrink-first">
|
|
<input
|
|
id="wiki-page-input"
|
|
v-model="editLinks.wiki.val"
|
|
:disabled="editLinks.wiki.clear"
|
|
type="url"
|
|
maxlength="2048"
|
|
:placeholder="
|
|
editLinks.wiki.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
|
"
|
|
/>
|
|
<Button
|
|
v-tooltip="'Clear link'"
|
|
aria-label="Clear link"
|
|
:data-active="editLinks.wiki.clear"
|
|
icon-only
|
|
@click="editLinks.wiki.clear = !editLinks.wiki.clear"
|
|
>
|
|
<TrashIcon />
|
|
</Button>
|
|
</div>
|
|
<label for="discord-invite-input" title="An invitation link to your Discord server.">
|
|
<span class="label__title">Discord invite</span>
|
|
</label>
|
|
<div class="input-group shrink-first">
|
|
<input
|
|
id="discord-invite-input"
|
|
v-model="editLinks.discord.val"
|
|
:disabled="editLinks.discord.clear"
|
|
type="url"
|
|
maxlength="2048"
|
|
:placeholder="
|
|
editLinks.discord.clear
|
|
? 'Existing link will be cleared'
|
|
: 'Enter a valid Discord invite URL'
|
|
"
|
|
/>
|
|
<Button
|
|
v-tooltip="'Clear link'"
|
|
aria-label="Clear link"
|
|
:data-active="editLinks.discord.clear"
|
|
icon-only
|
|
@click="editLinks.discord.clear = !editLinks.discord.clear"
|
|
>
|
|
<TrashIcon />
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
<p>
|
|
Changes will be applied to
|
|
<strong>{{ selectedProjects.length }}</strong> project{{
|
|
selectedProjects.length > 1 ? "s" : ""
|
|
}}.
|
|
</p>
|
|
<ul>
|
|
<li
|
|
v-for="project in selectedProjects.slice(
|
|
0,
|
|
editLinks.showAffected ? selectedProjects.length : 3,
|
|
)"
|
|
:key="project.id"
|
|
>
|
|
{{ project.name }}
|
|
</li>
|
|
<li v-if="!editLinks.showAffected && selectedProjects.length > 3">
|
|
<strong>and {{ selectedProjects.length - 3 }} more...</strong>
|
|
</li>
|
|
</ul>
|
|
<Checkbox
|
|
v-if="selectedProjects.length > 3"
|
|
v-model="editLinks.showAffected"
|
|
:label="editLinks.showAffected ? 'Less' : 'More'"
|
|
description="Show all loaders"
|
|
:border="false"
|
|
:collapsing-toggle-style="true"
|
|
/>
|
|
<div class="push-right input-group">
|
|
<Button @click="$refs.editLinksModal.hide()">
|
|
<XIcon />
|
|
Cancel
|
|
</Button>
|
|
<Button color="primary" @click="onBulkEditLinks">
|
|
<SaveIcon />
|
|
Save changes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
<ModalCreation ref="modal_creation" :organization-id="organization.id" />
|
|
<div class="universal-card">
|
|
<h2>Projects</h2>
|
|
<div class="input-group">
|
|
<Button color="primary" @click="$refs.modal_creation.show()">
|
|
<PlusIcon />
|
|
{{ formatMessage(commonMessages.createAProjectButton) }}
|
|
</Button>
|
|
<OrganizationProjectTransferModal
|
|
:projects="usersOwnedProjects || []"
|
|
@submit="onProjectTransferSubmit"
|
|
/>
|
|
</div>
|
|
<p v-if="sortedProjects.length < 1">
|
|
You don't have any projects yet. Click the green button above to begin.
|
|
</p>
|
|
<template v-else>
|
|
<p>You can edit multiple projects at once by selecting them below.</p>
|
|
<div class="input-group">
|
|
<Button :disabled="selectedProjects.length === 0" @click="$refs.editLinksModal.show()">
|
|
<EditIcon />
|
|
Edit links
|
|
</Button>
|
|
<div class="push-right">
|
|
<div class="labeled-control-row">
|
|
Sort by
|
|
<Multiselect
|
|
v-model="sortBy"
|
|
:searchable="false"
|
|
class="small-select"
|
|
:options="['Name', 'Status', 'Type']"
|
|
:close-on-select="true"
|
|
:show-labels="false"
|
|
:allow-empty="false"
|
|
@update:model-value="
|
|
sortedProjects = updateSort(sortedProjects, sortBy, descending)
|
|
"
|
|
/>
|
|
<Button
|
|
v-tooltip="descending ? 'Descending' : 'Ascending'"
|
|
class="square-button"
|
|
icon-only
|
|
@click="updateDescending()"
|
|
>
|
|
<SortDescendingIcon v-if="descending" />
|
|
<SortAscendingIcon v-else />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="table">
|
|
<div class="table-head table-row">
|
|
<div class="check-cell table-cell">
|
|
<Checkbox
|
|
:model-value="selectedProjects === sortedProjects"
|
|
@update:model-value="
|
|
selectedProjects === sortedProjects
|
|
? (selectedProjects = [])
|
|
: (selectedProjects = sortedProjects)
|
|
"
|
|
/>
|
|
</div>
|
|
<div class="table-cell">Icon</div>
|
|
<div class="table-cell">Name</div>
|
|
<div class="table-cell">ID</div>
|
|
<div class="table-cell">Type</div>
|
|
<div class="table-cell">Status</div>
|
|
<div class="table-cell" />
|
|
</div>
|
|
<div v-for="project in sortedProjects" :key="`project-${project.id}`" class="table-row">
|
|
<div class="check-cell table-cell">
|
|
<Checkbox
|
|
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
|
|
:model-value="selectedProjects.includes(project)"
|
|
@update:model-value="
|
|
selectedProjects.includes(project)
|
|
? (selectedProjects = selectedProjects.filter((it) => it !== project))
|
|
: selectedProjects.push(project)
|
|
"
|
|
/>
|
|
</div>
|
|
<div class="table-cell">
|
|
<nuxt-link tabindex="-1" :to="`/project/${project.slug ? project.slug : project.id}`">
|
|
<Avatar
|
|
:src="project.icon_url"
|
|
aria-hidden="true"
|
|
:alt="'Icon for ' + project.name"
|
|
no-shadow
|
|
/>
|
|
</nuxt-link>
|
|
</div>
|
|
|
|
<div class="table-cell">
|
|
<span class="project-title">
|
|
<IssuesIcon
|
|
v-if="project.moderator_message"
|
|
aria-label="Project has a message from the moderators. View the project to see more."
|
|
/>
|
|
|
|
<nuxt-link
|
|
class="hover-link wrap-as-needed"
|
|
:to="`/project/${project.slug ? project.slug : project.id}`"
|
|
>
|
|
{{ project.name }}
|
|
</nuxt-link>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="table-cell">
|
|
<CopyCode :text="project.id" />
|
|
</div>
|
|
|
|
<div class="table-cell">
|
|
<BoxIcon />
|
|
<span>{{
|
|
$formatProjectType(
|
|
$getProjectTypeForDisplay(project.project_types[0] ?? "project", project.loaders),
|
|
)
|
|
}}</span>
|
|
</div>
|
|
|
|
<div class="table-cell">
|
|
<Badge v-if="project.status" :type="project.status" class="status" />
|
|
</div>
|
|
|
|
<div class="table-cell">
|
|
<nuxt-link
|
|
class="btn icon-only"
|
|
:to="`/project/${project.slug ? project.slug : project.id}/settings`"
|
|
>
|
|
<SettingsIcon />
|
|
</nuxt-link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { Multiselect } from "vue-multiselect";
|
|
import {
|
|
BoxIcon,
|
|
SettingsIcon,
|
|
TrashIcon,
|
|
IssuesIcon,
|
|
PlusIcon,
|
|
XIcon,
|
|
EditIcon,
|
|
SaveIcon,
|
|
SortAscendingIcon,
|
|
SortDescendingIcon,
|
|
} from "@modrinth/assets";
|
|
import { Button, Modal, Avatar, CopyCode, Badge, Checkbox } from "@modrinth/ui";
|
|
|
|
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
|
import OrganizationProjectTransferModal from "~/components/ui/OrganizationProjectTransferModal.vue";
|
|
|
|
const { formatMessage } = useVIntl();
|
|
|
|
const { organization, projects, refresh } = inject("organizationContext");
|
|
|
|
const auth = await useAuth();
|
|
|
|
const { data: userProjects, refresh: refreshUserProjects } = await useAsyncData(
|
|
`user/${auth.value.user.id}/projects`,
|
|
() => useBaseFetch(`user/${auth.value.user.id}/projects`),
|
|
{
|
|
watch: [auth],
|
|
},
|
|
);
|
|
|
|
const usersOwnedProjects = ref([]);
|
|
|
|
watch(
|
|
() => userProjects.value,
|
|
async () => {
|
|
if (!userProjects.value) return;
|
|
if (!userProjects.value.length) return;
|
|
|
|
const projects = userProjects.value.filter((project) => project.organization === null);
|
|
|
|
const teamIds = projects.map((project) => project?.team).filter((x) => x);
|
|
// Shape of teams is member[][]
|
|
const teams = await useBaseFetch(`teams?ids=${JSON.stringify(teamIds)}`, {
|
|
apiVersion: 3,
|
|
});
|
|
// for each team id, figure out if the user is a member, and is_owner. Then filter the projects to only include those that are owned by the user
|
|
const ownedTeamIds = teamIds.filter((_tid, i) => {
|
|
const team = teams?.[i];
|
|
if (!team) return false;
|
|
const member = team.find((member) => member.user.id === auth.value.user.id);
|
|
return member && member.is_owner;
|
|
});
|
|
const ownedProjects = projects.filter((project) => ownedTeamIds.includes(project.team));
|
|
usersOwnedProjects.value = ownedProjects;
|
|
}, // watch options
|
|
{ immediate: true, deep: true },
|
|
);
|
|
|
|
const onProjectTransferSubmit = async (projects) => {
|
|
try {
|
|
for (const project of projects) {
|
|
await useBaseFetch(`organization/${organization.value.id}/projects`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
project_id: project.id,
|
|
}),
|
|
apiVersion: 3,
|
|
});
|
|
}
|
|
|
|
await refresh();
|
|
await refreshUserProjects();
|
|
|
|
addNotification({
|
|
group: "main",
|
|
title: "Success",
|
|
text: "Transferred selected projects to organization.",
|
|
type: "success",
|
|
});
|
|
} catch (err) {
|
|
addNotification({
|
|
group: "main",
|
|
title: "An error occurred",
|
|
text: err?.data?.description || err?.message || err || "Unknown error",
|
|
type: "error",
|
|
});
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const EDIT_DETAILS = 1 << 2;
|
|
|
|
const updateSort = (inputProjects, sort, descending) => {
|
|
let sortedArray = inputProjects;
|
|
switch (sort) {
|
|
case "Name":
|
|
sortedArray = inputProjects.slice().sort((a, b) => {
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
break;
|
|
case "Status":
|
|
sortedArray = inputProjects.slice().sort((a, b) => {
|
|
if (a.status < b.status) {
|
|
return -1;
|
|
}
|
|
if (a.status > b.status) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
});
|
|
break;
|
|
case "Type":
|
|
sortedArray = inputProjects.slice().sort((a, b) => {
|
|
if (a.project_type < b.project_type) {
|
|
return -1;
|
|
}
|
|
if (a.project_type > b.project_type) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
});
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (descending) {
|
|
sortedArray = sortedArray.reverse();
|
|
}
|
|
|
|
return sortedArray;
|
|
};
|
|
|
|
const sortedProjects = ref(updateSort(projects.value, "Name"));
|
|
const selectedProjects = ref([]);
|
|
const sortBy = ref("Name");
|
|
const descending = ref(false);
|
|
const editLinksModal = ref(null);
|
|
|
|
watch(
|
|
() => projects.value,
|
|
(newVal) => {
|
|
sortedProjects.value = updateSort(newVal, sortBy.value, descending.value);
|
|
},
|
|
);
|
|
|
|
const emptyLinksData = {
|
|
showAffected: false,
|
|
source: {
|
|
val: "",
|
|
clear: false,
|
|
},
|
|
discord: {
|
|
val: "",
|
|
clear: false,
|
|
},
|
|
wiki: {
|
|
val: "",
|
|
clear: false,
|
|
},
|
|
issues: {
|
|
val: "",
|
|
clear: false,
|
|
},
|
|
};
|
|
|
|
const editLinks = ref(emptyLinksData);
|
|
|
|
const updateDescending = () => {
|
|
descending.value = !descending.value;
|
|
sortedProjects.value = updateSort(sortedProjects.value, sortBy.value, descending.value);
|
|
};
|
|
|
|
const onBulkEditLinks = useClientTry(async () => {
|
|
const linkData = editLinks.value;
|
|
|
|
const baseData = {};
|
|
|
|
if (linkData.issues.clear) {
|
|
baseData.issues_url = null;
|
|
} else if (linkData.issues.val.trim().length > 0) {
|
|
baseData.issues_url = linkData.issues.val.trim();
|
|
}
|
|
|
|
if (linkData.source.clear) {
|
|
baseData.source_url = null;
|
|
} else if (linkData.source.val.trim().length > 0) {
|
|
baseData.source_url = linkData.source.val.trim();
|
|
}
|
|
|
|
if (linkData.wiki.clear) {
|
|
baseData.wiki_url = null;
|
|
} else if (linkData.wiki.val.trim().length > 0) {
|
|
baseData.wiki_url = linkData.wiki.val.trim();
|
|
}
|
|
|
|
if (linkData.discord.clear) {
|
|
baseData.discord_url = null;
|
|
} else if (linkData.discord.val.trim().length > 0) {
|
|
baseData.discord_url = linkData.discord.val.trim();
|
|
}
|
|
|
|
await useBaseFetch(`projects?ids=${JSON.stringify(selectedProjects.value.map((x) => x.id))}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(baseData),
|
|
});
|
|
|
|
editLinksModal.value.hide();
|
|
|
|
addNotification({
|
|
group: "main",
|
|
title: "Success",
|
|
text: "Bulk edited selected project's links.",
|
|
type: "success",
|
|
});
|
|
|
|
selectedProjects.value = [];
|
|
editLinks.value = emptyLinksData;
|
|
});
|
|
</script>
|
|
<style lang="scss" scoped>
|
|
.table {
|
|
display: grid;
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
margin-top: var(--gap-md);
|
|
border: 1px solid var(--color-button-bg);
|
|
background-color: var(--color-raised-bg);
|
|
|
|
.table-row {
|
|
grid-template-columns: 2.75rem 3.75rem 2fr 1fr 1fr 1fr 3.5rem;
|
|
}
|
|
|
|
.table-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--gap-xs);
|
|
padding: var(--gap-md);
|
|
padding-left: 0;
|
|
}
|
|
|
|
.check-cell {
|
|
padding-left: var(--gap-md);
|
|
}
|
|
|
|
@media screen and (max-width: 750px) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.table-row {
|
|
display: grid;
|
|
grid-template: "checkbox icon name type settings" "checkbox icon id status settings";
|
|
grid-template-columns:
|
|
min-content min-content minmax(min-content, 2fr)
|
|
minmax(min-content, 1fr) min-content;
|
|
|
|
:nth-child(1) {
|
|
grid-area: checkbox;
|
|
}
|
|
|
|
:nth-child(2) {
|
|
grid-area: icon;
|
|
}
|
|
|
|
:nth-child(3) {
|
|
grid-area: name;
|
|
}
|
|
|
|
:nth-child(4) {
|
|
grid-area: id;
|
|
padding-top: 0;
|
|
}
|
|
|
|
:nth-child(5) {
|
|
grid-area: type;
|
|
}
|
|
|
|
:nth-child(6) {
|
|
grid-area: status;
|
|
padding-top: 0;
|
|
}
|
|
|
|
:nth-child(7) {
|
|
grid-area: settings;
|
|
}
|
|
}
|
|
|
|
.table-head {
|
|
grid-template: "checkbox settings";
|
|
grid-template-columns: min-content minmax(min-content, 1fr);
|
|
|
|
:nth-child(2),
|
|
:nth-child(3),
|
|
:nth-child(4),
|
|
:nth-child(5),
|
|
:nth-child(6) {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
|
|
@media screen and (max-width: 560px) {
|
|
.table-row {
|
|
display: grid;
|
|
grid-template: "checkbox icon name settings" "checkbox icon id settings" "checkbox icon type settings" "checkbox icon status settings";
|
|
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
|
|
|
|
:nth-child(5) {
|
|
padding-top: 0;
|
|
}
|
|
}
|
|
|
|
.table-head {
|
|
grid-template: "checkbox settings";
|
|
grid-template-columns: min-content minmax(min-content, 1fr);
|
|
}
|
|
}
|
|
}
|
|
|
|
.project-title {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: var(--spacing-card-xs);
|
|
|
|
svg {
|
|
color: var(--color-orange);
|
|
}
|
|
}
|
|
|
|
.status {
|
|
margin-top: var(--spacing-card-xs);
|
|
}
|
|
|
|
.hover-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.labeled-control-row {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: row;
|
|
min-width: 0;
|
|
align-items: center;
|
|
gap: var(--gap-sm);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.small-select {
|
|
width: fit-content;
|
|
width: -moz-fit-content;
|
|
}
|
|
|
|
.label-button[data-active="true"] {
|
|
--background-color: var(--color-red);
|
|
--text-color: var(--color-brand-inverted);
|
|
}
|
|
|
|
.links-modal {
|
|
.links {
|
|
display: grid;
|
|
gap: var(--spacing-card-sm);
|
|
grid-template-columns: 1fr 2fr;
|
|
|
|
.input-group {
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
@media screen and (max-width: 530px) {
|
|
grid-template-columns: 1fr;
|
|
.input-group {
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
}
|
|
|
|
ul {
|
|
margin: 0 0 var(--spacing-card-sm) 0;
|
|
}
|
|
}
|
|
|
|
h1 {
|
|
margin-block: var(--gap-sm) var(--gap-lg);
|
|
font-size: 2em;
|
|
line-height: 1em;
|
|
}
|
|
|
|
:deep(.checkbox-outer) {
|
|
button.checkbox {
|
|
border: none;
|
|
}
|
|
}
|
|
</style>
|