forked from potsda.mn/mobilizon
Add an error component
Shows when a rendering error has been triggered, like the one in 5edc402a01
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
5edc402a01
commit
3d2fafc254
|
@ -20,7 +20,9 @@
|
||||||
</p>
|
</p>
|
||||||
</b-message>
|
</b-message>
|
||||||
</div>
|
</div>
|
||||||
<main>
|
<error v-if="error" :error="error" />
|
||||||
|
|
||||||
|
<main v-else>
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
<router-view />
|
<router-view />
|
||||||
</transition>
|
</transition>
|
||||||
|
@ -57,6 +59,8 @@ import { ICurrentUser } from "./types/current-user.model";
|
||||||
components: {
|
components: {
|
||||||
Logo,
|
Logo,
|
||||||
NavBar,
|
NavBar,
|
||||||
|
error: () =>
|
||||||
|
import(/* webpackChunkName: "editor" */ "./components/Error.vue"),
|
||||||
"mobilizon-footer": Footer,
|
"mobilizon-footer": Footer,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -65,12 +69,18 @@ export default class App extends Vue {
|
||||||
|
|
||||||
currentUser!: ICurrentUser;
|
currentUser!: ICurrentUser;
|
||||||
|
|
||||||
|
error: Error | null = null;
|
||||||
|
|
||||||
async created(): Promise<void> {
|
async created(): Promise<void> {
|
||||||
if (await this.initializeCurrentUser()) {
|
if (await this.initializeCurrentUser()) {
|
||||||
await initializeCurrentActor(this.$apollo.provider.defaultClient);
|
await initializeCurrentActor(this.$apollo.provider.defaultClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorCaptured(error: Error): void {
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
private async initializeCurrentUser() {
|
private async initializeCurrentUser() {
|
||||||
const userId = localStorage.getItem(AUTH_USER_ID);
|
const userId = localStorage.getItem(AUTH_USER_ID);
|
||||||
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
|
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
|
||||||
|
|
52
js/src/components/About/InstanceContactLink.vue
Normal file
52
js/src/components/About/InstanceContactLink.vue
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<p>
|
||||||
|
<a :title="contact" v-if="configLink" :href="configLink.uri">{{
|
||||||
|
configLink.text
|
||||||
|
}}</a>
|
||||||
|
<span v-else-if="contact">{{ contact }}</span>
|
||||||
|
<span v-else>{{ $t("contact uninformed") }}</span>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class InstanceContactLink extends Vue {
|
||||||
|
@Prop({ required: true, type: String }) contact!: string;
|
||||||
|
|
||||||
|
get configLink(): { uri: string; text: string } | null {
|
||||||
|
if (!this.contact) return null;
|
||||||
|
if (this.isContactEmail) {
|
||||||
|
return {
|
||||||
|
uri: `mailto:${this.contact}`,
|
||||||
|
text: this.contact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this.isContactURL) {
|
||||||
|
return {
|
||||||
|
uri: this.contact,
|
||||||
|
text:
|
||||||
|
InstanceContactLink.urlToHostname(this.contact) ||
|
||||||
|
(this.$t("Contact") as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isContactEmail(): boolean {
|
||||||
|
return this.contact.includes("@");
|
||||||
|
}
|
||||||
|
|
||||||
|
get isContactURL(): boolean {
|
||||||
|
return this.contact.match(/^https?:\/\//g) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static urlToHostname(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
219
js/src/components/Error.vue
Normal file
219
js/src/components/Error.vue
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
<template>
|
||||||
|
<div class="container section" id="error-wrapper">
|
||||||
|
<div class="column">
|
||||||
|
<section>
|
||||||
|
<div class="picture-wrapper">
|
||||||
|
<picture>
|
||||||
|
<source
|
||||||
|
srcset="
|
||||||
|
/img/pics/error-480w.webp 1x,
|
||||||
|
/img/pics/error-1024w.webp 2x
|
||||||
|
"
|
||||||
|
type="image/webp"
|
||||||
|
/>
|
||||||
|
<source
|
||||||
|
srcset="/img/pics/error-480w.jpg 1x, /img/pics/error-1024w.jpg 2x"
|
||||||
|
type="image/jpeg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="`/img/pics/error-480w.jpg`"
|
||||||
|
alt=""
|
||||||
|
width="480"
|
||||||
|
height="312"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
<b-message type="is-danger" class="is-size-5">
|
||||||
|
<h1>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"An error has occured. Sorry about that. You may try to reload the page."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</h1>
|
||||||
|
</b-message>
|
||||||
|
</section>
|
||||||
|
<b-loading v-if="$apollo.loading" :active.sync="$apollo.loading" />
|
||||||
|
<section v-else>
|
||||||
|
<h2 class="is-size-5">{{ $t("What can I do to help?") }}</h2>
|
||||||
|
<p class="content">
|
||||||
|
<i18n
|
||||||
|
tag="span"
|
||||||
|
path="{instanceName} is an instance of {mobilizon_link}, a free software built with the community."
|
||||||
|
>
|
||||||
|
<b slot="instanceName">{{ config.name }}</b>
|
||||||
|
<a slot="mobilizon_link" href="https://joinmobilizon.org">{{
|
||||||
|
$t("Mobilizon")
|
||||||
|
}}</a>
|
||||||
|
</i18n>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="content">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://framacolibri.org/c/mobilizon/39"
|
||||||
|
target="_blank"
|
||||||
|
>{{ $t("Open a topic on our forum") }}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://framagit.org/framasoft/mobilizon/-/issues/new?issuable_template=Bug"
|
||||||
|
target="_blank"
|
||||||
|
>{{
|
||||||
|
$t("Open an issue on our bug tracker (advanced users)")
|
||||||
|
}}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="content">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"Please add as many details as possible to help identify the problem."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary class="is-size-5">{{ $t("Technical details") }}</summary>
|
||||||
|
<p>{{ $t("Error message") }}</p>
|
||||||
|
<pre>{{ error }}</pre>
|
||||||
|
<p>{{ $t("Error stacktrace") }}</p>
|
||||||
|
<pre>{{ error.stack }}</pre>
|
||||||
|
</details>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="buttons">
|
||||||
|
<b-tooltip
|
||||||
|
:label="tooltipConfig.label"
|
||||||
|
:type="tooltipConfig.type"
|
||||||
|
:active="copied !== false"
|
||||||
|
always
|
||||||
|
>
|
||||||
|
<b-button @click="copyErrorToClipboard">{{
|
||||||
|
$t("Copy details to clipboard")
|
||||||
|
}}</b-button>
|
||||||
|
</b-tooltip>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { CONTACT } from "@/graphql/config";
|
||||||
|
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||||
|
import InstanceContactLink from "@/components/About/InstanceContactLink.vue";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
apollo: {
|
||||||
|
config: {
|
||||||
|
query: CONTACT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metaInfo() {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
title: this.$t("Error") as string,
|
||||||
|
titleTemplate: "%s | Mobilizon",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
InstanceContactLink,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class ErrorComponent extends Vue {
|
||||||
|
@Prop({ required: true, type: Error }) error!: Error;
|
||||||
|
|
||||||
|
copied: "success" | "error" | false = false;
|
||||||
|
|
||||||
|
config!: { contact: string | null; name: string };
|
||||||
|
|
||||||
|
async copyErrorToClipboard(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (window.isSecureContext && navigator.clipboard) {
|
||||||
|
await navigator.clipboard.writeText(this.fullErrorString);
|
||||||
|
} else {
|
||||||
|
this.fallbackCopyTextToClipboard(this.fullErrorString);
|
||||||
|
}
|
||||||
|
this.copied = "success";
|
||||||
|
setTimeout(() => {
|
||||||
|
this.copied = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
this.copied = "error";
|
||||||
|
console.error("Unable to copy to clipboard");
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get fullErrorString(): string {
|
||||||
|
return `${this.error.name}: ${this.error.message}\n\n${this.error.stack}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get tooltipConfig(): { label: string | null; type: string | null } {
|
||||||
|
if (this.copied === "success")
|
||||||
|
return {
|
||||||
|
label: this.$t("Error details copied!") as string,
|
||||||
|
type: "is-success",
|
||||||
|
};
|
||||||
|
if (this.copied === "error")
|
||||||
|
return {
|
||||||
|
label: this.$t("Unable to copy to clipboard") as string,
|
||||||
|
type: "is-danger",
|
||||||
|
};
|
||||||
|
return { label: null, type: "is-primary" };
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackCopyTextToClipboard(text: string): void {
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
|
||||||
|
// Avoid scrolling to bottom
|
||||||
|
textArea.style.top = "0";
|
||||||
|
textArea.style.left = "0";
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
document.execCommand("copy");
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
#error-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
background: $white;
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture-wrapper {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
summary:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -112,6 +112,15 @@ export const ABOUT = gql`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const CONTACT = gql`
|
||||||
|
query Contact {
|
||||||
|
config {
|
||||||
|
name
|
||||||
|
contact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const RULES = gql`
|
export const RULES = gql`
|
||||||
query Rules {
|
query Rules {
|
||||||
config {
|
config {
|
||||||
|
|
|
@ -836,5 +836,19 @@
|
||||||
"No follower matches the filters": "No follower matches the filters",
|
"No follower matches the filters": "No follower matches the filters",
|
||||||
"@{username}'s follow request was rejected": "@{username}'s follow request was rejected",
|
"@{username}'s follow request was rejected": "@{username}'s follow request was rejected",
|
||||||
"Followers will receive new public events and posts.": "Followers will receive new public events and posts.",
|
"Followers will receive new public events and posts.": "Followers will receive new public events and posts.",
|
||||||
"Manually approve new followers": "Manually approve new followers"
|
"Manually approve new followers": "Manually approve new followers",
|
||||||
|
"An error has occured. Sorry about that. You may try to reload the page.": "An error has occured. Sorry about that. You may try to reload the page.",
|
||||||
|
"What can I do to help?": "What can I do to help?",
|
||||||
|
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):": "We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):'",
|
||||||
|
"Please add as many details as possible to help identify the problem.": "Please add as many details as possible to help identify the problem.",
|
||||||
|
"Technical details": "Technical details",
|
||||||
|
"Error message": "Error message",
|
||||||
|
"Error stacktrace": "Error stacktrace",
|
||||||
|
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback.": "The technical details of the error can help developers solve the problem more easily. Please add them to your feedback.",
|
||||||
|
"Error details copied!": "Error details copied!",
|
||||||
|
"Copy details to clipboard": "Copy details to clipboard",
|
||||||
|
"{instanceName} is an instance of {mobilizon_link}, a free software built with the community.": "{instanceName} is an instance of {mobilizon_link}, a free software built with the community.",
|
||||||
|
"Open a topic on our forum": "Open a topic on our forum",
|
||||||
|
"Open an issue on our bug tracker (advanced users)": "Open an issue on our bug tracker (advanced users)",
|
||||||
|
"Unable to copy to clipboard": "Unable to copy to clipboard"
|
||||||
}
|
}
|
||||||
|
|
|
@ -931,5 +931,19 @@
|
||||||
"No follower matches the filters": "Aucun⋅e abonné⋅e ne correspond aux filtres",
|
"No follower matches the filters": "Aucun⋅e abonné⋅e ne correspond aux filtres",
|
||||||
"@{username}'s follow request was rejected": "La demande de suivi de @{username} a été rejettée",
|
"@{username}'s follow request was rejected": "La demande de suivi de @{username} a été rejettée",
|
||||||
"Followers will receive new public events and posts.": "Les abonnée⋅s recevront les nouveaux événements et billets publics.",
|
"Followers will receive new public events and posts.": "Les abonnée⋅s recevront les nouveaux événements et billets publics.",
|
||||||
"Manually approve new followers": "Approuver les nouvelles demandes de suivi manuellement"
|
"Manually approve new followers": "Approuver les nouvelles demandes de suivi manuellement",
|
||||||
|
"An error has occured. Sorry about that. You may try to reload the page.": "Une erreur est survenue. Nous en sommes désolé⋅es. Vous pouvez essayer de rafraîchir la page.",
|
||||||
|
"What can I do to help?": "Que puis-je faire pour aider ?",
|
||||||
|
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):": "Nous améliorons ce logiciel grâce à vos retours. Pour nous avertir de ce problème, vous avez deux possibilités (les deux requièrent toutefois la création d'un compte) :",
|
||||||
|
"Please add as many details as possible to help identify the problem.": "Merci d'ajouter un maximum de détails afin d'aider à identifier le problème.",
|
||||||
|
"Technical details": "Détails techniques",
|
||||||
|
"Error message": "Message d'erreur",
|
||||||
|
"Error stacktrace": "Trace d'appels de l'erreur",
|
||||||
|
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback.": "Les détails techniques de l'erreur peuvent aider les développeur⋅ices à résoudre le problème plus facilement. Merci de les inclure dans vos retours.",
|
||||||
|
"Error details copied!": "Détails de l'erreur copiés !",
|
||||||
|
"Copy details to clipboard": "Copier les détails dans le presse-papiers",
|
||||||
|
"{instanceName} is an instance of {mobilizon_link}, a free software built with the community.": "{instanceName} est une instance de {mobilizon_link}, un logiciel libre construit de manière communautaire.",
|
||||||
|
"Open a topic on our forum": "Ouvrir un sujet sur notre forum",
|
||||||
|
"Open an issue on our bug tracker (advanced users)": "Ouvrir un ticket sur notre système de suivi des bugs (utilisateur⋅ices avancé⋅es)",
|
||||||
|
"Unable to copy to clipboard": "Impossible de copier dans le presse-papiers"
|
||||||
}
|
}
|
||||||
|
|
|
@ -157,5 +157,10 @@ const router = new Router({
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(authGuardIfNeeded);
|
router.beforeEach(authGuardIfNeeded);
|
||||||
|
router.afterEach(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
router.app.$children[0].error = null;
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -25,16 +25,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column contact">
|
<div class="column contact">
|
||||||
<h4>{{ $t("Contact") }}</h4>
|
<h4>{{ $t("Contact") }}</h4>
|
||||||
<p>
|
<instance-contact-link :contact="config.contact" />
|
||||||
<a
|
|
||||||
:title="config.contact"
|
|
||||||
v-if="generateConfigLink()"
|
|
||||||
:href="generateConfigLink().uri"
|
|
||||||
>{{ generateConfigLink().text }}</a
|
|
||||||
>
|
|
||||||
<span v-else-if="config.contact">{{ config.contact }}</span>
|
|
||||||
<span v-else>{{ $t("contact uninformed") }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<hr />
|
<hr />
|
||||||
|
@ -85,6 +76,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
import { formatList } from "@/utils/i18n";
|
import { formatList } from "@/utils/i18n";
|
||||||
|
import InstanceContactLink from "@/components/About/InstanceContactLink.vue";
|
||||||
import { LANGUAGES_CODES } from "@/graphql/admin";
|
import { LANGUAGES_CODES } from "@/graphql/admin";
|
||||||
import { ILanguage } from "@/types/admin.model";
|
import { ILanguage } from "@/types/admin.model";
|
||||||
import { ABOUT } from "../../graphql/config";
|
import { ABOUT } from "../../graphql/config";
|
||||||
|
@ -109,6 +101,9 @@ import langs from "../../i18n/langs.json";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
InstanceContactLink,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export default class AboutInstance extends Vue {
|
export default class AboutInstance extends Vue {
|
||||||
config!: IConfig;
|
config!: IConfig;
|
||||||
|
@ -117,14 +112,6 @@ export default class AboutInstance extends Vue {
|
||||||
|
|
||||||
languages!: ILanguage[];
|
languages!: ILanguage[];
|
||||||
|
|
||||||
get isContactEmail(): boolean {
|
|
||||||
return this.config && this.config.contact.includes("@");
|
|
||||||
}
|
|
||||||
|
|
||||||
get isContactURL(): boolean {
|
|
||||||
return this.config && this.config.contact.match(/^https?:\/\//g) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get formattedLanguageList(): string {
|
get formattedLanguageList(): string {
|
||||||
if (this.languages) {
|
if (this.languages) {
|
||||||
const list = this.languages.map(({ name }) => name);
|
const list = this.languages.map(({ name }) => name);
|
||||||
|
@ -138,33 +125,6 @@ export default class AboutInstance extends Vue {
|
||||||
const languageMaps = langs as Record<string, any>;
|
const languageMaps = langs as Record<string, any>;
|
||||||
return languageMaps[code];
|
return languageMaps[code];
|
||||||
}
|
}
|
||||||
|
|
||||||
generateConfigLink(): { uri: string; text: string } | null {
|
|
||||||
if (!this.config.contact) return null;
|
|
||||||
if (this.isContactEmail) {
|
|
||||||
return {
|
|
||||||
uri: `mailto:${this.config.contact}`,
|
|
||||||
text: this.config.contact,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (this.isContactURL) {
|
|
||||||
return {
|
|
||||||
uri: this.config.contact,
|
|
||||||
text:
|
|
||||||
AboutInstance.urlToHostname(this.config.contact) ||
|
|
||||||
(this.$t("Contact") as string),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static urlToHostname(url: string): string | null {
|
|
||||||
try {
|
|
||||||
return new URL(url).hostname;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<b-loading :active.sync="$apollo.loading" />
|
|
||||||
<transition appear name="fade" mode="out-in">
|
<transition appear name="fade" mode="out-in">
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|
Loading…
Reference in a new issue