mirror of
https://github.com/HangarMC/Hangar.git
synced 2025-01-12 14:06:14 +08:00
implement admin project approval page, visibility change modal
This commit is contained in:
parent
8aafe48269
commit
b1cb09299e
@ -217,7 +217,7 @@ once QA has passed, the checkboxes can be removed and the page can be ~~striked
|
|||||||
- flags
|
- flags
|
||||||
- [x] fetch
|
- [x] fetch
|
||||||
- [x] layout
|
- [x] layout
|
||||||
- [x] functionality (cors error)
|
- [x] functionality
|
||||||
- [x] design
|
- [x] design
|
||||||
- [ ] qa
|
- [ ] qa
|
||||||
- health
|
- health
|
||||||
@ -253,10 +253,10 @@ once QA has passed, the checkboxes can be removed and the page can be ~~striked
|
|||||||
- [ ] qa
|
- [ ] qa
|
||||||
- approval (empty)
|
- approval (empty)
|
||||||
- projects
|
- projects
|
||||||
- [ ] fetch
|
- [x] fetch
|
||||||
- [ ] layout
|
- [x] layout
|
||||||
- [ ] functionality
|
- [x] functionality
|
||||||
- [ ] design
|
- [x] design
|
||||||
- [ ] qa
|
- [ ] qa
|
||||||
- versions
|
- versions
|
||||||
- [ ] fetch
|
- [ ] fetch
|
||||||
|
@ -7,10 +7,10 @@ const classes = "color-primary font-bold hover:(underline)";
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-link v-if="to" :to="to" :class="classes">
|
<router-link v-if="to" :to="to" :class="classes" v-bind="$attrs">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</router-link>
|
</router-link>
|
||||||
<a v-else :href="href" :class="classes">
|
<a v-else :href="href" :class="classes" v-bind="$attrs">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import Button from "~/components/design/Button.vue";
|
||||||
|
import Modal from "~/components/modals/Modal.vue";
|
||||||
|
import { Visibility } from "~/types/enums";
|
||||||
|
import InputRadio from "~/components/ui/InputRadio.vue";
|
||||||
|
import { useBackendDataStore } from "~/store/backendData";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import InputTextarea from "~/components/ui/InputTextarea.vue";
|
||||||
|
import { useInternalApi } from "~/composables/useApi";
|
||||||
|
import { handleRequestError } from "~/composables/useErrorHandling";
|
||||||
|
import { useContext } from "vite-ssr/vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
type: "project" | "version";
|
||||||
|
propVisibility: Visibility;
|
||||||
|
postUrl: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const ctx = useContext();
|
||||||
|
const backendData = useBackendDataStore();
|
||||||
|
|
||||||
|
const visibility = ref<Visibility>();
|
||||||
|
const reason = ref<string>("");
|
||||||
|
|
||||||
|
const showTextarea = computed(() => currentIVis.value?.showModal && props.propVisibility !== visibility.value);
|
||||||
|
const currentIVis = computed(() => backendData.visibilities.find((v) => v.name === visibility.value));
|
||||||
|
|
||||||
|
async function submit(closeModal: () => void): Promise<void> {
|
||||||
|
await useInternalApi(props.postUrl, true, "post", {
|
||||||
|
visibility: visibility.value,
|
||||||
|
comment: currentIVis.value?.showModal ? reason.value : null,
|
||||||
|
}).catch((e) => handleRequestError(e, ctx, i18n));
|
||||||
|
reason.value = "";
|
||||||
|
// TODO success notification
|
||||||
|
// this.$util.success(i18n.t("visibility.modal.success", [this.type, i18n.t(currentIVis.value?.title)]));
|
||||||
|
// this.$nuxt.refresh();
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="i18n.t('visibility.modal.title', [type])">
|
||||||
|
<template #default="{ on }">
|
||||||
|
<InputRadio v-for="vis in backendData.visibilities" :key="vis.name" v-model="visibility" :value="vis.name" :label="i18n.t(vis.title)" class="block" />
|
||||||
|
|
||||||
|
<InputTextarea v-if="showTextarea" v-model.trim="reason" rows="2" :label="i18n.t('visibility.modal.reason')" />
|
||||||
|
|
||||||
|
<Button class="mt-2" v-on="on">{{ i18n.t("general.close") }}</Button>
|
||||||
|
<Button class="mt-2 ml-2" @click="submit(on.click)">{{ i18n.t("general.submit") }}</Button>
|
||||||
|
</template>
|
||||||
|
<template #activator="{ on }">
|
||||||
|
<Button v-bind="$attrs" color="warning" class="mr-1" v-on="on">
|
||||||
|
<IconMdiEye />
|
||||||
|
{{ i18n.t("visibility.modal.activatorBtn") }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
40
frontend-new/src/components/projects/AdminProjectList.vue
Normal file
40
frontend-new/src/components/projects/AdminProjectList.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ProjectApproval } from "hangar-internal";
|
||||||
|
import Alert from "~/components/design/Alert.vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import Markdown from "~/components/Markdown.vue";
|
||||||
|
import Link from "~/components/design/Link.vue";
|
||||||
|
import VisibilityChangerModal from "~/components/modals/VisibilityChangerModal.vue";
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const props = defineProps<{
|
||||||
|
projects: ProjectApproval[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul v-if="projects.length">
|
||||||
|
<template v-for="project in projects" :key="project.projectId">
|
||||||
|
<hr class="mb-3" />
|
||||||
|
<li>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-grow">
|
||||||
|
{{ i18n.t("projectApproval.description", [project.changeRequester, `${project.namespace.owner}/${project.namespace.slug}`]) }}
|
||||||
|
<Link :to="`/${project.namespace.owner}/${project.namespace.slug}`" target="_blank">
|
||||||
|
<IconMdiOpenInNew />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink">
|
||||||
|
<VisibilityChangerModal :prop-visibility="project.visibility" small-btn type="project" :post-url="`projects/visibility/${project.projectId}`" />
|
||||||
|
</div>
|
||||||
|
<div class="basis-full">
|
||||||
|
<Markdown :raw="project.comment" class="mb-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
<Alert v-else>
|
||||||
|
{{ i18n.t("projectApproval.noProjects") }}
|
||||||
|
</Alert>
|
||||||
|
</template>
|
@ -4,7 +4,7 @@ import { computed } from "vue";
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "update:modelValue", value: boolean | boolean[]): void;
|
(e: "update:modelValue", value: boolean | boolean[]): void;
|
||||||
}>();
|
}>();
|
||||||
const value = computed({
|
const internalVal = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (v) => emit("update:modelValue", v),
|
set: (v) => emit("update:modelValue", v),
|
||||||
});
|
});
|
||||||
@ -17,7 +17,7 @@ const props = defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<label class="group relative cursor-pointer pl-30px customCheckboxContainer w-max">
|
<label class="group relative cursor-pointer pl-30px customCheckboxContainer w-max">
|
||||||
<template v-if="props.label">{{ props.label }}</template>
|
<template v-if="props.label">{{ props.label }}</template>
|
||||||
<input v-model="value" type="checkbox" class="hidden" v-bind="$attrs" />
|
<input v-model="internalVal" type="checkbox" class="hidden" v-bind="$attrs" />
|
||||||
<span
|
<span
|
||||||
class="absolute top-5px left-0 h-15px w-15px rounded bg-gray-300"
|
class="absolute top-5px left-0 h-15px w-15px rounded bg-gray-300"
|
||||||
after="absolute hidden content-DEFAULT top-1px left-5px border-solid w-6px h-12px border-r-3px border-b-3px border-white"
|
after="absolute hidden content-DEFAULT top-1px left-5px border-solid w-6px h-12px border-r-3px border-b-3px border-white"
|
||||||
|
40
frontend-new/src/components/ui/InputRadio.vue
Normal file
40
frontend-new/src/components/ui/InputRadio.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value?: string): void;
|
||||||
|
}>();
|
||||||
|
const internalVal = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit("update:modelValue", v),
|
||||||
|
});
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue?: string;
|
||||||
|
label?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="group relative cursor-pointer pl-30px customCheckboxContainer w-max">
|
||||||
|
<template v-if="props.label">{{ props.label }}</template>
|
||||||
|
<input v-model="internalVal" type="radio" class="hidden" v-bind="$attrs" />
|
||||||
|
<span
|
||||||
|
class="absolute top-5px left-0 h-15px w-15px rounded-full bg-gray-300"
|
||||||
|
after="absolute hidden content-DEFAULT top-1px left-5px border-solid w-6px h-12px border-r-3px border-b-3px border-white rounded-full"
|
||||||
|
group-hover="bg-gray-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/*This is needed, because you cannot have more than one parent group in tailwind/windi*/
|
||||||
|
.customCheckboxContainer input:checked ~ span {
|
||||||
|
background-color: #004ee9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*The tailwind/windi utility class rotate-45 is BROKEN*/
|
||||||
|
.customCheckboxContainer input:checked ~ span:after {
|
||||||
|
display: block;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
</style>
|
23
frontend-new/src/components/ui/InputTextarea.vue
Normal file
23
frontend-new/src/components/ui/InputTextarea.vue
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void;
|
||||||
|
}>();
|
||||||
|
const value = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit("update:modelValue", v),
|
||||||
|
});
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
label?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- todo make fancy -->
|
||||||
|
<label class="block">
|
||||||
|
<span v-if="label" class="block">{{ label }}</span>
|
||||||
|
<textarea v-model="value" class="ml-2" v-bind="$attrs" />
|
||||||
|
</label>
|
||||||
|
</template>
|
@ -1,5 +1,32 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ProjectApproval } from "hangar-internal";
|
||||||
|
import { useInternalApi } from "~/composables/useApi";
|
||||||
|
import { handleRequestError } from "~/composables/useErrorHandling";
|
||||||
|
import { useContext } from "vite-ssr/vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import Card from "~/components/design/Card.vue";
|
||||||
|
import AdminProjectList from "~/components/projects/AdminProjectList.vue";
|
||||||
|
|
||||||
|
const ctx = useContext();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const data = await useInternalApi<{ needsApproval: ProjectApproval[]; waitingProjects: ProjectApproval[] }>("admin/approval/projects").catch((e) =>
|
||||||
|
handleRequestError(e, ctx, i18n)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>project approvals</h1>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<template #header>{{ i18n.t("projectApproval.awaitingChanges") }}</template>
|
||||||
|
|
||||||
|
<AdminProjectList :projects="data.waitingProjects" />
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<template #header>{{ i18n.t("projectApproval.needsApproval") }}</template>
|
||||||
|
|
||||||
|
<AdminProjectList :projects="data.needsApproval" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<route lang="yaml">
|
<route lang="yaml">
|
||||||
|
@ -12,6 +12,7 @@ import PageTitle from "~/components/design/PageTitle.vue";
|
|||||||
import Card from "~/components/design/Card.vue";
|
import Card from "~/components/design/Card.vue";
|
||||||
import Link from "~/components/design/Link.vue";
|
import Link from "~/components/design/Link.vue";
|
||||||
import Button from "~/components/design/Button.vue";
|
import Button from "~/components/design/Button.vue";
|
||||||
|
import VisibilityChangerModal from "~/components/modals/VisibilityChangerModal.vue";
|
||||||
|
|
||||||
const ctx = useContext();
|
const ctx = useContext();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
@ -61,7 +62,7 @@ function resolve(flag: Flag) {
|
|||||||
</div>
|
</div>
|
||||||
<Link fix-href="$util.forumUrl(flag.reportedByName)">{{ i18n.t("flagReview.msgUser") }}</Link>
|
<Link fix-href="$util.forumUrl(flag.reportedByName)">{{ i18n.t("flagReview.msgUser") }}</Link>
|
||||||
<Link fix-href="$util.forumUrl(flag.projectNamespace.owner)">{{ i18n.t("flagReview.msgProjectOwner") }}</Link>
|
<Link fix-href="$util.forumUrl(flag.projectNamespace.owner)">{{ i18n.t("flagReview.msgProjectOwner") }}</Link>
|
||||||
<!-- todo modal for visibility change -->
|
<VisibilityChangerModal :prop-visibility="flag.projectVisibility" type="project" :post-url="`projects/visibility/${flag.projectId}`" />
|
||||||
<Button :disabled="loading[flag.id]" @click="resolve(flag)">{{ i18n.t("flagReview.markResolved") }}</Button>
|
<Button :disabled="loading[flag.id]" @click="resolve(flag)">{{ i18n.t("flagReview.markResolved") }}</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
import { IPlatform, IProjectCategory, IPrompt } from "hangar-internal";
|
import { IPlatform, IProjectCategory, IPrompt, IVisibility } from "hangar-internal";
|
||||||
import { NamedPermission, Platform, ProjectCategory, Prompt } from "~/types/enums";
|
import { NamedPermission, Platform, ProjectCategory, Prompt } from "~/types/enums";
|
||||||
|
|
||||||
import { Announcement as AnnouncementObject, Announcement, IPermission } from "hangar-api";
|
import { Announcement as AnnouncementObject, Announcement, IPermission } from "hangar-api";
|
||||||
@ -38,6 +38,7 @@ export const useBackendDataStore = defineStore("backendData", () => {
|
|||||||
const validations = ref<Validations | null>(null);
|
const validations = ref<Validations | null>(null);
|
||||||
const prompts = ref<Map<Prompt, IPrompt> | null>(null);
|
const prompts = ref<Map<Prompt, IPrompt> | null>(null);
|
||||||
const announcements = ref<Announcement[]>([]);
|
const announcements = ref<Announcement[]>([]);
|
||||||
|
const visibilities = ref<IVisibility[]>([]);
|
||||||
const licenses = ref<string[]>([]);
|
const licenses = ref<string[]>([]);
|
||||||
|
|
||||||
async function initBackendData() {
|
async function initBackendData() {
|
||||||
@ -74,6 +75,8 @@ export const useBackendDataStore = defineStore("backendData", () => {
|
|||||||
await fetchIfNeeded(async () => await useInternalApi<string[]>("data/licenses", false), licenses);
|
await fetchIfNeeded(async () => await useInternalApi<string[]>("data/licenses", false), licenses);
|
||||||
|
|
||||||
await fetchIfNeeded(async () => await useInternalApi<AnnouncementObject[]>("data/announcements", false), announcements);
|
await fetchIfNeeded(async () => await useInternalApi<AnnouncementObject[]>("data/announcements", false), announcements);
|
||||||
|
|
||||||
|
await fetchIfNeeded(async () => await useInternalApi<IVisibility[]>("data/visibilities", false), visibilities);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("ERROR FETCHING BACKEND DATA");
|
console.error("ERROR FETCHING BACKEND DATA");
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@ -91,6 +94,7 @@ export const useBackendDataStore = defineStore("backendData", () => {
|
|||||||
prompts,
|
prompts,
|
||||||
licenses,
|
licenses,
|
||||||
announcements,
|
announcements,
|
||||||
|
visibilities,
|
||||||
initBackendData,
|
initBackendData,
|
||||||
visibleCategories,
|
visibleCategories,
|
||||||
visiblePlatforms,
|
visiblePlatforms,
|
||||||
|
2
frontend-new/src/types/generated/icons.d.ts
vendored
2
frontend-new/src/types/generated/icons.d.ts
vendored
@ -6,7 +6,9 @@ declare module "vue" {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
IconMdiClipboardOutline: typeof import("~icons/mdi/clipboard-outline")["default"];
|
IconMdiClipboardOutline: typeof import("~icons/mdi/clipboard-outline")["default"];
|
||||||
IconMdiCloseCircle: typeof import("~icons/mdi/close-circle")["default"];
|
IconMdiCloseCircle: typeof import("~icons/mdi/close-circle")["default"];
|
||||||
|
IconMdiEye: typeof import("~icons/mdi/eye")["default"];
|
||||||
IconMdiKeyOutline: typeof import("~icons/mdi/key-outline")["default"];
|
IconMdiKeyOutline: typeof import("~icons/mdi/key-outline")["default"];
|
||||||
|
IconMdiListStatus: typeof import("~icons/mdi/list-status")["default"];
|
||||||
IconMdiMenu: typeof import("~icons/mdi/menu")["default"];
|
IconMdiMenu: typeof import("~icons/mdi/menu")["default"];
|
||||||
IconMdiOpenInNew: typeof import("~icons/mdi/open-in-new")["default"];
|
IconMdiOpenInNew: typeof import("~icons/mdi/open-in-new")["default"];
|
||||||
IconMdiSortVariant: typeof import("~icons/mdi/sort-variant")["default"];
|
IconMdiSortVariant: typeof import("~icons/mdi/sort-variant")["default"];
|
||||||
|
Loading…
Reference in New Issue
Block a user