mirror of
https://github.com/HangarMC/Hangar.git
synced 2024-11-21 01:21:54 +08:00
new project page is functional now!
This commit is contained in:
parent
9768c730e5
commit
74d0394d46
@ -68,7 +68,7 @@ once QA has passed, the checkboxes can be removed and the page can be ~~striked
|
||||
- new
|
||||
- [x] fetch
|
||||
- [x] layout
|
||||
- [ ] functionality
|
||||
- [x] functionality
|
||||
- [ ] design
|
||||
- [ ] qa
|
||||
- notifications
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import Link from "~/components/design/Link.vue";
|
||||
import Card from "~/components/design/Card.vue";
|
||||
import { useSettingsStore } from "~/store/settings";
|
||||
@ -17,23 +17,48 @@ const internalValue = computed({
|
||||
set: (value) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const activeStep = computed(() => props.steps.find((s) => s.value === internalValue.value));
|
||||
const activeStepIndex = computed(() => props.steps.indexOf(activeStep.value as Step) + 1);
|
||||
|
||||
export interface Step {
|
||||
value: string;
|
||||
header: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
steps: Step[];
|
||||
buttonLangKey: string;
|
||||
}>();
|
||||
|
||||
const activeStep = computed(() => props.steps.find((s) => s.value === internalValue.value));
|
||||
const activeStepIndex = computed(() => props.steps.indexOf(activeStep.value as Step) + 1);
|
||||
|
||||
const canBack = ref(true);
|
||||
const canNext = ref(true);
|
||||
const showBack = ref(true);
|
||||
const showNext = ref(true);
|
||||
|
||||
function back() {
|
||||
if (canBack.value) {
|
||||
internalValue.value = props.steps[activeStepIndex.value - 2].value;
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (canNext.value) {
|
||||
internalValue.value = props.steps[activeStepIndex.value].value;
|
||||
}
|
||||
}
|
||||
|
||||
function goto(step: Step) {
|
||||
const idx = props.steps.indexOf(step);
|
||||
if (idx >= activeStepIndex.value && canNext.value) {
|
||||
next();
|
||||
} else if (idx < activeStepIndex.value && canBack.value) {
|
||||
back();
|
||||
}
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
value: string;
|
||||
header: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- todo stepper logic to prevent stepping thru out of order -->
|
||||
<!-- todo style active, next and past step headers differently -->
|
||||
<div>
|
||||
<div class="w-full">
|
||||
@ -47,7 +72,7 @@ const props = defineProps<{
|
||||
:class="internalValue === step.value ? 'underline' : '!font-semibold'"
|
||||
:href="'#' + step.value"
|
||||
display="hidden md:inline"
|
||||
@click.prevent="internalValue = step.value"
|
||||
@click.prevent="goto(step)"
|
||||
>
|
||||
{{ step.header }}
|
||||
</Link>
|
||||
@ -64,9 +89,10 @@ const props = defineProps<{
|
||||
<div v-for="step in steps" :key="step.value">
|
||||
<slot v-if="internalValue === step.value" :name="step.value" />
|
||||
</div>
|
||||
<!-- todo next/back button -->
|
||||
<Button size="medium" class="mt-2 mr-2">{{ i18n.t(buttonLangKey + activeStepIndex + ".continue") }}</Button>
|
||||
<Button size="medium" class="mt-2">{{ i18n.t(buttonLangKey + activeStepIndex + ".back") }}</Button>
|
||||
<Button v-if="showBack" :disable="canBack" size="medium" class="mt-2 mr-2" @click="back">{{
|
||||
i18n.t(buttonLangKey + activeStepIndex + ".back")
|
||||
}}</Button>
|
||||
<Button v-if="showNext" :disable="canNext" size="medium" class="mt-2" @click="next">{{ i18n.t(buttonLangKey + activeStepIndex + ".continue") }}</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,13 +15,24 @@ export interface Option {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: object | string | boolean | number | null | undefined;
|
||||
values: Option[];
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
errorMessages?: string[];
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: object | string | boolean | number | null;
|
||||
values: Option[] | Record<string, any> | string[];
|
||||
itemValue?: string;
|
||||
itemText?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
errorMessages?: string[];
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
itemValue: "value",
|
||||
itemText: "text",
|
||||
label: "",
|
||||
errorMessages: () => [],
|
||||
}
|
||||
);
|
||||
|
||||
// TODO proper validation
|
||||
const error = computed<boolean>(() => {
|
||||
@ -29,12 +40,11 @@ const error = computed<boolean>(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- todo make fancy -->
|
||||
<template>
|
||||
<label class="relative flex" :class="{ filled: modelValue, error: error }">
|
||||
<label class="relative flex" :class="{ filled: internalVal, error: error }">
|
||||
<select v-model="internalVal" :disabled="disabled" :class="inputClasses">
|
||||
<option v-for="val in values" :key="val.value" :value="val.value" class="dark:bg-[#191e28]">
|
||||
{{ val.text }}
|
||||
<option v-for="val in values" :key="val[itemValue] || val" :value="val[itemValue] || val" class="dark:bg-[#191e28]">
|
||||
{{ val[itemText] || val }}
|
||||
</option>
|
||||
</select>
|
||||
<floating-label :label="label" />
|
||||
|
@ -16,7 +16,6 @@ const props = defineProps<{
|
||||
errorMessages?: string[];
|
||||
counter?: boolean;
|
||||
maxlength?: number;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
// TODO proper validation
|
||||
|
@ -60,16 +60,6 @@ const loading = reactive({
|
||||
});
|
||||
|
||||
const isCustomLicense = computed(() => form.settings.license.type === "(custom)");
|
||||
const licenses = computed<Option[]>(() =>
|
||||
backendData.licenses.map<Option>((l) => {
|
||||
return { value: l, text: l };
|
||||
})
|
||||
);
|
||||
const categories = computed<Option[]>(() =>
|
||||
backendData.visibleCategories.map<Option>((c) => {
|
||||
return { value: c.apiName, text: i18n.t(c.title) };
|
||||
})
|
||||
);
|
||||
|
||||
watch(route, (val) => (selectedTab.value = val.hash.replace("#", "")), { deep: true });
|
||||
watch(selectedTab, (val) => history.replaceState({}, "", route.path + "#" + val));
|
||||
@ -219,7 +209,7 @@ useHead(
|
||||
<Tabs v-model="selectedTab" :tabs="tabs">
|
||||
<template #general>
|
||||
<ProjectSettingsSection title="project.settings.category" description="project.settings.categorySub">
|
||||
<InputSelect v-model="form.category" :values="categories" />
|
||||
<InputSelect v-model="form.category" :values="backendData.categoryOptions" />
|
||||
</ProjectSettingsSection>
|
||||
<ProjectSettingsSection title="project.settings.description" description="project.settings.descriptionSub">
|
||||
<InputText v-model="form.description" counter :maxlength="backendData.validations?.project?.desc?.max" />
|
||||
@ -280,7 +270,7 @@ useHead(
|
||||
<ProjectSettingsSection title="project.settings.license" description="project.settings.licenseSub">
|
||||
<div class="flex">
|
||||
<div class="basis-full" :md="isCustomLicense ? 'basis-4/12' : 'basis-6/12'">
|
||||
<InputSelect v-model="form.settings.license.type" :values="licenses" :label="i18n.t('project.settings.licenseType')" />
|
||||
<InputSelect v-model="form.settings.license.type" :values="backendData.licenseOptions" :label="i18n.t('project.settings.licenseType')" />
|
||||
</div>
|
||||
<div v-if="isCustomLicense" class="basis-full md:basis-8/12">
|
||||
<InputText v-model.trim="form.settings.license.name" :label="i18n.t('project.settings.licenseCustom')" />
|
||||
|
@ -2,17 +2,15 @@
|
||||
import { ProjectOwner, ProjectSettingsForm } from "hangar-internal";
|
||||
import { ProjectCategory } from "~/types/enums";
|
||||
import { handleRequestError } from "~/composables/useErrorHandling";
|
||||
import { computed, Ref, ref, watch } from "vue";
|
||||
import { computed, reactive, Ref, ref, watch } from "vue";
|
||||
import { useInternalApi } from "~/composables/useApi";
|
||||
import { useContext } from "vite-ssr/vue";
|
||||
import PageTitle from "~/components/design/PageTitle.vue";
|
||||
import { useBackendDataStore } from "~/store/backendData";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useSeo } from "~/composables/useSeo";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import Steps, { Step } from "~/components/design/Steps.vue";
|
||||
import Card from "~/components/design/Card.vue";
|
||||
import { useSettingsStore } from "~/store/settings";
|
||||
import InputSelect from "~/components/ui/InputSelect.vue";
|
||||
import InputText from "~/components/ui/InputText.vue";
|
||||
@ -30,36 +28,34 @@ interface NewProjectForm extends ProjectSettingsForm {
|
||||
|
||||
const ctx = useContext();
|
||||
const i18n = useI18n();
|
||||
const store = useBackendDataStore();
|
||||
const backendData = useBackendDataStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const settings = useSettingsStore();
|
||||
const visibleCategories = store.visibleCategories;
|
||||
const licenses = store.licenses;
|
||||
|
||||
// TODO move to useApi
|
||||
const projectOwners = await useInternalApi<ProjectOwner[]>("projects/possibleOwners");
|
||||
const error = ref("");
|
||||
const nameErrors: Ref<string[]> = ref([]);
|
||||
const projectCreationErrors: Ref<string[]> = ref([]);
|
||||
const form: NewProjectForm = {
|
||||
const projectLoading = ref(true);
|
||||
const form = ref<NewProjectForm>({
|
||||
category: ProjectCategory.ADMIN_TOOLS,
|
||||
settings: {
|
||||
license: {} as ProjectSettingsForm["settings"]["license"],
|
||||
donation: {} as ProjectSettingsForm["settings"]["donation"],
|
||||
keywords: [],
|
||||
} as unknown as ProjectSettingsForm["settings"],
|
||||
} as NewProjectForm;
|
||||
const projectName = ref(form.name);
|
||||
} as NewProjectForm);
|
||||
|
||||
form.ownerId = projectOwners[0].userId;
|
||||
form.value.ownerId = projectOwners[0].userId;
|
||||
|
||||
const converter = {
|
||||
const converter = ref({
|
||||
bbCode: "",
|
||||
markdown: "",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
|
||||
const isCustomLicense = computed(() => form.settings.license.type === "(custom)");
|
||||
const isCustomLicense = computed(() => form.value.settings.license.type === "(custom)");
|
||||
|
||||
const selectedStep = ref("tos");
|
||||
const steps: Step[] = [
|
||||
@ -82,16 +78,16 @@ const bbCodeTabs: Tab[] = [
|
||||
useHead(useSeo("New Project", null, route, null));
|
||||
|
||||
function convertBBCode() {
|
||||
converter.loading = true;
|
||||
converter.value.loading = true;
|
||||
useInternalApi<string>("pages/convert-bbcode", false, "post", {
|
||||
content: converter.bbCode,
|
||||
content: converter.value.bbCode,
|
||||
})
|
||||
.then((markdown) => {
|
||||
converter.markdown = markdown;
|
||||
converter.value.markdown = markdown;
|
||||
})
|
||||
.catch((e) => handleRequestError(e, ctx, i18n))
|
||||
.finally(() => {
|
||||
converter.loading = false;
|
||||
converter.value.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@ -113,26 +109,38 @@ function createProject() {
|
||||
});
|
||||
}
|
||||
|
||||
const projectName = computed(() => form.value.name);
|
||||
watch(projectName, (newName) => {
|
||||
nameErrors.value = [];
|
||||
if (!newName) {
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
useInternalApi("projects/validateName", false, "get", {
|
||||
userId: form.ownerId,
|
||||
userId: form.value.ownerId,
|
||||
value: newName,
|
||||
})
|
||||
.then(() => {
|
||||
error.value = "";
|
||||
form.name = newName;
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!e.response?.data.isHangarApiException) {
|
||||
return handleRequestError(e, ctx, i18n);
|
||||
}
|
||||
error.value = i18n.t(e.response?.data.message);
|
||||
});
|
||||
}).catch((e) => {
|
||||
if (!e.response?.data.isHangarApiException) {
|
||||
return handleRequestError(e, ctx, i18n);
|
||||
}
|
||||
nameErrors.value.push(i18n.t(e.response?.data.message));
|
||||
});
|
||||
});
|
||||
|
||||
function retry() {
|
||||
if (!form.value.pageContent) {
|
||||
form.value.pageContent = "# " + form.value.name + " \nWelcome to your new project!";
|
||||
}
|
||||
useInternalApi<string>("projects/create", true, "post", form.value)
|
||||
.then((url) => {
|
||||
router.push(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
projectCreationErrors.value.push(err.response.data);
|
||||
})
|
||||
.finally(() => {
|
||||
projectLoading.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- todo: rules, icon -->
|
||||
@ -145,14 +153,14 @@ watch(projectName, (newName) => {
|
||||
<template #basic>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="basis-full md:basis-6/12">
|
||||
<InputSelect v-model="form.ownerId" :values="projectOwners" :label="i18n.t('project.new.step2.userSelect')" />
|
||||
<InputSelect v-model="form.ownerId" :values="projectOwners" item-value="id" item-text="name" :label="i18n.t('project.new.step2.userSelect')" />
|
||||
</div>
|
||||
<div class="basis-full md:basis-6/12">
|
||||
<InputText v-model.trim="form.name" :error-messages="nameErrors" :label="i18n.t('project.new.step2.projectName')" />
|
||||
</div>
|
||||
<div class="basis-full md:basis-8/12"><InputText v-model.trim="form.description" :label="i18n.t('project.new.step2.projectSummary')" /></div>
|
||||
<div class="basis-full md:basis-4/12">
|
||||
<InputSelect v-model="form.category" :values="visibleCategories" :label="i18n.t('project.new.step2.projectCategory')" />
|
||||
<InputSelect v-model="form.category" :values="backendData.categoryOptions" :label="i18n.t('project.new.step2.projectCategory')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -175,7 +183,7 @@ watch(projectName, (newName) => {
|
||||
</div>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="basis-full" :md="isCustomLicense ? 'basis-4/12' : 'basis-6/12'">
|
||||
<InputSelect v-model="form.settings.license.type" :values="licenses" :label="i18n.t('project.new.step3.type')" />
|
||||
<InputSelect v-model="form.settings.license.type" :values="backendData.licenseOptions" :label="i18n.t('project.new.step3.type')" />
|
||||
</div>
|
||||
<div v-if="isCustomLicense" class="basis-full md:basis-8/12">
|
||||
<InputText v-model.trim="form.settings.license.name" :label="i18n.t('project.new.step3.customName')" />
|
||||
@ -207,7 +215,7 @@ watch(projectName, (newName) => {
|
||||
<InputTextarea v-model="converter.markdown" :rows="6" :label="i18n.t('project.new.step4.convertLabels.output')" />
|
||||
</template>
|
||||
<template #preview>
|
||||
<Button block color="primary" class="my-2" :disabled="form.pageContent === converter.markdown" @click="saveAsHomePage">
|
||||
<Button block color="primary" class="my-2" :disabled="form.pageContent === converter.markdown" @click="form.pageContent = converter.markdown">
|
||||
<IconMdiContentSave />
|
||||
{{ i18n.t("project.new.step4.saveAsHomePage") }}
|
||||
</Button>
|
||||
@ -226,16 +234,20 @@ watch(projectName, (newName) => {
|
||||
<template #finishing>
|
||||
<!-- todo loader -->
|
||||
<!--<v-progress-circular v-if="projectLoading" indeterminate color="red" size="50" />-->
|
||||
<span v-if="projectLoading">Loading....</span>
|
||||
<div v-if="!projectError" class="text-h5 mt-2">
|
||||
{{ i18n.t("project.new.step5.text") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<span v-if="projectLoading">
|
||||
Loading....
|
||||
<Button @click="retry">button go brrrr</Button>
|
||||
</span>
|
||||
<template v-else-if="projectCreationErrors && projectCreationErrors.length > 0">
|
||||
<div class="text-lg mt-2">
|
||||
{{ i18n.t("project.new.error.create") }}
|
||||
{{ projectCreationErrors }}
|
||||
</div>
|
||||
<Button @click="retry"> Retry </Button>
|
||||
</template>
|
||||
<div v-else class="text-h5 mt-2">
|
||||
{{ i18n.t("project.new.step5.text") }}
|
||||
</div>
|
||||
</template>
|
||||
</Steps>
|
||||
</template>
|
||||
|
@ -6,6 +6,8 @@ import { NamedPermission, Platform, ProjectCategory, Prompt } from "~/types/enum
|
||||
|
||||
import { Announcement as AnnouncementObject, Announcement, IPermission, Role } from "hangar-api";
|
||||
import { fetchIfNeeded, useInternalApi } from "~/composables/useApi";
|
||||
import { Option } from "~/components/ui/InputSelect.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
interface Validation {
|
||||
regex?: string;
|
||||
@ -97,6 +99,11 @@ export const useBackendDataStore = defineStore("backendData", () => {
|
||||
const visibleCategories = computed(() => [...(projectCategories.value?.values() || [])].filter((value) => value.visible));
|
||||
const visiblePlatforms = computed(() => (platforms.value ? [...platforms.value.values()].filter((value) => value.visible) : []));
|
||||
|
||||
const licenseOptions = computed<Option[]>(() => licenses.value.map<Option>((l) => ({ value: l, text: l })));
|
||||
const categoryOptions = computed<Option[]>(() =>
|
||||
visibleCategories.value.map<Option>((c) => ({ value: c.apiName, text: useI18n({ useScope: "global" }).t(c.title) }))
|
||||
);
|
||||
|
||||
return {
|
||||
projectCategories,
|
||||
permissions,
|
||||
@ -112,6 +119,8 @@ export const useBackendDataStore = defineStore("backendData", () => {
|
||||
initBackendData,
|
||||
visibleCategories,
|
||||
visiblePlatforms,
|
||||
licenseOptions,
|
||||
categoryOptions,
|
||||
};
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user