Files
AstralRinth/apps/frontend/src/pages/organization/[id]/settings/projects.vue
Evan Song abec2e48d4 Add TailwindCSS (#1252)
* Setup TailwindCSS

* Fully setup configuration

* Refactor some tailwind variables
2024-07-06 20:57:32 -07:00

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>