mobilizon.chapril.org-mobil.../js/src/views/Resources/ResourceFolder.vue

860 lines
24 KiB
Vue

<template>
<div class="container section" v-if="resource">
<breadcrumbs-nav :links="breadcrumbLinks">
<li>
<b-dropdown aria-role="list">
<b-button class="button is-primary" slot="trigger">+</b-button>
<b-dropdown-item aria-role="listitem" @click="createFolderModal">
<b-icon icon="folder" />
{{ $t("New folder") }}
</b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="createLinkModal">
<b-icon icon="link" />
{{ $t("New link") }}
</b-dropdown-item>
<hr
role="presentation"
class="dropdown-divider"
v-if="resourceProviders.length"
/>
<b-dropdown-item
aria-role="listitem"
v-for="resourceProvider in resourceProviders"
:key="resourceProvider.software"
@click="createResourceFromProvider(resourceProvider)"
>
<b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
{{ createSentenceForType(resourceProvider.software) }}
</b-dropdown-item>
</b-dropdown>
</li>
</breadcrumbs-nav>
<section>
<p v-if="resource.path === '/'" class="module-description">
{{
$t("A place to store links to documents or resources of any type.")
}}
</p>
<div class="list-header">
<div class="list-header-right">
<b-checkbox v-model="checkedAll" v-if="resource.children.total > 0" />
<div class="actions" v-if="validCheckedResources.length > 0">
<small>
{{
$tc("No resources selected", validCheckedResources.length, {
count: validCheckedResources.length,
})
}}
</small>
<b-button
type="is-danger"
icon-right="delete"
size="is-small"
@click="deleteMultipleResources"
>{{ $t("Delete") }}</b-button
>
</div>
</div>
</div>
<draggable
v-model="resource.children.elements"
:sort="false"
:group="groupObject"
v-if="resource.children.total > 0"
>
<transition-group>
<div
v-for="localResource in resource.children.elements"
:key="localResource.id"
>
<div class="resource-item">
<div
class="resource-checkbox"
:class="{ checked: checkedResources[localResource.id] }"
>
<b-checkbox v-model="checkedResources[localResource.id]" />
</div>
<resource-item
:resource="localResource"
v-if="localResource.type !== 'folder'"
@delete="deleteResource"
@rename="handleRename"
@move="handleMove"
/>
<folder-item
:resource="localResource"
:group="resource.actor"
@delete="deleteResource"
@rename="handleRename"
@move="handleMove"
v-else
/>
</div>
</div>
</transition-group>
</draggable>
<div
class="content has-text-centered has-text-grey"
v-if="resource.children.total === 0"
>
<p>{{ $t("No resources in this folder") }}</p>
</div>
</section>
<b-pagination
v-if="resource.children.total > RESOURCES_PER_PAGE"
:total="resource.children.total"
v-model="page"
:per-page="RESOURCES_PER_PAGE"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
<b-modal
:active.sync="renameModal"
has-modal-card
:close-button-aria-label="$t('Close')"
>
<div class="modal-card">
<section class="modal-card-body">
<form @submit.prevent="renameResource">
<b-field :label="$t('Title')">
<b-input
ref="resourceRenameInput"
aria-required="true"
v-model="updatedResource.title"
/>
</b-field>
<b-button native-type="submit">{{
$t("Rename resource")
}}</b-button>
</form>
</section>
</div>
</b-modal>
<b-modal
:active.sync="moveModal"
has-modal-card
:close-button-aria-label="$t('Close')"
>
<div class="modal-card">
<section class="modal-card-body">
<resource-selector
:initialResource="updatedResource"
:username="usernameWithDomain(resource.actor)"
@update-resource="moveResource"
@close-move-modal="moveModal = false"
/>
</section>
</div>
</b-modal>
<b-modal
:active.sync="createResourceModal"
has-modal-card
:close-button-aria-label="$t('Close')"
trap-focus
>
<div class="modal-card">
<section class="modal-card-body">
<b-message type="is-danger" v-if="modalError">
{{ modalError }}
</b-message>
<form @submit.prevent="createResource">
<b-field :label="$t('Title')" label-for="new-resource-title">
<b-input
ref="modalNewResourceInput"
aria-required="true"
v-model="newResource.title"
id="new-resource-title"
/>
</b-field>
<b-button native-type="submit">{{
createResourceButtonLabel
}}</b-button>
</form>
</section>
</div>
</b-modal>
<b-modal
:active.sync="createLinkResourceModal"
has-modal-card
class="link-resource-modal"
aria-modal
:close-button-aria-label="$t('Close')"
trap-focus
>
<div class="modal-card">
<section class="modal-card-body">
<b-message type="is-danger" v-if="modalError">
{{ modalError }}
</b-message>
<form @submit.prevent="createResource">
<b-field :label="$t('URL')" label-for="new-resource-url">
<b-input
id="new-resource-url"
type="url"
required
v-model="newResource.resourceUrl"
@blur="previewResource"
ref="modalNewResourceLinkInput"
/>
</b-field>
<div class="new-resource-preview" v-if="newResource.title">
<resource-item :resource="newResource" :preview="true" />
</div>
<b-field :label="$t('Title')" label-for="new-resource-link-title">
<b-input
aria-required="true"
v-model="newResource.title"
id="new-resource-link-title"
/>
</b-field>
<b-field
:label="$t('Description')"
label-for="new-resource-summary"
>
<b-input
type="textarea"
v-model="newResource.summary"
id="new-resource-summary"
/>
</b-field>
<b-button native-type="submit">{{
$t("Create resource")
}}</b-button>
</form>
</section>
</div>
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
import Draggable from "vuedraggable";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import {
IResource,
mapServiceTypeToIcon,
IProvider,
} from "../../types/resource";
import {
CREATE_RESOURCE,
DELETE_RESOURCE,
PREVIEW_RESOURCE_LINK,
GET_RESOURCE,
UPDATE_RESOURCE,
} from "../../graphql/resources";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import ResourceMixin from "../../mixins/resource";
import ResourceSelector from "../../components/Resource/ResourceSelector.vue";
import {
ApolloCache,
FetchResult,
InternalRefetchQueriesInclude,
} from "@apollo/client/core";
import VueRouter from "vue-router";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
@Component({
components: { FolderItem, ResourceItem, Draggable, ResourceSelector },
apollo: {
resource: {
query: GET_RESOURCE,
fetchPolicy: "cache-and-network",
variables() {
let path = Array.isArray(this.$route.params.path)
? this.$route.params.path.join("/")
: this.$route.params.path || this.path;
path = path[0] !== "/" ? `/${path}` : path;
return {
path,
username: this.$route.params.preferredUsername,
page: this.page,
limit: this.RESOURCES_PER_PAGE,
};
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
config: CONFIG,
currentActor: CURRENT_ACTOR_CLIENT,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.isRoot
? (this.$t("Resources") as string)
: (this.$t("{folder} - Resources", {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
folder: this.lastFragment,
}) as string),
};
},
})
export default class Resources extends Mixins(ResourceMixin) {
@Prop({ required: true }) path!: string;
resource!: IResource;
config!: IConfig;
currentActor!: IActor;
RouteName = RouteName;
ResourceMixin = ResourceMixin;
usernameWithDomain = usernameWithDomain;
RESOURCES_PER_PAGE = 10;
newResource: IResource = {
title: "",
summary: "",
resourceUrl: "",
children: { elements: [], total: 0 },
metadata: {},
type: "link",
};
updatedResource: IResource = {
title: "",
resourceUrl: "",
metadata: {},
children: { elements: [], total: 0 },
path: undefined,
};
checkedResources: { [key: string]: boolean } = {};
validCheckedResources: string[] = [];
checkedAll = false;
createResourceModal = false;
createLinkResourceModal = false;
moveModal = false;
renameModal = false;
modalError = "";
groupObject: Record<string, unknown> = {
name: "resources",
pull: "clone",
put: true,
};
$refs!: {
resourceRenameInput: any;
modalNewResourceInput: HTMLElement;
modalNewResourceLinkInput: HTMLElement;
};
mapServiceTypeToIcon = mapServiceTypeToIcon;
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter({
page: page.toString(),
});
}
get actualPath(): string {
const path = Array.isArray(this.$route.params.path)
? this.$route.params.path.join("/")
: this.$route.params.path || this.path;
return path[0] !== "/" ? `/${path}` : path;
}
get filteredPath(): string[] {
if (this.resource && this.resource.path !== "/") {
return ResourceMixin.resourcePathArray(this.resource);
}
return [];
}
get isRoot(): boolean {
return this.actualPath === "/";
}
get lastFragment(): string | undefined {
return this.filteredPath.slice(-1)[0];
}
get resourceProviders(): IProvider[] {
return this.config?.resourceProviders || [];
}
async createResource(): Promise<void> {
if (!this.resource.actor) return;
this.modalError = "";
try {
await this.$apollo.mutate({
mutation: CREATE_RESOURCE,
variables: {
title: this.newResource.title,
summary: this.newResource.summary,
actorId: this.resource.actor.id,
resourceUrl: this.newResource.resourceUrl,
parentId:
this.resource.id && this.resource.id.startsWith("root_")
? null
: this.resource.id,
type: this.newResource.type,
},
refetchQueries: () => this.postRefreshQueries(),
});
this.createLinkResourceModal = false;
this.createResourceModal = false;
this.newResource.title = "";
this.newResource.summary = "";
this.newResource.resourceUrl = "";
} catch (err: any) {
console.error(err);
this.modalError = err.graphQLErrors[0].message;
}
}
async previewResource(): Promise<void> {
this.modalError = "";
try {
if (this.newResource.resourceUrl === "") return;
const { data } = await this.$apollo.mutate({
mutation: PREVIEW_RESOURCE_LINK,
variables: {
resourceUrl: this.newResource.resourceUrl,
},
});
this.newResource.title = data.previewResourceLink.title;
this.newResource.summary = data.previewResourceLink.description;
this.newResource.metadata = data.previewResourceLink;
this.newResource.type = "link";
} catch (err: any) {
console.error(err);
this.modalError = err.graphQLErrors[0].message;
}
}
createSentenceForType(type: string): string {
switch (type) {
case "folder":
return this.$t("Create a folder") as string;
case "pad":
return this.$t("Create a pad") as string;
case "calc":
return this.$t("Create a calc") as string;
case "visio":
return this.$t("Create a videoconference") as string;
default:
return "";
}
}
async createLinkModal(): Promise<void> {
this.createLinkResourceModal = true;
await this.$nextTick();
this.$refs.modalNewResourceLinkInput.focus();
}
async createFolderModal(): Promise<void> {
this.newResource.type = "folder";
this.createResourceModal = true;
await this.$nextTick();
this.$refs.modalNewResourceInput.focus();
}
async createResourceFromProvider(provider: IProvider): Promise<void> {
this.newResource.resourceUrl = Resources.generateFullResourceUrl(provider);
this.newResource.type = provider.software;
this.createResourceModal = true;
await this.$nextTick();
this.$refs.modalNewResourceInput.focus();
}
static generateFullResourceUrl(provider: IProvider): string {
const randomString = [...Array(10)]
.map(() => Math.random().toString(36)[3])
.join("")
.replace(/(.|$)/g, (c) =>
c[!Math.round(Math.random()) ? "toString" : "toLowerCase"]()
);
switch (provider.type) {
case "ethercalc":
case "etherpad":
case "jitsi":
default:
return `${provider.endpoint}${randomString}`;
}
}
get createResourceButtonLabel(): string {
if (!this.newResource.type) return "";
return this.createSentenceForType(this.newResource.type);
}
@Watch("checkedAll")
watchCheckedAll(): void {
this.resource.children.elements.forEach(({ id }) => {
if (!id) return;
this.checkedResources[id] = this.checkedAll;
});
}
@Watch("checkedResources", { deep: true })
watchValidCheckedResources(): string[] {
const validCheckedResources: string[] = [];
Object.entries(this.checkedResources).forEach(([key, value]) => {
if (value) {
validCheckedResources.push(key);
}
});
this.validCheckedResources = validCheckedResources;
return this.validCheckedResources;
}
async deleteMultipleResources(): Promise<void> {
this.validCheckedResources.forEach(async (resourceID) => {
await this.deleteResource(resourceID);
});
}
// eslint-disable-next-line class-methods-use-this
private postRefreshQueries(): InternalRefetchQueriesInclude {
return [
{
query: GET_RESOURCE,
variables: {
path: this.actualPath,
username: this.$route.params.preferredUsername,
page: this.page,
limit: this.RESOURCES_PER_PAGE,
},
},
];
}
async deleteResource(resourceID: string): Promise<void> {
try {
await this.$apollo.mutate({
mutation: DELETE_RESOURCE,
variables: {
id: resourceID,
},
refetchQueries: () => this.postRefreshQueries(),
});
this.validCheckedResources = this.validCheckedResources.filter(
(id) => id !== resourceID
);
delete this.checkedResources[resourceID];
} catch (e: any) {
console.error(e);
}
}
async handleRename(resource: IResource): Promise<void> {
this.renameModal = true;
this.updatedResource = { ...resource };
await this.$nextTick();
this.$refs.resourceRenameInput.focus();
this.$refs.resourceRenameInput.$el.querySelector("input").select();
}
handleMove(resource: IResource): void {
this.moveModal = true;
this.updatedResource = { ...resource };
}
async moveResource(
resource: IResource,
oldParent: IResource | undefined
): Promise<void> {
const parentPath =
oldParent && oldParent.path ? oldParent.path || "/" : "/";
await this.updateResource(resource, parentPath);
this.moveModal = false;
}
async renameResource(): Promise<void> {
await this.updateResource(this.updatedResource);
this.renameModal = false;
}
async updateResource(
resource: IResource,
parentPath: string | null = null
): Promise<void> {
try {
await this.$apollo.mutate<{ updateResource: IResource }>({
mutation: UPDATE_RESOURCE,
variables: {
id: resource.id,
title: resource.title,
parentId: resource.parent ? resource.parent.id : null,
path: resource.path,
},
refetchQueries: () => this.postRefreshQueries(),
update: (
store: ApolloCache<{ updateResource: IResource }>,
{ data }: FetchResult
) => {
if (!data || data.updateResource == null || parentPath == null)
return;
if (!this.resource.actor) return;
console.log("Removing ressource from old parent");
const oldParentCachedData = store.readQuery<{ resource: IResource }>({
query: GET_RESOURCE,
variables: {
path: parentPath,
username: this.resource.actor.preferredUsername,
},
});
if (oldParentCachedData == null) return;
const { resource: oldParentCachedResource } = oldParentCachedData;
if (oldParentCachedResource == null) {
console.error(
"Cannot update resource cache, because of null value."
);
return;
}
const updatedResource: IResource = data.updateResource;
const updatedElementList =
oldParentCachedResource.children.elements.filter(
(cachedResource) => cachedResource.id !== updatedResource.id
);
store.writeQuery({
query: GET_RESOURCE,
variables: {
path: parentPath,
username: this.resource.actor.preferredUsername,
},
data: {
resource: {
...oldParentCachedResource,
children: {
...oldParentCachedResource.children,
elements: [...updatedElementList],
},
},
},
});
console.log("Finished removing ressource from old parent");
console.log("Adding resource to new parent");
if (!updatedResource.parent || !updatedResource.parent.path) {
console.log("No cache found for new parent");
return;
}
const newParentCachedData = store.readQuery<{ resource: IResource }>({
query: GET_RESOURCE,
variables: {
path: updatedResource.parent.path,
username: this.resource.actor.preferredUsername,
},
});
if (newParentCachedData == null) return;
const { resource: newParentCachedResource } = newParentCachedData;
if (newParentCachedResource == null) {
console.error(
"Cannot update resource cache, because of null value."
);
return;
}
store.writeQuery({
query: GET_RESOURCE,
variables: {
path: updatedResource.parent.path,
username: this.resource.actor.preferredUsername,
},
data: {
resource: {
...newParentCachedResource,
children: {
...newParentCachedResource.children,
elements: [
...newParentCachedResource.children.elements,
resource,
],
},
},
},
});
console.log("Finished adding resource to new parent");
},
});
} catch (e: any) {
console.error(e);
}
}
@Watch("page")
loadMoreResources(): void {
this.$apollo.queries.resource.fetchMore({
// New variables
variables: {
page: this.page,
limit: this.RESOURCES_PER_PAGE,
},
});
}
handleErrors(errors: any[]): void {
if (errors.some((error) => error.status_code === 404)) {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}
async pushRouter(args: Record<string, string>): Promise<void> {
try {
const path = this.filteredPath.toString();
const routeName =
path === ""
? RouteName.RESOURCE_FOLDER_ROOT
: RouteName.RESOURCE_FOLDER;
await this.$router.push({
name: routeName,
params: { path },
query: { ...this.$route.query, ...args },
});
} catch (e: any) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
get breadcrumbLinks() {
if (!this.resource?.actor) return [];
const resourceActor = this.resource.actor;
const links = [
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(this.resource.actor) },
text: displayName(this.resource.actor),
},
{
name: RouteName.RESOURCE_FOLDER_ROOT,
params: { preferredUsername: usernameWithDomain(this.resource.actor) },
text: this.$t("Resources") as string,
},
];
links.push(
...this.filteredPath.map((pathFragment, index) => {
return {
name: RouteName.RESOURCE_FOLDER,
params: {
path: ResourceMixin.resourcePathArray(this.resource).slice(
0,
index + 1
) as unknown as string,
preferredUsername: usernameWithDomain(resourceActor),
},
text: pathFragment,
};
})
);
return links;
}
}
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.container.section {
background: $white;
& > nav.pagination {
margin-top: 1rem;
}
}
nav.breadcrumb ul {
align-items: center;
li:last-child .dropdown {
@include margin-left(5px);
a {
justify-content: left;
color: inherit;
padding: 0.375rem 1rem;
}
}
}
.list-header {
display: flex;
justify-content: space-between;
.list-header-right {
display: flex;
align-items: center;
::v-deep .b-checkbox.checkbox {
@include margin-left(10px);
}
.actions {
@include margin-right(5px);
& > * {
@include margin-left(5px);
}
}
}
}
.resource-item,
.new-resource-preview {
display: flex;
font-size: 14px;
border: 1px solid #c0cdd9;
border-radius: 4px;
color: #444b5d;
margin-top: 14px;
margin-bottom: 14px;
.resource-checkbox {
align-self: center;
@include padding-left(10px);
opacity: 0.3;
::v-deep .b-checkbox.checkbox {
@include margin-right(0.25rem);
}
}
&:hover .resource-checkbox,
.resource-checkbox.checked {
opacity: 1;
}
}
</style>