This commit is contained in:
Richard Dern
2022-01-12 00:35:37 +01:00
commit 400e3d01f1
1363 changed files with 57778 additions and 0 deletions

250
resources/js/app.js vendored Executable file
View File

@@ -0,0 +1,250 @@
require("./modules/bootstrap");
require("./modules/websockets");
import { createApp } from "vue";
import mixins from "./mixins";
import store from "./store";
import { mapGetters, mapActions } from "vuex";
import DetailsDocument from "./components/Details/DetailsDocument.vue";
import DetailsDocuments from "./components/Details/DetailsDocuments.vue";
import DetailsFeedItem from "./components/Details/DetailsFeedItem.vue";
import DetailsFolder from "./components/Details/DetailsFolder.vue";
import DocumentsList from "./components/DocumentsList.vue";
import FeedItemsList from "./components/FeedItemsList.vue";
import FoldersTree from "./components/FoldersTree.vue";
import { collect } from "collect.js";
createApp({
components: {
DetailsDocument,
DetailsDocuments,
DetailsFeedItem,
DetailsFolder,
DocumentsList,
FeedItemsList,
FoldersTree,
},
data: function () {
return {
detailsViewComponent: null,
showFeedItemsList: false,
};
},
mounted: function () {
const self = this;
self.listenToBroadcast();
},
computed: {
...mapGetters({
selectedFolder: "folders/selectedFolder",
documents: "documents/documents",
selectedDocuments: "documents/selectedDocuments",
feedItems: "feedItems/feedItems",
selectedFeedItems: "feedItems/selectedFeedItems",
getUnreadItemsFolder: "folders/getUnreadItemsFolder",
selectedGroup: "groups/selectedGroup",
}),
},
watch: {
selectedFolder: function (oldFolder, newFolder) {
const self = this;
if (newFolder) {
self.detailsViewComponent = "details-folder";
}
},
selectedDocuments: function (documents) {
const self = this;
if (documents && documents.length > 0) {
if (documents.length === 1) {
self.detailsViewComponent = "details-document";
} else {
self.detailsViewComponent = "details-documents";
}
} else {
self.detailsViewComponent = "details-folder";
}
},
selectedFeedItems: function (feedItems) {
const self = this;
if (feedItems && feedItems.length > 0) {
if (feedItems.length === 1) {
self.detailsViewComponent = "details-feed-item";
} else {
//TODO: Handle multiple selected feed items ?
self.detailsViewComponent = null;
}
} else {
if (
self.selectedDocuments &&
self.selectedDocuments.length > 0
) {
self.detailsViewComponent = "details-document";
} else {
self.detailsViewComponent = "details-folder";
}
}
},
feedItems: function (value) {
this.showFeedItemsList = value && collect(value).count() > 0;
},
},
methods: {
...mapActions({
showFolder: "folders/show",
indexFolders: "folders/index",
selectDocuments: "documents/selectDocuments",
indexDocuments: "documents/index",
dropIntoFolder: "folders/dropIntoFolder",
selectFeedItems: "feedItems/selectFeedItems",
markFeedItemsAsRead: "feedItems/markAsRead",
updateDocument: "documents/update",
deleteDocuments: "documents/destroy",
updateFolder: "folders/updateProperties",
showGroup: "groups/show",
updateGroup: "groups/updateProperties",
resetUnreadCount: "folders/resetUnreadCount",
updateUnreadFeedItemsCount: "feedItems/updateUnreadFeedItemsCount",
}),
/**
* Listen to broadcast events
*/
listenToBroadcast: function () {
const self = this;
const userId = document
.querySelector('meta[name="user-id"]')
.getAttribute("content");
window.Echo.private("App.Models.User." + userId).notification(
(notification) => {
switch (notification.type) {
case "App\\Notifications\\UnreadItemsChanged":
self.updateUnreadFeedItemsCount(notification);
self.showFolder({
deselectDocuments: false,
updateFeedItems: true,
});
break;
case "App\\Notifications\\DocumentUpdated":
self.updateDocument({
documentId: notification.document.id,
newProperties: notification.document,
});
break;
}
}
);
},
/**
* User-action - Selected group has changed
* @param {*} group
*/
onSelectedGroupChanged: function (group) {
const self = this;
self.showGroup({ group: group, folder: "unread_items" });
},
/**
* Groups have been loaded
*/
onGroupsLoaded: function () {
//
},
/**
* Folders tree has been loaded
*/
onFoldersLoaded: function () {
//
},
/**
* User-action - Selected folder has changed
* @param {*} folder
*/
onSelectedFolderChanged: function (folder, group) {
const self = this;
if (!folder) {
return;
}
if (group && group != self.selectedGroup.id) {
self.showGroup({ group, folder });
} else {
self.showFolder({ folder });
}
self.detailsViewComponent = "details-folder";
},
/**
* User-action - Something has been dropped into a folder
*/
onItemDropped: function (folder) {
const self = this;
self.dropIntoFolder(folder);
},
/**
* User-action - Selected documents has changed
*/
onSelectedDocumentsChanged: function (documents) {
const self = this;
self.selectDocuments({ documents: documents });
if (self.feedItems.length > 0) {
document.getElementById("feed-items-list").scrollTop = 0;
}
},
/**
* User-action - Refresh documents list after adding one
*/
onDocumentAdded: function () {
//
},
/**
* User-action - Refresh documents list after deleting one (or more)
*/
onDocumentsDeleted: function ({ folder, documents }) {
const self = this;
self.deleteDocuments({
documents: documents,
folder: folder,
});
},
/**
* User-action - Selected feed items has changed
*/
onSelectedFeedItemsChanged: function (feedItems) {
const self = this;
self.selectFeedItems(feedItems);
},
/**
* User-action - Feed items marked as read
*/
onFeedItemsRead: function (data) {
const self = this;
self.markFeedItemsAsRead(data);
},
},
})
.mixin(mixins)
.use(store)
.mount("#app");

28
resources/js/bootstrap.js vendored Executable file
View File

@@ -0,0 +1,28 @@
window._ = require('lodash');
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
// import Echo from 'laravel-echo';
// window.Pusher = require('pusher-js');
// window.Echo = new Echo({
// broadcaster: 'pusher',
// key: process.env.MIX_PUSHER_APP_KEY,
// cluster: process.env.MIX_PUSHER_APP_CLUSTER,
// forceTLS: true
// });

View File

@@ -0,0 +1,40 @@
<template>
<time v-bind:datetime="iso">
{{ formatted }}
</time>
</template>
<script>
export default {
props: ["datetime", "onlyDate", "onlyTime"],
computed: {
iso: function () {
if (!this.datetime) {
return null;
}
return new Date(this.datetime).toISOString();
},
formatted: function () {
const date = new Date(this.datetime);
if (this.onlyTime) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
} else if (this.onlyDate) {
return date.toLocaleDateString();
}
return (
date.toLocaleDateString() +
", " +
date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
);
},
},
};
</script>

View File

@@ -0,0 +1,57 @@
<template>
<stateful-details name="default_folder_permissions_details">
<summary>{{ __("Users without explicit permissions can") }}:</summary>
<div class="body flex items-center mt-2 space-x-2">
<permission-box
v-bind:text="__('Create folder')"
ability="can_create_folder"
v-bind:folder="folder"
></permission-box>
<permission-box
v-bind:text="__('Update folder')"
ability="can_update_folder"
v-bind:folder="folder"
></permission-box>
<permission-box
v-bind:text="__('Delete folder')"
ability="can_delete_folder"
v-bind:folder="folder"
></permission-box>
<permission-box
v-bind:text="__('Create document')"
ability="can_create_document"
v-bind:folder="folder"
></permission-box>
<permission-box
v-bind:text="__('Delete document')"
ability="can_delete_document"
v-bind:folder="folder"
></permission-box>
</div>
</stateful-details>
</template>
<script>
import PermissionBox from "./PermissionBox.vue";
import StatefulDetails from "../StatefulDetails.vue";
export default {
components: { PermissionBox, StatefulDetails },
props: ["folder"],
methods: {
can: function (permission) {
const self = this;
if (
"user_permissions" in self.folder &&
permission in self.folder.user_permissions
) {
return self.folder.user_permissions[permission];
}
return false;
},
},
};
</script>

View File

@@ -0,0 +1,500 @@
<template>
<article v-if="document">
<header>
<div class="icons">
<img v-bind:src="document.favicon" />
</div>
<h1>{{ document.title }}</h1>
<div class="badges">
<div
class="badge default"
v-if="document.feed_item_states_count > 0"
>
{{ document.feed_item_states_count }}
</div>
</div>
<div class="tools">
<button
v-if="document.feed_item_states_count > 0"
class="info"
v-on:click="onMarkAsReadClicked"
v-bind:title="__('Mark as read')"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('unread_items')" />
</svg>
<span class="hidden xl:inline-block">
{{ __("Mark as read") }}
</span>
</button>
<a
class="button info"
v-bind:href="url"
rel="noopener noreferrer"
v-on:click.left.stop.prevent="
openDocument({
document: document,
})
"
v-on:click.middle.exact="
incrementVisits({
document: document,
})
"
v-bind:title="__('Open')"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('open')" />
</svg>
<span class="hidden xl:inline-block">
{{ __("Open") }}
</span>
</a>
<button
class="info"
v-on:click="onShareClicked"
v-bind:title="__('Share')"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('share')" />
</svg>
<span class="hidden xl:inline-block">
{{ __("Share") }}
</span>
</button>
</div>
</header>
<div class="body">
<div
class="cyca-prose"
v-if="document.description"
v-html="document.description"
></div>
<stateful-details name="document_details" class="details-block">
<summary>{{ __("Details") }}</summary>
<div class="vertical list striped items-rounded compact">
<div class="list-item">
<div class="list-item-title">{{ __("Real URL") }}</div>
<div class="list-item-value">
<a
v-bind:href="document.url"
rel="noopener noreferrer"
v-on:click.left.stop.prevent="
openDocument({
document: document,
})
"
class="readable"
v-html="document.ascii_url"
></a>
</div>
</div>
<div class="list-item" v-if="document.visits">
<div class="list-item-title">{{ __("Visits") }}</div>
<div class="list-item-value">
{{ document.visits }}
</div>
</div>
<div class="list-item">
<div class="list-item-title">
{{ __("Date of document's last check") }}
</div>
<div class="list-item-value">
<date-time
v-bind:datetime="document.checked_at"
v-bind:calendar="true"
></date-time>
</div>
</div>
<div
class="list-item"
v-if="dupplicateInFolders.length > 0"
>
<div class="list-item-title">
{{ __("Also exists in") }}
</div>
<div class="list-item-value">
<div
v-for="dupplicateInFolder in dupplicateInFolders"
v-bind:key="dupplicateInFolder.id"
v-on:click="
$emit(
'folder-selected',
dupplicateInFolder.id,
dupplicateInFolder.group_id
)
"
v-html="dupplicateInFolder.breadcrumbs"
class="cursor-pointer mb-1"
></div>
</div>
</div>
</div>
</stateful-details>
<stateful-details
name="feeds_details"
class="details-block"
v-if="document.feeds.length > 0"
>
<summary>{{ __("Feeds") }}</summary>
<div class="list vertical striped items-rounded">
<div v-for="feed in document.feeds" v-bind:key="feed.id">
<div class="list-item">
<div class="icons">
<img v-bind:src="feed.favicon" />
</div>
<div class="list-item-text">{{ feed.title }}</div>
<div class="badges">
<button
class="success"
v-if="feed.is_ignored"
v-on:click="follow(feed)"
>
<svg
fill="currentColor"
width="16"
height="16"
>
<use v-bind:xlink:href="icon('join')" />
</svg>
<span>
{{ __("Follow") }}
</span>
</button>
<button
class="danger"
v-if="!feed.is_ignored"
v-on:click="ignore(feed)"
>
<svg
fill="currentColor"
width="16"
height="16"
>
<use
v-bind:xlink:href="icon('cancel')"
/>
</svg>
<span>
{{ __("Ignore") }}
</span>
</button>
</div>
</div>
<div
class="vertical list striped items-rounded compact mt-2 alt"
>
<div class="list-item" v-if="feed.description">
<div v-html="feed.description"></div>
</div>
<div class="list-item">
<div class="list-item-title">
{{ __("Real URL") }}
</div>
<div class="list-item-value">
<div
class="readable"
v-html="feed.ascii_url"
></div>
</div>
</div>
<div class="list-item">
<div class="list-item-title">
{{ __("Date of document's last check") }}
</div>
<div class="list-item-value">
<date-time
v-bind:datetime="feed.checked_at"
v-bind:calendar="true"
></date-time>
</div>
</div>
<div class="list-item" v-if="feed.error">
<div class="list-item-title">
{{ __("Error") }}
</div>
<div class="list-item-value text-red-500">
{{ feed.error }}
</div>
</div>
</div>
</div>
</div>
</stateful-details>
<stateful-details
class="details-block"
name="document_meta_data_details"
v-if="document.meta_data"
>
<summary>{{ __("Metadata") }}</summary>
<meta-data-browser
class="vertical list striped items-rounded compact"
v-bind:meta-data="document.meta_data"
></meta-data-browser>
</stateful-details>
<stateful-details
name="http_response_details"
class="details-block"
>
<summary class="list-item">{{ __("HTTP response") }}</summary>
<div class="vertical list striped items-rounded compact">
<div class="list-item">
<div class="list-item-title">
{{ __("HTTP Status Code") }}
</div>
<div
class="list-item-value flex items-center space-x-2"
v-bind:class="statusClass"
>
<span v-if="statusIcon">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon(statusIcon)" />
</svg>
</span>
<span>{{ document.http_status_code }}</span>
<span>{{ document.http_status_text }}</span>
</div>
</div>
<div class="list-item" v-if="document.mimetype">
<div class="list-item-title">
{{ __("MIME type") }}
</div>
<div class="list-item-value">
{{ document.mimetype }}
</div>
</div>
<stateful-details
name="document_response_details"
class="list-item"
>
<summary>
{{ __("Response details") }}
</summary>
<meta-data-browser
class="vertical list striped items-rounded compact alt"
v-bind:meta-data="document.response"
parent-name="document_response"
></meta-data-browser>
</stateful-details>
</div>
</stateful-details>
<div
class="mt-6"
v-if="
selectedFolder.type !== 'unread_items' &&
selectedFolder.user_permissions.can_delete_document
"
>
<button class="danger" v-on:click="onDeleteDocument">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('trash')" />
</svg>
<span>
{{ __("Delete") }}
</span>
</button>
</div>
</div>
</article>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
import DateTime from "../DateTime";
import StatefulDetails from "../StatefulDetails.vue";
import MetaDataBrowser from "../MetaDataBrowser.vue";
export default {
components: { DateTime, StatefulDetails, MetaDataBrowser },
data: function () {
return {
dupplicateInFolders: [],
localStorage: localStorage,
};
},
computed: {
...mapGetters({
document: "documents/selectedDocument",
selectedFolder: "folders/selectedFolder",
feeds: "feeds/feeds",
}),
/**
* Return document's initial URL instead of the real URL, unless there
* is not attached bookmark
*/
url: function () {
const self = this;
return self.document.url;
},
statusClass: function () {
const code = this.document.http_status_code;
let className = null;
if (code === 0) {
className = "http-status-general-error";
} else if (code >= 100 && code <= 199) {
className = "http-status-info";
} else if (code >= 200 && code <= 299) {
className = "http-status-success";
} else if (code >= 300 && code <= 399) {
className = "http-status-redirection";
} else if (code >= 400 && code <= 499) {
className = "http-status-client-error";
} else if (code >= 500 && code <= 599) {
className = "http-status-server-error";
}
return className;
},
statusIcon: function () {
const code = this.document.http_status_code;
let icon = null;
if (code === 0) {
icon = "error";
} else if (code >= 100 && code <= 199) {
icon = "info";
} else if (code >= 200 && code <= 299) {
icon = "success";
} else if (code >= 300 && code <= 399) {
icon = "redirect";
} else if (code >= 400 && code <= 499) {
icon = "warning";
} else if (code >= 500 && code <= 599) {
icon = "error";
}
return icon;
},
metaData: function () {
return collect(this.document.meta_data);
},
},
watch: {
document: function (document) {
const self = this;
if (document && document.id) {
self.load(document).then(function () {
self.findDupplicateInFolders();
});
}
},
},
mounted: function () {
const self = this;
if (self.document && self.document.id) {
self.load(self.document).then(function () {
self.findDupplicateInFolders();
});
}
},
methods: {
...mapActions({
incrementVisits: "documents/incrementVisits",
openDocument: "documents/openDocument",
ignoreFeed: "documents/ignoreFeed",
followFeed: "documents/followFeed",
load: "documents/load",
}),
/**
* Filters dupplicates provided by the document by excluding current
* folder
*/
findDupplicateInFolders: function () {
const self = this;
const dupplicates = collect(self.document.dupplicates)
.reject((folder) => folder.id === self.selectedFolder.id)
.all();
self.dupplicateInFolders = dupplicates;
},
/**
* Mark as read button clicked
*/
onMarkAsReadClicked: function () {
const self = this;
self.$emit("feeditems-read", {
documents: [self.document.id],
});
},
/**
* Delete button clicked
*/
onDeleteDocument: function () {
const self = this;
self.$emit("documents-deleted", {
folder: self.selectedFolder,
documents: [self.document],
});
},
/**
* Share button clicked
*/
onShareClicked: function () {
const self = this;
const sharedData = {
title: self.document.title,
text: self.document.description,
url: self.document.url,
};
if (navigator.share) {
navigator.share(sharedData);
} else {
location.href =
"mailto:?subject=" +
sharedData.title +
"&body=" +
sharedData.url;
}
},
/**
* Follow specified feed
*/
follow: function (feed) {
const self = this;
self.followFeed(feed);
},
/**
* Ignore specified feed
*/
ignore: function (feed) {
const self = this;
self.ignoreFeed(feed);
},
},
};
</script>

View File

@@ -0,0 +1,118 @@
<template>
<article>
<h1>
<span></span>
<div class="flex items-center">
<button
v-if="totalUnreadFeedItemsCount > 0"
class="button info"
v-on:click="onMarkAsReadClicked"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('unread_items')" />
</svg>
<span>
{{ __("Mark as read") }}
</span>
</button>
<button
class="info"
v-on:click.left.stop.prevent="onOpenClicked"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('open')" />
</svg>
<span>
{{ __("Open") }}
</span>
</button>
</div>
</h1>
<div class="body">
<img
v-for="document in documents"
v-bind:key="document.id"
v-bind:title="document.title"
v-bind:src="document.favicon"
class="favicon inline mr-1 mb-1"
/>
<div
class="mt-6"
v-if="
selectedFolder.type !== 'unread_items' &&
selectedFolder.user_permissions.can_delete_document
"
>
<button class="danger" v-on:click="onDeleteDocument">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('trash')" />
</svg>
<span>
{{ __("Delete") }}
</span>
</button>
</div>
</div>
</article>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
export default {
computed: {
...mapGetters({
documents: "documents/selectedDocuments",
selectedFolder: "folders/selectedFolder",
}),
totalUnreadFeedItemsCount: function () {
const self = this;
return collect(self.documents).sum("feed_item_states_count");
},
},
methods: {
...mapActions({
openDocument: "documents/openDocument",
}),
/**
* Click on the "Open" button
*/
onOpenClicked: function () {
const self = this;
self.documents.forEach(function (document) {
self.openDocument({
document: document,
folder: self.selectedFolder,
});
});
},
/**
* Mark as read button clicked
*/
onMarkAsReadClicked: function () {
const self = this;
self.$emit("feeditems-read", {
documents: collect(self.documents).pluck("id").all(),
});
},
/**
* Delete button clicked
*/
onDeleteDocument: function () {
const self = this;
self.$emit("documents-deleted", {
folder: self.selectedFolder,
documents: self.documents,
});
},
},
};
</script>

View File

@@ -0,0 +1,196 @@
<template>
<article>
<header>
<h1>
<span v-html="feedItem.title"></span>
</h1>
<div class="tools">
<button
v-if="feedItem.feed_item_states_count > 0"
class="button info"
v-on:click="onMarkAsReadClicked"
v-bind:title="__('Mark as read')"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('unread_items')" />
</svg>
<span class="hidden xl:inline-block">
{{ __("Mark as read") }}
</span>
</button>
<a
class="button info"
v-bind:href="feedItem.url"
rel="noopener noreferrer"
v-bind:title="__('Open')"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('open')" />
</svg>
<span class="hidden xl:inline-block">
{{ __("Open") }}
</span>
</a>
<button
class="button info"
v-on:click="onShareClicked"
v-bind:title="__('Share')"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('share')" />
</svg>
<span class="hidden xl:inline-block">
{{ __("Share") }}
</span>
</button>
</div>
</header>
<div class="body">
<div
class="cyca-prose"
v-html="
feedItem.content ? feedItem.content : feedItem.description
"
></div>
<stateful-details name="feed_item_details" class="details-block">
<summary>{{ __("Details") }}</summary>
<div class="vertical list striped items-rounded compact">
<div class="list-item">
<div class="list-item-title">{{ __("Real URL") }}</div>
<div class="list-item-value">
<a
v-bind:href="feedItem.url"
rel="noopener noreferrer"
class="readable"
v-html="feedItem.ascii_url"
></a>
</div>
</div>
<div class="list-item">
<div class="list-item-title">
{{ __("Date of item's creation") }}
</div>
<div class="list-item-value">
<date-time
v-bind:datetime="feedItem.created_at"
v-bind:calendar="true"
></date-time>
</div>
</div>
<div class="list-item">
<div class="list-item-title">
{{ __("Date of item's publication") }}
</div>
<div class="list-item-value">
<date-time
v-bind:datetime="feedItem.published_at"
v-bind:calendar="true"
></date-time>
</div>
</div>
<div class="list-item">
<div class="list-item-title">
{{ __("Published in") }}
</div>
<div class="list-item-value">
<div class="list horizontal compact">
<div
class="list-item px-0 ml-2"
v-for="feed in feedItem.feeds"
v-bind:key="feed.id"
v-bind:title="feed.url"
>
<div class="icons">
<img v-bind:src="feed.favicon" />
</div>
<div class="list-item-text">
{{ feed.title }}
</div>
</div>
</div>
</div>
</div>
</div>
</stateful-details>
</div>
</article>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
import DateTime from "../DateTime";
import StatefulDetails from "../StatefulDetails.vue";
export default {
components: { DateTime, StatefulDetails },
/**
* Computed properties
*/
computed: {
...mapGetters({
feedItem: "feedItems/selectedFeedItem",
}),
},
watch: {
feedItem: function () {
this.$el.getElementsByClassName("body")[0].scrollTop = 0;
},
},
/**
* Methods
*/
methods: {
...mapActions({
loadFolders: "folders/loadFolders",
loadFeedItemDetails: "feedItems/loadDetails",
}),
/**
* Mark as read button clicked
*/
onMarkAsReadClicked: function () {
const self = this;
self.$emit("feeditems-read", {
feed_items: [self.feedItem.id],
});
},
/**
* Share button clicked
*/
onShareClicked: function () {
const self = this;
var content = self.feedItem.description
? self.feedItem.description
: self.feedItem.content;
if (content) {
content = content.substring(0, 200);
}
const sharedData = {
title: self.feedItem.title,
text: content,
url: self.feedItem.url,
};
if (navigator.share) {
navigator.share(sharedData);
} else {
location.href =
"mailto:?subject=" +
sharedData.title +
"&body=" +
sharedData.url;
}
},
},
};
</script>

View File

@@ -0,0 +1,297 @@
<template>
<article v-if="folder">
<header>
<div class="icons">
<svg
fill="currentColor"
width="16"
height="16"
v-bind:class="folder.iconColor"
>
<use v-bind:xlink:href="icon(folder.icon)" />
</svg>
</div>
<h1>{{ folder.title }}</h1>
<div class="badges">
<div
class="badge default"
v-if="folder.feed_item_states_count > 0"
>
{{ folder.feed_item_states_count }}
</div>
</div>
<div class="tools">
<button
v-if="folder.feed_item_states_count > 0"
class="info"
v-on:click="onMarkAsReadClicked"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('unread_items')" />
</svg>
<span>
{{ __("Mark as read") }}
</span>
</button>
</div>
</header>
<div class="body">
<form
v-bind:action="route('folder.update', folder)"
v-if="can('can_update_folder') && folder.type === 'folder'"
v-on:submit.prevent="onUpdateFolder"
>
<div class="input-group">
<input
type="text"
name="title"
v-bind:value="folder.title"
v-on:input="updateFolderTitle = $event.target.value"
/>
<button type="submit" class="success">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('update')" />
</svg>
<span>
{{ __("Update folder") }}
</span>
</button>
</div>
</form>
<form
v-bind:action="route('folder.store')"
v-on:submit.prevent="onAddFolder"
v-if="can('can_create_folder')"
>
<div class="input-group">
<input type="text" v-model="addFolderTitle" />
<button type="submit" class="success">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('add')" />
</svg>
<span>
{{ __("Add folder") }}
</span>
</button>
</div>
</form>
<form
v-bind:action="route('document.store')"
v-on:submit.prevent="onAddDocument"
v-if="can('can_create_document')"
class="mb-6"
>
<div class="input-group">
<input type="url" v-model="addDocumentUrl" />
<button type="submit" class="success">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('add')" />
</svg>
<span>
{{ __("Add document") }}
</span>
</button>
</div>
</form>
<div
v-if="folder.group && folder.group.active_users_count > 1"
class="mb-6"
>
<default-folder-permissions
class="details-block"
v-bind:folder="folder"
v-if="
can('can_change_permissions') &&
(folder.type === 'root' || folder.type === 'folder')
"
></default-folder-permissions>
<per-user-folder-permissions
class="details-block"
v-bind:folder="folder"
v-if="
can('can_change_permissions') &&
(folder.type === 'root' || folder.type === 'folder')
"
></per-user-folder-permissions>
</div>
<div v-if="can('can_delete_folder')">
<button class="danger" v-on:click="onDeleteFolder">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('trash')" />
</svg>
<span>
{{ __("Delete") }}
</span>
</button>
</div>
</div>
</article>
</template>
<script>
import { mapActions, mapGetters } from "vuex";
import DefaultFolderPermissions from "./DefaultFolderPermissions.vue";
import PerUserFolderPermissions from "./PerUserFolderPermissions.vue";
export default {
components: {
DefaultFolderPermissions,
PerUserFolderPermissions,
},
data: function () {
return {
updateFolderTitle: null,
addFolderTitle: null,
addDocumentUrl: null,
};
},
/**
* Computed properties
*/
computed: {
...mapGetters({
group: "groups/selectedGroup",
folder: "folders/selectedFolder",
}),
},
/**
* Watchers
*/
watch: {
folder: function () {
const self = this;
if (self.folder) {
self.loadDetails(self.folder).then(function () {
self.$forceUpdate();
});
}
},
},
mounted: function () {
const self = this;
if (self.folder) {
self.loadDetails(self.folder).then(function () {
self.$forceUpdate();
});
}
},
/**
* Methods
*/
methods: {
...mapActions({
update: "folders/update",
updateProperties: "folders/updateProperties",
updatePermission: "folders/updatePermission",
store: "folders/store",
destroy: "folders/destroy",
storeDocument: "documents/store",
index: "folders/index",
loadDetails: "folders/loadDetails",
}),
/**
* Mark as read button clicked
*/
onMarkAsReadClicked: function () {
const self = this;
self.$emit("feeditems-read", {
folders: [self.folder.id],
});
},
/**
* Update folder form submitted
*/
onUpdateFolder: function () {
const self = this;
if (
!self.updateFolderTitle ||
self.updateFolderTitle === self.folder.title
) {
return;
}
self.update({
folder: self.folder,
newProperties: {
...self.folder,
...{
title: self.updateFolderTitle,
},
},
});
},
/**
* Add folder form submitted
*/
onAddFolder: function () {
const self = this;
if (!self.addFolderTitle) {
return;
}
self.store({
group_id: self.group.id,
parent_id: self.folder.id,
title: self.addFolderTitle,
})
.then(function () {
self.addFolderTitle = null;
})
.catch((error) => console.error(error));
},
/**
* Delete folder button clicked
*/
onDeleteFolder: function () {
const self = this;
self.destroy(self.folder);
},
/**
* Add document form submitted
*/
onAddDocument: function () {
const self = this;
if (!self.addDocumentUrl) {
return;
}
self.storeDocument({
group_id: self.group.id,
folder_id: self.folder.id,
url: self.addDocumentUrl,
}).then(function () {
self.addDocumentUrl = null;
self.$emit("document-added");
});
},
can: function (permission) {
const self = this;
if (
"user_permissions" in self.folder &&
permission in self.folder.user_permissions
) {
return self.folder.user_permissions[permission];
}
return false;
},
},
};
</script>

View File

@@ -0,0 +1,170 @@
<template>
<stateful-details
name="per_user_folders_permissions_details"
v-on:toggle="onToggle"
>
<summary>{{ __("Per-user permissions") }}:</summary>
<div class="body">
<form v-if="usersWithoutPermissions.length > 0">
<div class="input-group">
<label for="addPermissionsForUser"
>{{ __("Add explicit permissions for") }}:</label
>
<select
v-model="addPermissionsForUser"
id="addPermissionsForUser"
>
<option
v-for="user in usersWithoutPermissions"
v-bind:key="user.id"
v-bind:value="user.id"
>
{{ user.name }}
</option>
</select>
</div>
</form>
<div
class="list vertical spaced striped items-rounded compact"
v-for="user in users"
v-bind:key="user.id"
>
<div class="list-item">
<div>
{{ user.name }}
<small class="feed-item-meta inline ml-2">{{
user.email
}}</small>
<div class="flex items-center space-x-2 mt-2">
<permission-box
v-bind:text="__('Create folder')"
ability="can_create_folder"
v-bind:folder="folder"
v-bind:user="user"
></permission-box>
<permission-box
v-bind:text="__('Update folder')"
ability="can_update_folder"
v-bind:folder="folder"
v-bind:user="user"
></permission-box>
<permission-box
v-bind:text="__('Delete folder')"
ability="can_delete_folder"
v-bind:folder="folder"
v-bind:user="user"
></permission-box>
<permission-box
v-bind:text="__('Create document')"
ability="can_create_document"
v-bind:folder="folder"
v-bind:user="user"
></permission-box>
<permission-box
v-bind:text="__('Delete document')"
ability="can_delete_document"
v-bind:folder="folder"
v-bind:user="user"
></permission-box>
</div>
</div>
<div>
<button
class="info"
v-on:click="onRemovePermissions(user)"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('cancel')" /></svg
><span>{{ __("Apply default permissions") }}</span>
</button>
</div>
</div>
</div>
</div>
</stateful-details>
</template>
<script>
import PermissionBox from "./PermissionBox.vue";
import StatefulDetails from "../StatefulDetails.vue";
export default {
components: { PermissionBox, StatefulDetails },
props: ["folder"],
data: function () {
return {
users: [],
usersWithoutPermissions: [],
addPermissionsForUser: null,
};
},
watch: {
folder: function (folder) {
this.loadPermissions();
},
addPermissionsForUser: function (userId) {
const self = this;
if (userId) {
api.post(
route("folder.set_permission", { folder: self.folder.id }),
{
user_id: userId,
}
).then(function (response) {
self.users = response;
self.addPermissionsForUser = null;
self.loadUsersWithoutPermissions();
});
}
},
},
methods: {
onToggle: function (evt) {
if (evt.target.open) {
this.loadPermissions();
}
},
loadPermissions: function () {
this.loadPerUserPermissions();
this.loadUsersWithoutPermissions();
},
loadPerUserPermissions: function () {
const self = this;
api.get(route("folder.per_user_permissions", self.folder)).then(
function (response) {
self.users = response;
}
);
},
loadUsersWithoutPermissions: function () {
const self = this;
api.get(
route("folder.users_without_permissions", self.folder)
).then(function (response) {
self.usersWithoutPermissions = response;
});
},
onRemovePermissions: function (user) {
const self = this;
api.delete(
route("folder.remove_permissions", {
folder: self.folder.id,
user: user.id,
})
).then(function (response) {
self.users = response;
self.loadUsersWithoutPermissions();
});
},
},
};
</script>

View File

@@ -0,0 +1,80 @@
<template>
<label
class="badge cursor-pointer"
v-bind:class="{ success: granted, danger: !granted }"
>
<input
type="checkbox"
v-model="granted"
class="hidden"
v-on:input="
updatePermission({
ability: ability,
granted: $event.target.checked,
folder: folder,
user: user ? user.id : null,
})
"
/>
<svg fill="currentColor" width="16" height="16">
<use
v-bind:xlink:href="
granted
? icon('permission_granted')
: icon('permission_denied')
"
/>
</svg>
<span>
{{ text }}
</span>
</label>
</template>
<script>
import { mapActions } from "vuex";
export default {
props: ["folder", "user", "ability", "text"],
data: function () {
return {
granted: false,
};
},
watch: {
folder: function () {
this.granted = this.canByDefault(this.ability);
this.$forceUpdate();
},
user: function () {
this.granted = this.canByDefault(this.ability);
this.$forceUpdate();
},
},
mounted: function () {
this.granted = this.canByDefault(this.ability);
},
methods: {
...mapActions({
updatePermission: "folders/updatePermission",
}),
canByDefault: function (permission) {
const self = this;
if (
self.user &&
"permissions" in self.user &&
permission in self.user.permissions[0]
) {
return self.user.permissions[0][permission];
} else if (
"default_permissions" in self.folder &&
permission in self.folder.default_permissions
) {
return self.folder.default_permissions[permission];
}
return false;
},
},
};
</script>

View File

@@ -0,0 +1,217 @@
<template>
<a
class="list-item"
v-bind:class="{ selected: is_selected }"
v-bind:draggable="true"
v-bind:href="url"
v-on:click.meta.left.exact.prevent.stop="onAddToSelection"
v-on:click.left.exact.prevent.stop="onClicked"
v-on:click.middle.exact="
incrementVisits({ document: document, folder: selectedFolder })
"
v-on:dblclick="
openDocument({ document: document, folder: selectedFolder })
"
v-on:dragstart="onDragStart"
v-on:dragend="onDragEnd"
rel="noopener noreferrer"
>
<div class="icons">
<img v-bind:src="document.favicon" class="favicon-lg" />
</div>
<div class="list-item-text">
{{ document.title }}
<div class="text-sm">{{ document.url }}</div>
</div>
<div class="badges">
<div
v-if="
document.http_status_code < 200 ||
document.http_status_code > 299
"
v-bind:class="statusClass"
v-bind:title="document.http_status_text"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon(statusIcon)" />
</svg>
</div>
<div
class="badge default"
v-if="document.feed_item_states_count > 0"
>
<span v-if="document.has_new_unread_items">
<svg
fill="currentColor"
width="16"
height="16"
class="text-blue-300"
>
<use v-bind:xlink:href="icon('update')" />
</svg>
</span>
{{ document.feed_item_states_count }}
</div>
</div>
</a>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
export default {
props: ["document"],
data: function () {
return {
enableEnsureVisible: false,
};
},
computed: {
...mapGetters({
selectedDocuments: "documents/selectedDocuments",
selectedFolder: "folders/selectedFolder",
}),
/**
* Return a boolean value indicating if this document belongs to
* currently selected documents
*/
is_selected: function () {
const self = this;
return (
self.selectedDocuments &&
self.selectedDocuments.find((d) => d.id === self.document.id)
);
},
/**
* Return document's initial URL instead of the real URL, unless there
* is not attached bookmark
*/
url: function () {
const self = this;
if (self.document.bookmark.initial_url) {
return self.document.bookmark.initial_url;
}
return self.document.url;
},
statusClass: function () {
const code = this.document.http_status_code;
let className = null;
if (code === 0) {
className = "http-status-general-error";
} else if (code >= 100 && code <= 199) {
className = "http-status-info";
} else if (code >= 200 && code <= 299) {
className = "http-status-success";
} else if (code >= 300 && code <= 399) {
className = "http-status-redirection";
} else if (code >= 400 && code <= 499) {
className = "http-status-client-error";
} else if (code >= 500 && code <= 599) {
className = "http-status-server-error";
}
return className;
},
statusIcon: function () {
const code = this.document.http_status_code;
let icon = null;
if (code === 0) {
icon = "error";
} else if (code >= 100 && code <= 199) {
icon = "info";
} else if (code >= 200 && code <= 299) {
icon = "success";
} else if (code >= 300 && code <= 399) {
icon = "redirect";
} else if (code >= 400 && code <= 499) {
icon = "warning";
} else if (code >= 500 && code <= 599) {
icon = "error";
}
return icon;
},
},
methods: {
...mapActions({
startDraggingDocuments: "documents/startDraggingDocuments",
stopDraggingDocuments: "documents/stopDraggingDocuments",
incrementVisits: "documents/incrementVisits",
openDocument: "documents/openDocument",
}),
/**
* Document has been clicked
*/
onClicked: function () {
const self = this;
self.$emit("selected-documents-changed", [self.document]);
},
/**
* Add this document to selection
*/
onAddToSelection: function () {
const self = this;
let selectedDocuments = [...self.selectedDocuments];
const index = selectedDocuments.findIndex(
(doc) => doc.id === self.document.id
);
if (index === -1) {
selectedDocuments.push(self.document);
} else {
selectedDocuments.splice(index, 1);
}
self.$emit("selected-documents-changed", selectedDocuments);
},
/**
* Begin drag'n'drop
*/
onDragStart: function () {
const self = this;
var documents = [self.document];
if (self.selectedDocuments && self.selectedDocuments.length > 0) {
documents = self.selectedDocuments;
}
self.startDraggingDocuments(documents);
},
/**
* Stop drag'n'drop
*/
onDragEnd: function () {
const self = this;
self.stopDraggingDocuments();
},
ensureVisible: function () {
const self = this;
if (self.enableEnsureVisible) {
self.$el.scrollIntoView({
block: "center",
});
}
},
},
};
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="vertical list spaced striped items-rounded">
<document-item
v-for="document in sortedList"
v-bind:key="document.id"
v-bind:document="document"
v-on:selected-documents-changed="onSelectedDocumentsChanged"
></document-item>
</div>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
import DocumentItem from "./DocumentItem";
export default {
components: { DocumentItem },
data: function () {
return {
selectedFeeds: [],
};
},
/**
* Computed properties
*/
computed: {
...mapGetters({
documents: "documents/documents",
selectedFolder: "folders/selectedFolder",
}),
sortedList: function () {
const self = this;
let collection = collect(self.documents).sortBy("title");
if (
self.selectedFolder &&
self.selectedFolder.type === "unread_items"
) {
collection = collection.where("feed_item_states_count", ">", 0);
}
return collection.all();
},
},
/**
* Methods
*/
methods: {
/**
* Selected documents have changed
*/
onSelectedDocumentsChanged: function (documents) {
const self = this;
self.$emit("selected-documents-changed", documents);
},
},
};
</script>

View File

@@ -0,0 +1,153 @@
<template>
<a
v-bind:class="{
selected: is_selected,
emphasize: feedItem.feed_item_states_count > 0,
}"
class="list-item"
v-bind:href="feedItem.url"
rel="noopener noreferrer"
v-on:click.left.exact.stop.prevent="onClicked"
>
<div
class="list-item-text mr-2"
v-html="highlight(feedItem.title)"
></div>
<div class="flex justify-between items-center space-x-2">
<div class="list horizontal compact overflow-hidden">
<div
class="list-item px-0"
v-bind:title="feedItem.feeds[0].title"
>
<div class="icons">
<img v-bind:src="feedItem.feeds[0].favicon" />
</div>
<div class="hidden xl:inline-block text-xs">
{{ feedItem.feeds[0].title }}
</div>
</div>
</div>
<date-time
class="text-2xs font-mono badge shadow dark:bg-gray-850"
v-bind:datetime="feedItem.published_at"
v-bind:calendar="true"
></date-time>
</div>
</a>
</template>
<script>
import TEXTColor from "textcolor";
import { mapGetters, mapActions } from "vuex";
import DateTime from "./DateTime";
export default {
components: { DateTime },
data: function () {
return {
enableEnsureVisible: true,
};
},
props: ["feedItem"],
computed: {
...mapGetters({
selectedFeedItems: "feedItems/selectedFeedItems",
}),
/**
* Return a boolean value indicating if this document belongs to
* currently selected documents
*/
is_selected: function () {
const self = this;
let selected = false;
try {
selected =
self.selectedFeedItems &&
self.selectedFeedItems.find(
(fi) => fi.id === self.feedItem.id
);
} finally {
return selected;
}
},
},
watch: {
is_selected: function (selected) {
const self = this;
if (selected) {
self.ensureVisible();
}
},
},
methods: {
...mapActions({
selectFeedItem: "feedItems/selectFeedItem",
addFeedItemToSelection: "documents/addFeedItemToSelection",
}),
/**
* Document has been clicked
*/
onClicked: function () {
const self = this;
self.$emit("selected-feeditems-changed", [self.feedItem]);
},
/**
* Add this document to selection
*/
onAddToSelection: function () {
const self = this;
let selectedFeedItems = [...self.selectedFeedItems];
const index = selectedFeedItems.findIndex(
(fi) => fi.id === self.feedItem.id
);
if (index === -1) {
selectedFeedItems.push(self.feedItem);
} else {
selectedFeedItems.splice(index, 1);
}
self.$emit("selected-feeditems-changed", selectedFeedItems);
},
highlight: function (title) {
highlights.forEach(function (highlight) {
var regex = new RegExp(
"(" + highlight.expression + ")(?![^<]*>|[^<>]*</)",
"ig"
);
let textColor = TEXTColor.findTextColor(highlight.color);
title = title.replace(
regex,
'<span class="highlight" style="color: ' +
textColor +
"; background-color: " +
highlight.color +
'">$1</span>'
);
});
return title;
},
ensureVisible: function () {
const self = this;
if (self.enableEnsureVisible) {
self.$el.scrollIntoView({
block: "center",
});
}
},
},
};
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div
id="feed-items-list"
class="list vertical spaced striped items-rounded"
v-on:scroll.passive="onScroll"
>
<feed-item
v-for="feedItem in sortedList"
v-bind:key="feedItem.id"
v-bind:feedItem="feedItem"
v-on:selected-feeditems-changed="onSelectedFeedItemsChanged"
></feed-item>
</div>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
import FeedItem from "./FeedItem";
export default {
components: { FeedItem },
/**
* Computed properties
*/
computed: {
...mapGetters({
feedItems: "feedItems/feedItems",
canLoadMore: "feedItems/canLoadMore",
selectedFolder: "folders/selectedFolder",
}),
/**
* Return list of feed items sorted by published date
*/
sortedList: function () {
const self = this;
let collection = collect(self.feedItems).sortByDesc("published_at");
if (
self.selectedFolder &&
self.selectedFolder.type === "unread_items"
) {
collection = collection.where("feed_item_states_count", ">", 0);
}
return collection.all();
},
},
watch: {
sortedList: function () {
const self = this;
self.$nextTick(function () {
const scrollHeight = self.$el.scrollHeight;
const innerHeight = self.$el.clientHeight;
if (scrollHeight === innerHeight && self.canLoadMore) {
self.loadMoreFeedItems();
}
});
},
},
methods: {
...mapActions({
loadMoreFeedItems: "feedItems/loadMoreFeedItems",
}),
/**
* Selected feed items has changed
*/
onSelectedFeedItemsChanged: function (feedItems) {
const self = this;
self.$emit("selected-feeditems-changed", feedItems);
},
onScroll: function ($event) {
const scrollTop = $event.target.scrollTop;
const innerHeight = $event.target.clientHeight;
const scrollHeight = $event.target.scrollHeight;
if (
scrollTop + innerHeight >= scrollHeight - 20 &&
this.canLoadMore
) {
this.loadMoreFeedItems();
}
},
},
};
</script>

View File

@@ -0,0 +1,286 @@
<template>
<a
class="list-item compact"
v-bind:class="{
selected: folder.is_selected,
'dragged-over': is_dragged_over,
'cannot-drop': is_dragged_over && cannot_drop,
deleted: folder.deleted_at,
}"
v-bind:href="route('folder.show', folder)"
v-bind:draggable="isDraggable"
v-on:click.stop.prevent="onClick"
v-on:dragstart="onDragStart"
v-on:dragend="onDragEnd"
v-on:drop="onDrop"
v-on:dragleave="onDragLeave"
v-on:dragover="onDragOver"
v-if="branchIsExpanded"
v-bind:style="'padding-left:' + indent"
>
<div class="icons">
<span class="caret">
<svg
fill="currentColor"
width="16"
height="16"
v-on:mouseup.capture.stop="onToggleExpandedClicked"
v-on:mousedown.capture.stop="startTimer"
v-if="folder.children_count > 0"
>
<use v-bind:xlink:href="icon(expanderIcon)" />
</svg>
</span>
<svg
fill="currentColor"
width="16"
height="16"
class="favicon"
v-bind:class="folder.iconColor"
>
<use v-bind:xlink:href="icon(folder.icon)" />
</svg>
</div>
<div class="list-item-text">{{ folder.title }}</div>
<div class="badges">
<div class="badge default" v-if="folder.feed_item_states_count > 0">
<span v-if="folder.has_new_unread_items">
<svg
fill="currentColor"
width="16"
height="16"
class="text-blue-300"
>
<use v-bind:xlink:href="icon('update')" />
</svg>
</span>
{{ folder.feed_item_states_count }}
</div>
</div>
</a>
</template>
<script>
import { mapActions, mapGetters } from "vuex";
export default {
props: ["folder"],
data: function () {
return {
is_dragged_over: false,
cannot_drop: false,
timer: null,
longClick: false,
enableEnsureVisible: false,
};
},
mounted: function () {
const self = this;
if (self.folder.is_selected) {
self.enableEnsureVisible = true;
self.ensureVisible();
self.enableEnsureVisible = false;
}
self.$watch(
() => self.folder.is_selected,
function (value) {
if (value) {
if (self.folder.type === "unread_items") {
self.enableEnsureVisible = true;
}
self.ensureVisible();
self.enableEnsureVisible = false;
}
}
);
},
/**
* Computed properties
*/
computed: {
...mapGetters({
folders: "folders/folders",
}),
/**
* Folder's indentation
*/
indent: function () {
return this.folder.depth + "rem";
},
/**
* Return a boolean value indicating if folder can be dragged
*/
isDraggable: function () {
const self = this;
if (self.folder.deleted_at) {
return false;
}
return self.folder.type === "folder";
},
/**
* Return a boolean value indicating if content can be dropped into this
* folder
*/
canDrop: function () {
const self = this;
if (self.folder.deleted_at) {
return false;
}
return self.folder.type === "folder" || self.folder.type === "root";
},
/**
* Return a boolean value indicating if branch is expanded or collapsed
*/
branchIsExpanded: function () {
const self = this;
var parentId = self.folder.parent_id;
if (!parentId || !self.folders || self.folder.type === "root") {
return true;
}
while (parentId != null) {
var parentFolder = self.folders.find((f) => f.id === parentId);
if (parentFolder) {
if (!parentFolder.is_expanded) {
return false;
}
}
parentId = parentFolder.parent_id;
}
return true;
},
/**
* Icon adjacent to folder indicating its state (expanded/collapsed)
*/
expanderIcon: function () {
const self = this;
if (self.folder.is_expanded) {
return "expanded";
}
return "collapsed";
},
},
/**
* Methods
*/
methods: {
...mapActions({
startDraggingFolder: "folders/startDraggingFolder",
stopDraggingFolder: "folders/stopDraggingFolder",
dropIntoFolder: "folders/dropIntoFolder",
toggleExpanded: "folders/toggleExpanded",
toggleBranch: "folders/toggleBranch",
}),
/**
* Dragging started
*/
onDragStart: function (event) {
const self = this;
self.startDraggingFolder(self.folder);
},
/**
* Dragging ended
*/
onDragEnd: function () {
const self = this;
self.stopDraggingFolder();
self.is_dragged_over = false;
},
/**
* Dropping content in this folder
*/
onDrop: function () {
const self = this;
self.$emit("item-dropped", self.folder);
self.is_dragged_over = false;
},
/**
* Dragging outside of this folder
*/
onDragLeave: function () {
const self = this;
self.is_dragged_over = false;
},
/**
* Dragging over this folder
*/
onDragOver: function (event) {
const self = this;
self.is_dragged_over = true;
if (self.canDrop) {
event.preventDefault();
self.cannot_drop = false;
} else {
self.cannot_drop = true;
}
},
/**
* Selected folder changed
*/
onClick: function () {
const self = this;
self.$emit("selected-folder-changed", self.folder);
},
/**
* Keep track of current timestamp
*/
startTimer: function (event) {
const self = this;
self.timer = window.setTimeout(function () {
self.longClick = true;
self.toggleBranch(self.folder);
}, 500);
},
/**
* Expand/collapse button clicked
*/
onToggleExpandedClicked: function (event) {
const self = this;
if (!self.longClick) {
self.toggleExpanded(self.folder);
}
clearTimeout(self.timer);
self.longClick = false;
},
ensureVisible: function () {
const self = this;
if (self.enableEnsureVisible) {
self.$el.scrollIntoView({
block: "center",
});
}
},
},
};
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div class="flex flex-col">
<div
class="list vertical items-rounded spaced compact striped flex-none overflow-auto max-h-36 bg-gray-50 dark:bg-gray-800"
>
<group-item
v-for="group in sortedGroups"
v-bind:key="group.id"
v-bind:group="group"
v-on:selected-group-changed="onSelectedGroupChanged"
></group-item>
</div>
<div
class="list vertical items-rounded overflow-auto flex-grow compact"
>
<folder-item
v-for="folder in folders"
v-bind:key="folder.id"
v-bind:folder="folder"
v-on:selected-folder-changed="onSelectedFolderChanged"
v-on:item-dropped="onItemDropped"
></folder-item>
</div>
<div
class="list vertical items-rounded spaced compact striped flex-none bg-gray-50 dark:bg-gray-800"
>
<a v-bind:href="route('account')" class="list-item">
<div class="icons">
<svg
fill="currentColor"
width="16"
height="16"
class="text-green-500"
>
<use v-bind:xlink:href="icon('account')" />
</svg>
</div>
<div class="list-item-text">
{{ __("My account") }}
</div>
</a>
<a href="#" class="list-item" v-on:click="logout">
<div class="icons">
<svg
fill="currentColor"
width="16"
height="16"
class="text-red-500"
>
<use v-bind:xlink:href="icon('logout')" />
</svg>
</div>
<div class="list-item-text">
{{ __("Logout") }}
</div>
</a>
</div>
</div>
</template>
<script>
import { mapActions, mapGetters } from "vuex";
import GroupItem from "./GroupItem.vue";
import FolderItem from "./FolderItem.vue";
export default {
components: { GroupItem, FolderItem },
mounted: function () {
const self = this;
self.indexGroups().then(function () {
self.showGroup({});
});
},
/**
* Computed properties
*/
computed: {
...mapGetters({
folders: "folders/folders",
groups: "groups/groups",
}),
csrf: function () {
return document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content");
},
sortedGroups: function () {
return collect(this.groups).sortBy("position");
},
},
/**
* Methods
*/
methods: {
...mapActions({
indexGroups: "groups/indexActive",
showGroup: "groups/show",
}),
onSelectedGroupChanged: function (group) {
const self = this;
self.$emit("selected-group-changed", group);
},
/**
* Folder has been selected
*/
onSelectedFolderChanged: function (folder) {
const self = this;
self.$emit("selected-folder-changed", folder);
},
/**
* Something had been dropped into a folder
*/
onItemDropped: function (folder) {
const self = this;
self.$emit("item-dropped", folder);
},
logout: function () {
document.forms.logout_form.submit();
},
},
};
</script>

View File

@@ -0,0 +1,43 @@
<template>
<a
v-bind:href="route('group.show', group)"
class="list-item"
v-bind:class="{ selected: group.is_selected }"
v-on:click.stop.prevent="$emit('selected-group-changed', group)"
>
<div class="icons">
<svg
fill="currentColor"
width="16"
height="16"
class="text-green-500"
>
<use v-bind:xlink:href="icon('group')" />
</svg>
</div>
<div class="list-item-text">
{{ group.name }}
</div>
<div class="badges">
<div class="badge default" v-if="group.feed_item_states_count > 0">
<span v-if="group.has_new_unread_items">
<svg
fill="currentColor"
width="16"
height="16"
class="text-blue-300"
>
<use v-bind:xlink:href="icon('update')" />
</svg>
</span>
{{ group.feed_item_states_count }}
</div>
</div>
</a>
</template>
<script>
export default {
props: ["group"],
};
</script>

View File

@@ -0,0 +1,229 @@
<template>
<div class="flex w-full h-screen">
<div class="w-1/2 h-screen flex flex-col">
<div class="h-1/2 overflow-auto">
<draggable
class="list vertical striped spaced items-rounded"
v-model="myGroupsSorted"
group="myGroups"
@start="drag = true"
@end="drag = false"
item-key="id"
v-bind:force-fallback="true"
v-bind:fallback-tolerance="10"
handle=".handle"
>
<template #item="{ element }">
<groups-browser-item
class="select-none"
v-bind:group="element"
v-bind:movable="true"
v-on:selected="selectedGroup = element"
v-bind:class="{
selected: selectedGroup === element,
}"
></groups-browser-item>
</template>
</draggable>
</div>
<group-form
class="h-1/2"
v-bind:group="selectedGroup"
v-on:unselect="selectedGroup = null"
v-on:group-created="onGroupCreated"
v-on:group-updated="onGroupUpdated"
v-on:group-deleted="onGroupDeleted"
v-on:invitation-sent="onInvitationSent"
v-on:invitation-accepted="onInvitationAccepted"
v-on:invitation-declined="onInvitationDeclined"
v-on:leave="onLeaveGroup"
>
</group-form>
</div>
<div class="w-1/2 bg-gray-50 dark:bg-gray-850">
<article>
<header>
<h1>
<div class="flex justify-between items-center w-full">
<div class="mr-4">
{{ __("Public groups") }}
</div>
<input
type="search"
v-bind:placeholder="__('Search')"
v-model="search"
/>
</div>
</h1>
</header>
<div class="list vertical striped spaced items-rounded">
<groups-browser-item
v-for="group in publicGroups"
v-bind:group="group"
v-bind:key="group.id"
v-bind:movable="false"
v-on:join="onJoinGroup"
></groups-browser-item>
</div>
</article>
</div>
</div>
</template>
<script>
import GroupsBrowserItem from "./GroupsBrowser/GroupsBrowserItem";
import GroupForm from "./GroupsBrowser/GroupForm";
import draggable from "vuedraggable";
import { mapActions, mapGetters } from "vuex";
export default {
components: { draggable, GroupForm, GroupsBrowserItem },
data: function () {
return {
positions: [],
selectedGroup: null,
publicGroups: [],
search: "",
drag: false,
};
},
mounted: function () {
const self = this;
self.indexMyGroups();
self.loadPublicGroups();
},
computed: {
...mapGetters({
myGroups: "groups/groups",
}),
myGroupsSorted: {
get() {
return collect(this.myGroups).sortBy("pivot.position").all();
},
set(value) {
const self = this;
const collection = collect(value);
let positions = {};
collection.each(function (group, index) {
positions[group.id] = index;
});
self.updatePositions({ positions: positions });
},
},
},
watch: {
search: function (value) {
this.loadPublicGroups();
},
},
methods: {
...mapActions({
indexMyGroups: "groups/indexMyGroups",
createGroup: "groups/createGroup",
updateGroup: "groups/updateGroup",
updateGroupProperties: "groups/updateProperties",
deleteGroup: "groups/deleteGroup",
updatePositions: "groups/updatePositions",
}),
loadPublicGroups: function () {
const self = this;
api.get(route("group.index"), { search: self.search }).then(
function (response) {
self.publicGroups = response.data;
}
);
},
onGroupCreated: function (group) {
const self = this;
self.createGroup(group);
},
onGroupUpdated: function (group) {
const self = this;
self.updateGroup({
group: group,
newProperties: group,
});
},
onGroupDeleted: function (group) {
const self = this;
self.deleteGroup(group).then(function () {
self.selectedGroup = null;
});
},
onInvitationSent: function (response) {
const self = this;
self.updateGroupProperties({
groupId: self.selectedGroup.id,
newProperties: response,
});
},
onInvitationAccepted: function (group) {
const self = this;
api.post(route("group.accept_invitation", group)).then(function (
response
) {
self.updateGroupProperties({
groupId: response.id,
newProperties: response,
});
});
//TODO: Handle errors
},
onInvitationDeclined: function (group) {
const self = this;
api.post(route("group.reject_invitation", group)).then(function (
response
) {
self.updateGroupProperties({
groupId: response.id,
newProperties: response,
});
});
//TODO: Handle errors
},
onLeaveGroup: function (group) {
const self = this;
api.post(route("group.leave", group)).then(function () {
self.indexMyGroups();
self.loadPublicGroups();
self.selectedGroup = null;
});
//TODO: Handle errors
},
onJoinGroup: function (group) {
const self = this;
api.post(route("group.join", group)).then(function () {
self.indexMyGroups();
self.loadPublicGroups();
self.selectedGroup = null;
});
//TODO: Handle errors
},
},
};
</script>

View File

@@ -0,0 +1,257 @@
<template>
<article>
<header>
<h1>
{{ title }}
</h1>
</header>
<div class="body">
<div
v-if="group && group.description"
v-html="group.description"
></div>
<form
v-on:submit.prevent="onSubmit"
v-if="
!group ||
(group &&
(group.pivot.status === 'own' ||
group.pivot.status === 'created'))
"
>
<input type="hidden" name="id" v-model="id" />
<div class="form-group">
<input
type="text"
v-model="name"
v-bind:placeholder="__('Group name')"
/>
</div>
<div class="form-group">
<input
type="text"
v-model="description"
v-bind:placeholder="__('Description')"
/>
</div>
<div class="form-group">
<label class="my-0"
><input type="checkbox" v-model="inviteOnly" />
<span class="ml-2">{{ __("Invite only") }}</span>
</label>
</div>
<div class="form-group">
<label class="my-0"
><input type="checkbox" v-model="autoAcceptUsers" />
<span class="ml-2">{{ __("Auto-accept users") }}</span>
</label>
</div>
<div class="flex justify-between mt-4">
<button class="success" type="submit">
<svg fill="currentColor" width="16" height="16">
<use
v-bind:xlink:href="icon(!id ? 'add' : 'update')"
/>
</svg>
<span>
{{ !id ? __("Create group") : __("Update group") }}
</span>
</button>
<div v-if="id !== null" class="flex items-center space-x-2">
<button
class="secondary"
v-on:click.stop.prevent="$emit('unselect')"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('cancel')" />
</svg>
<span>
{{ __("Cancel") }}
</span>
</button>
<button
v-if="group.pivot.status !== 'own'"
class="danger"
v-on:click.stop.prevent="
$emit('group-deleted', group)
"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('trash')" />
</svg>
<span>
{{ __("Delete") }}
</span>
</button>
</div>
</div>
</form>
<form
v-if="
group &&
(group.pivot.status === 'own' ||
group.pivot.status === 'created')
"
v-on:submit.prevent="onInviteSubmit"
>
<div class="form-group">
<div class="input-group">
<input
type="text"
name="title"
v-model="inviteEmail"
v-bind:placeholder="__('E-Mail Address')"
/>
<button type="submit" class="info">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('join')" />
</svg>
<span>
{{ __("Invite in group") }}
</span>
</button>
</div>
</div>
</form>
<div
v-if="group && group.pivot.status === 'invited'"
class="mt-4 flex items-center justify-between"
>
<button
class="success"
v-on:click="$emit('invitation-accepted', group)"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('check')" />
</svg>
<span>
{{ __("Accept invitation") }}
</span>
</button>
<button
class="danger"
v-on:click="$emit('invitation-declined', group)"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('cancel')" />
</svg>
<span>
{{ __("Decline invitation") }}
</span>
</button>
</div>
<div
v-if="
group &&
(group.pivot.status === 'accepted' ||
group.pivot.status === 'joining')
"
class="mt-4"
>
<button class="danger" v-on:click="$emit('leave', group)">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('logout')" />
</svg>
<span>
{{ __("Leave the group") }}
</span>
</button>
</div>
</div>
</article>
</template>
<script>
export default {
props: ["group"],
data: function () {
return {
id: null,
name: null,
description: null,
inviteOnly: false,
autoAcceptUsers: false,
inviteEmail: null,
};
},
computed: {
title: function () {
if (this.group) {
return this.group.name;
}
return this.__("Create group");
},
},
watch: {
group: function (group) {
if (!group) {
this.id = null;
this.name = null;
this.description = null;
this.inviteOnly = false;
this.autoAcceptUsers = false;
} else {
this.id = group.id;
this.name = group.name;
this.description = group.description;
this.inviteOnly = group.invite_only;
this.autoAcceptUsers = group.auto_accept_users;
}
this.$forceUpdate();
},
},
methods: {
onSubmit: function () {
const self = this;
const properties = {
name: self.name,
description: self.description,
invite_only: self.inviteOnly,
auto_accept_users: self.autoAcceptUsers,
};
if (!properties.name) {
return;
}
if (self.id) {
properties["id"] = self.id;
self.$emit("group-updated", properties);
} else {
self.$emit("group-created", properties);
self.id = null;
self.name = null;
self.description = null;
self.inviteOnly = false;
self.autoAcceptUsers = false;
}
},
onInviteSubmit: function () {
const self = this;
if (!self.inviteEmail) {
return false;
}
api.post(route("group.invite_user", self.group), {
email: self.inviteEmail,
}).then(function (response) {
self.inviteEmail = null;
self.$emit("invitation-sent");
//TODO: Handle errors
});
},
},
};
</script>

View File

@@ -0,0 +1,121 @@
<template>
<div class="list-item">
<div class="handle">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('move-v')" />
</svg>
</div>
<div class="flex-grow flex justify-between">
<div>
<h2 class="text-black dark:text-white">{{ group.name }}</h2>
<div v-if="group.creator" class="mb-1">
{{ __("Created by") }} {{ group.creator.name }}
</div>
<div class="mb-1">{{ group.description }}</div>
<div class="flex items-center space-x-1">
<span
v-if="group.pivot"
class="badge"
v-bind:class="'group-status-' + group.pivot.status"
>{{ status }}</span
>
<span
class="badge default"
v-bind:class="{
'group-invite-only': group.invite_only,
}"
v-if="group.invite_only"
>{{ __("Invite only") }}</span
>
<span
class="badge"
v-bind:class="{
success: group.auto_accept_users,
warning: !group.auto_accept_users,
}"
v-if="!group.invite_only"
>{{
group.auto_accept_users
? __("Auto-accept users")
: __("New users must be approved")
}}</span
>
</div>
</div>
</div>
<div class="badges">
<span
v-if="group.pending_users_count > 0"
class="badge warning"
v-bind:title="__('Pending users')"
><svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('group')" /></svg
>{{ group.pending_users_count }}</span
>
<span class="badge success" v-bind:title="__('Active users')"
><svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('group')" /></svg
>{{ group.active_users_count }}</span
>
<button
class="info"
v-if="group.pivot"
v-on:click="$emit('selected', group)"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('update')" />
</svg>
<span>{{ __("Edit") }} </span>
</button>
<button
class="success"
v-if="!group.pivot"
v-on:click="$emit('join', group)"
>
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('join')" />
</svg>
<span
>{{ group.auto_accept_users ? __("Join") : __("Apply") }}
</span>
</button>
</div>
</div>
</template>
<script>
export default {
props: ["group", "movable"],
computed: {
status: function () {
const self = this;
if (!self.group.pivot) {
return;
}
switch (self.group.pivot.status) {
case "own":
return self.__("My own group");
case "created":
return self.__("Created by me");
case "invited":
return self.__("Invited to join");
case "accepted":
return self.__("Accepted");
case "rejected":
return self.__("Rejected");
case "left":
return self.__("Group left");
case "joining":
return self.__("Waiting for joining");
}
},
},
methods: {
select: function () {
this.$emit("selected");
},
},
};
</script>

View File

@@ -0,0 +1,285 @@
<template>
<div class="flex w-full h-screen">
<div class="w-1/2 h-screen flex flex-col">
<div class="h-2/3 overflow-auto">
<draggable
class="list vertical striped spaced items-rounded"
v-model="sortedHighlights"
group="myHighlights"
@start="drag = true"
@end="drag = false"
item-key="id"
v-bind:force-fallback="true"
v-bind:fallback-tolerance="10"
handle=".handle"
>
<template #item="{ element }">
<div
class="list-item select-none"
v-bind:class="{
selected:
selectedHighlight &&
selectedHighlight.id === element.id,
}"
>
<div class="handle">
<svg fill="currentColor" width="16" height="16">
<use v-bind:xlink:href="icon('move-v')" />
</svg>
</div>
<div class="icons">
<div
class="badge font-mono"
v-html="highlightText(element.color)"
></div>
</div>
<div class="list-item-text">
{{ element.expression }}
</div>
<div class="tools">
<button
class="info"
v-on:click="selectedHighlight = element"
>
<svg
fill="currentColor"
width="16"
height="16"
>
<use
v-bind:xlink:href="icon('update')"
/>
</svg>
<span>{{ __("Edit") }}</span>
</button>
</div>
</div>
</template>
</draggable>
</div>
<div class="h-1/3">
<article>
<header>
<h1>
{{
selectedHighlight
? selectedHighlight.expression
: __("Create highlight")
}}
</h1>
</header>
<div class="body">
<div class="flex items-center">
<div class="flex-none mr-2">
<div class="form-group">
<label for="color">{{ __("Color") }}</label>
<input
type="color"
id="color"
v-model="newColor"
/>
</div>
</div>
<div class="flex-grow">
<div class="form-group">
<label for="expression">{{
__("Expression")
}}</label>
<input
type="text"
id="expression"
v-model="newExpression"
/>
</div>
</div>
</div>
<div class="flex justify-between">
<button
type="submit"
class="success"
v-on:click="saveHighlight"
>
<svg fill="currentColor" width="16" height="16">
<use
v-bind:xlink:href="
icon(
!selectedHighlight
? 'add'
: 'update'
)
"
/>
</svg>
<span>
{{ __("Save") }}
</span>
</button>
<div class="flex items-center space-x-1">
<button
class="secondary"
v-on:click="resetForm"
>
<svg
fill="currentColor"
width="16"
height="16"
>
<use
v-bind:xlink:href="icon('cancel')"
/>
</svg>
<span>
{{ __("Cancel") }}
</span>
</button>
<button
class="danger"
v-on:click="onDestroy"
v-if="selectedHighlight"
>
<svg
fill="currentColor"
width="16"
height="16"
>
<use
v-bind:xlink:href="icon('trash')"
/>
</svg>
<span>
{{ __("Delete") }}
</span>
</button>
</div>
</div>
</div>
</article>
</div>
</div>
<div class="w-1/2 bg-gray-50 dark:bg-gray-850"></div>
</div>
</template>
<script>
import draggable from "vuedraggable";
import TEXTColor from "textcolor";
export default {
components: { draggable },
data: function () {
return {
positions: [],
highlights: highlights,
newExpression: null,
newColor: "#000000",
selectedHighlight: null,
};
},
computed: {
sortedHighlights: {
get() {
return collect(this.highlights).sortBy("position").all();
},
set(value) {
const self = this;
const collection = collect(value);
let positions = {};
collection.each(function (highlight, index) {
positions[highlight.id] = index;
});
self.updatePositions({ positions: positions });
},
},
},
watch: {
selectedHighlight: function (highlight) {
const self = this;
if (highlight) {
self.newExpression = highlight.expression;
self.newColor = highlight.color;
} else {
self.resetForm();
}
},
},
methods: {
resetForm: function () {
const self = this;
self.selectedHighlight = null;
self.newExpression = null;
self.newColor = "#000000";
},
saveHighlight: function () {
const self = this;
if (!self.newExpression) {
return;
}
if (self.selectedHighlight) {
api.put(route("highlight.update", self.selectedHighlight), {
expression: self.newExpression,
color: self.newColor,
}).then(function (response) {
self.highlights = response;
self.resetForm();
});
} else {
api.post(route("highlight.store"), {
expression: self.newExpression,
color: self.newColor,
}).then(function (response) {
self.highlights = response;
self.resetForm();
});
}
},
onDestroy: function () {
const self = this;
api.delete(route("highlight.destroy", self.selectedHighlight)).then(
function (response) {
self.highlights = response;
self.selectedHighlight = null;
}
);
},
updatePositions: function ({ positions }) {
const self = this;
for (var id in positions) {
const highlight = self.highlights.find((h) => h.id == id);
if (!highlight) {
console.warn("Highlight #" + id + " not found");
return;
}
highlight.position = positions[id];
}
api.post(route("highlight.update_positions"), {
positions: positions,
});
},
highlightText: function (color) {
let textColor = TEXTColor.findTextColor(color);
color =
'<span class="highlight" style="color: ' +
textColor +
"; background-color: " +
color +
'">' +
color +
"</span>";
return color;
},
},
};
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div>
<div class="form-group">
<label for="importer">{{ __("Import from") }}:</label>
<select v-model="importer" id="importer">
<option
v-for="(importerData, importerName) in availableImporters"
v-bind:key="importerName"
v-bind:value="importerName"
>
{{ importerData["name"] }}
</option>
</select>
</div>
<component
v-bind:is="availableImporters[importer]['view']"
v-on:import="onImport"
></component>
<div v-if="uploading" class="mt-2">
{{ __("Importing, please wait...") }}
</div>
<div v-if="uploaded" class="alert success">
{{ __("Data imported") }}
</div>
<div v-if="error" class="alert error">
{{ __("A problem occurred, interrupting importation") }}
</div>
</div>
</template>
<script>
import ImportFromCyca from "./Importers/ImportFromCyca";
export default {
components: { ImportFromCyca },
props: ["availableImporters"],
data: function () {
return {
importer: "cyca",
uploading: false,
uploaded: false,
error: false,
};
},
methods: {
onImport: function (formData) {
const self = this;
if (self.uploading) {
return;
}
self.uploaded = false;
self.uploading = true;
self.error = false;
formData.append("importer", self.importer);
fetch(route("account.import"), {
body: formData,
method: "POST",
headers: {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
},
})
.then((response) => {
if (!response.ok) {
self.uploaded = false;
self.error = true;
} else {
self.uploaded = true;
self.error = false;
}
})
.catch((error) => {
self.uploaded = false;
self.error = true;
})
.finally(() => {
self.uploading = false;
});
},
},
};
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div>
<div class="form-group">
<label for="file">{{ __("File to import") }}</label>
<input type="file" id="file" />
</div>
<div class="form-group">
<button type="submit" v-on:click="onImport">
{{ __("Import") }}
</button>
</div>
</div>
</template>
<script>
export default {
methods: {
onImport: function () {
const input = document.getElementById("file");
let formData = new FormData();
formData.append("file", input.files[0]);
this.$emit("import", formData);
},
},
};
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div>
<div v-for="(value, item) in metaData" v-bind:key="item">
<stateful-details
class="list-item"
v-if="isArrayOrObject(value)"
v-bind:name="getStatefulDetailsName(item)"
>
<summary>{{ item }}</summary>
<browser
class="vertical list striped items-rounded compact alt"
v-bind:meta-data="value"
v-bind:parent-name="getStatefulDetailsName(item)"
></browser>
</stateful-details>
<div class="list-item" v-if="!isArrayOrObject(value)">
<div class="list-item-title">
{{ item }}
</div>
<div class="list-item-value">
<code>{{ value }}</code>
</div>
</div>
</div>
</div>
</template>
<script>
import StatefulDetails from "./StatefulDetails.vue";
export default {
components: { StatefulDetails },
name: "browser",
props: ["metaData", "parentName"],
methods: {
getStatefulDetailsName: function (name) {
const self = this;
return collect(["details", self.parentName, name]).join("_");
},
isArrayOrObject: function (variable) {
return typeof variable === "array" || typeof variable === "object";
},
},
};
</script>

View File

@@ -0,0 +1,23 @@
<template>
<details v-bind:open="opened" v-on:toggle="onToggle"><slot></slot></details>
</template>
<script>
export default {
props: ["name"],
computed: {
opened: function () {
return localStorage.getItem(this.name);
},
},
methods: {
onToggle: function (event) {
if (event.target.open) {
localStorage.setItem(this.name, event.target.open);
} else {
localStorage.removeItem(this.name);
}
},
},
};
</script>

24
resources/js/groups.js vendored Executable file
View File

@@ -0,0 +1,24 @@
require("./modules/bootstrap");
import { createApp } from "vue";
import { createStore } from "vuex";
import mixins from "./mixins";
import groups from "./store/modules/groups";
import GroupsBrowser from "./components/GroupsBrowser.vue";
const debug = process.env.NODE_ENV !== "production";
const store = createStore({
modules: {
groups,
},
strict: debug,
});
createApp({
components: { GroupsBrowser },
el: "#app",
})
.mixin(mixins)
.use(store)
.mount("#app");

12
resources/js/highlights.js vendored Executable file
View File

@@ -0,0 +1,12 @@
require("./modules/bootstrap");
import { createApp } from "vue";
import mixins from "./mixins";
import Highlights from "./components/Highlights.vue";
createApp({
components: { Highlights },
el: "#app",
})
.mixin(mixins)
.mount("#app");

12
resources/js/import.js vendored Executable file
View File

@@ -0,0 +1,12 @@
require("./modules/bootstrap");
import { createApp } from "vue";
import mixins from "./mixins";
import Importer from "./components/Importer.vue";
createApp({
components: { Importer },
el: "#app",
})
.mixin(mixins)
.mount("#app");

35
resources/js/mixins/index.js vendored Executable file
View File

@@ -0,0 +1,35 @@
import router from "../modules/router";
window.route = router;
export default {
methods: {
route: (name, params) => route(name, params),
/**
* Return url to an icon
*/
icon: (name) => {
const iconsFileUrl = document
.querySelector('meta[name="icons-file-url"]')
.getAttribute("content");
const url = iconsFileUrl + "#" + name;
return url;
},
/**
* Translate specified string
* @param {*} langString
*/
__: function (langString) {
const translation = lang[langString];
if (translation) {
return translation;
}
return langString;
},
},
};

98
resources/js/modules/api.js vendored Executable file
View File

@@ -0,0 +1,98 @@
/**
* Default headers to apply to every query
*/
const defaultHeaders = {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content")
};
export default {
/**
* Send a request and return resulting JSON
*/
async send(url, params, method = "POST", upload = false) {
let headers = defaultHeaders;
if (upload) {
headers["Content-Type"] = "multipart/form-data";
}
let options = {
method: method,
headers: headers
};
if (params) {
if (upload) {
options.body = params;
} else if (method === "GET") {
// Build the query string
var query = Object.keys(params)
.map(
k => {
let key = encodeURIComponent(k);
let val = params[k];
if (val.constructor === Array) {
let arr = [];
val.forEach((v => {
arr.push(key + "[]=" + encodeURIComponent(v));
}));
return arr.join('&');
} else {
return key + "=" + encodeURIComponent(val);
}
}
)
.join("&");
url = url + "?" + query;
} else {
options.body = JSON.stringify(params);
}
}
const response = await fetch(url, options);
try {
const json = await response.json();
return json;
} catch {
return response;
}
},
/**
* Send a GET request and return resulting JSON
*/
async get(url, params) {
return this.send(url, params, "GET");
},
/**
* Send a POST request and return resulting JSON
*/
async post(url, params, upload = false) {
return this.send(url, params, "POST", upload);
},
/**
* Send a PUT request and return resulting JSON
*/
async put(url, params) {
return this.send(url, params, "PUT");
},
/**
* Send a DELETE request and return resulting JSON
*/
async delete(url, params) {
return this.send(url, params, "DELETE");
}
};

5
resources/js/modules/bootstrap.js vendored Executable file
View File

@@ -0,0 +1,5 @@
//window.Vue = require('vue');
window.collect = require("collect.js");
import api from "./api";
window.api = api;

37
resources/js/modules/components.js vendored Executable file
View File

@@ -0,0 +1,37 @@
const sets = {
app: [
"Details/DetailsDocument",
"Details/DetailsDocuments",
"Details/DetailsFeedItem",
"Details/DetailsFolder",
"DocumentsList",
"FeedItemsList",
"FoldersTree",
],
import: ["Importer"],
highlights: ["Highlights", "Highlight"],
historyBrowser: ["HistoryBrowser"],
groups: [
"GroupsBrowser"
]
};
module.exports = function(set) {
const files = sets[set];
files.map(file => {
const componentName = file
.split("/")
.pop()
.match(
/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g
)
.map(x => x.toLowerCase())
.join("-");
Vue.component(
componentName,
require("../components/" + file + ".vue").default
);
});
};

32
resources/js/modules/router.js vendored Executable file
View File

@@ -0,0 +1,32 @@
const routes = require("./routes.json");
module.exports = function(name, params) {
if (routes[name] === undefined) {
console.error("Unknown route ", name);
} else {
return (
document.querySelector("base").getAttribute("href") +
"/" +
routes[name]
.split("/")
.map(s => {
if (s[0] == "{") {
var paramName = s.substring(1, s.length - 1);
if(params[paramName]) {
return params[paramName];
}
if (params.id) {
return params.id;
}
return paramName;
}
return s;
})
.join("/")
);
}
};

View File

@@ -0,0 +1 @@
{"logout":"logout","account":"account","home":"\/","account.password":"account\/password","account.import.form":"account\/import","account.import":"account\/import","account.export":"account\/export","group.index_active":"group\/active","group.my_groups":"group\/my_groups","group.update_positions":"group\/update_positions","group.invite_user":"group\/{group}\/invite","group.leave":"group\/{group}\/leave","group.join":"group\/{group}\/join","group.accept_invitation":"group\/{group}\/accept_invitation","group.reject_invitation":"group\/{group}\/reject_invitation","group.index":"group","group.store":"group","group.show":"group\/{group}","group.update":"group\/{group}","group.destroy":"group\/{group}","folder.toggle_branch":"folder\/{folder}\/toggle_branch","folder.details":"folder\/{folder}\/details","folder.set_permission":"folder\/{folder}\/set_permission","folder.per_user_permissions":"folder\/{folder}\/per_user_permissions","folder.users_without_permissions":"folder\/{folder}\/users_without_permissions","folder.remove_permissions":"folder\/{folder}\/remove_permissions\/{user}","folder.index":"folder","folder.store":"folder","folder.show":"folder\/{folder}","folder.update":"folder\/{folder}","folder.destroy":"folder\/{folder}","document.move":"document\/move\/{sourceFolder}\/{targetFolder}","document.destroy_bookmarks":"document\/delete_bookmarks\/{folder}","document.visit":"document\/{document}\/visit","document.store":"document","document.show":"document\/{document}","feed.ignore":"feed\/{feed}\/ignore","feed.follow":"feed\/{feed}\/follow","feed_item.mark_as_read":"feed_item\/mark_as_read","feed_item.index":"feed_item","feed_item.show":"feed_item\/{feed_item}","highlight.update_positions":"highlight\/update_positions","highlight.store":"highlight","highlight.update":"highlight\/{highlight}","highlight.destroy":"highlight\/{highlight}"}

13
resources/js/modules/websockets.js vendored Executable file
View File

@@ -0,0 +1,13 @@
window.Pusher = require('pusher-js');
import Echo from "laravel-echo"
window.Echo = new Echo({
broadcaster: 'pusher',
key: 'cyca',
cluster: 'mt1',
forceTLS: false,
disableStats: true,
wsHost: window.location.hostname,
wsPort: 6001
});

17
resources/js/store/index.js vendored Executable file
View File

@@ -0,0 +1,17 @@
import { createStore } from "vuex";
import folders from "./modules/folders";
import documents from "./modules/documents";
import feedItems from "./modules/feeditems";
import groups from "./modules/groups";
const debug = process.env.NODE_ENV !== "production";
export default createStore({
modules: {
folders,
documents,
feedItems,
groups,
},
strict: debug,
});

View File

@@ -0,0 +1,235 @@
import { collect } from "collect.js";
/**
* Actions on documents
*/
export default {
/**
* Display a listing of the resource.
*/
async index(
{ commit, dispatch, getters },
{ documents, updateFeedItems = true }
) {
commit("setDocuments", documents);
let selectedDocuments = getters.selectedDocuments;
if (!selectedDocuments || selectedDocuments.length === 0) {
selectedDocuments = documents;
}
if (updateFeedItems) {
await dispatch("feedItems/index", selectedDocuments, {
root: true,
});
}
},
/**
* Store a newly created resource in storage.
*/
async store({ dispatch }, { url, folder_id, group_id }) {
const data = await api.post(route("document.store"), {
url: url,
folder_id: folder_id,
group_id: group_id,
});
dispatch("index", { documents: data });
},
/**
* Mark specified documents as selected.
*/
selectDocuments({ commit, dispatch }, { documents, selectFirstUnread }) {
commit("setSelectedDocuments", documents);
commit("feedItems/setSelectedFeedItems", [], {
root: true,
});
dispatch("feedItems/index", documents, { root: true }).then(
function () {
if (selectFirstUnread && documents) {
dispatch("feedItems/selectFirstUnreadFeedItem", null, {
root: true,
});
}
}
);
},
/**
* Select first document in currently displayed list
*/
selectFirstDocument({ getters, dispatch }) {
const document = collect(getters.documents).first();
dispatch("selectDocuments", [document]);
},
/**
* Select first document containing unread feed items in currently displayed
* list
*/
selectFirstDocumentWithUnreadItems(
{ getters, dispatch, rootGetters },
{ exclude, selectFirstUnread }
) {
if (!exclude) {
exclude = collect(getters.selectedDocuments).pluck("id").all();
}
const document = collect(getters.documents)
.where("feed_item_states_count", ">", 0)
.whereNotIn("id", exclude)
.first();
if (document) {
dispatch("selectDocuments", {
documents: [document],
selectFirstUnread: selectFirstUnread,
});
} else {
if (rootGetters["folders/selectedFolder"].type === "unread_items") {
dispatch("selectDocuments", {
selectFirstUnread: selectFirstUnread,
});
}
}
},
/**
* Remember documents being dragged
*/
startDraggingDocuments({ commit }, documents) {
commit("setDraggedDocuments", documents);
},
/**
* Forget dragged documents
*/
stopDraggingDocuments({ commit }) {
commit("setDraggedDocuments", []);
},
/**
* Move selected documents into specified folder
*/
async dropIntoFolder(
{ getters, commit, dispatch },
{ sourceFolder, targetFolder }
) {
const documents = collect(getters.draggedDocuments).pluck("id").all();
if (!documents || documents.length === 0) {
return;
}
const response = await api.post(
route("document.move", {
sourceFolder: sourceFolder.id,
targetFolder: targetFolder.id,
}),
{
documents: documents,
}
);
commit("setDraggedDocuments", []);
commit("setSelectedDocuments", []);
const newDocumentsList = collect(getters.documents).whereNotIn(
"id",
documents
);
dispatch("index", { documents: newDocumentsList });
dispatch("feedItems/index", getters.feeds, { root: true });
dispatch("feedItems/updateUnreadFeedItemsCount", response, {
root: true,
});
},
/**
* Increment visits for specified document
*/
async incrementVisits({ commit }, { document }) {
const data = await api.post(
route("document.visit", { document: document.id })
);
commit("update", {
document: document,
newProperties: data,
});
},
/**
* Open specified document in background
*/
openDocument({ dispatch }, { document }) {
window.open(document.url);
dispatch("incrementVisits", { document: document });
},
/**
* Remove specified documents from specified folder
*/
async destroy({ commit, getters, dispatch }, { folder, documents }) {
commit("setSelectedDocuments", []);
const data = await api.post(
route("document.destroy_bookmarks", { folder: folder.id }),
{
documents: collect(documents).pluck("id").all(),
}
);
dispatch("index", { documents: data });
},
/**
* Update the specified resource in storage.
*/
update({ commit, getters }, { documentId, newProperties }) {
const document = getters.documents.find((d) => d.id == documentId);
if (!document) {
return;
}
commit("update", { document: document, newProperties: newProperties });
},
/**
* Load every data available for specified document, unless it was already
* loaded
*/
load({ dispatch }, document) {
if (!document.loaded) {
return api
.get(route("document.show", document))
.then(function (response) {
response.loaded = true;
return dispatch("update", {
documentId: document.id,
newProperties: response,
});
});
}
},
followFeed({ commit }, feed) {
api.post(route("feed.follow", feed));
commit("ignoreFeed", { feed: feed, ignored: false });
},
ignoreFeed({ commit }, feed) {
api.post(route("feed.ignore", feed));
commit("ignoreFeed", { feed: feed, ignored: true });
},
};

View File

@@ -0,0 +1,29 @@
/**
* Documents getters
*/
export default {
/**
* Return documents list
*/
documents: state => {
return collect(state.documents).sortBy('title').all();
},
/**
* Return currently selected documents
*/
selectedDocuments: state => {
return state.selectedDocuments;
},
/**
* Return the first selected document
*/
selectedDocument: state => {
return collect(state.selectedDocuments).first();
},
/**
* Return documents being dragged
*/
draggedDocuments: state => {
return state.draggedDocuments;
}
}

12
resources/js/store/modules/documents/index.js vendored Executable file
View File

@@ -0,0 +1,12 @@
import state from "./state";
import getters from "./getters";
import actions from "./actions";
import mutations from "./mutations";
export default {
'namespaced': true,
state,
getters,
actions,
mutations
}

View File

@@ -0,0 +1,46 @@
/**
* Documents mutations
*/
export default {
/**
* Store documents list
* @param {*} state
* @param {*} documents
*/
setDocuments(state, documents) {
state.documents = documents;
},
/**
* Mark specified documents as selected
* @param {*} state
* @param {*} documents
*/
setSelectedDocuments(state, documents) {
state.selectedDocuments = documents;
},
/**
* Remember documents being dragged
* @param {*} state
* @param {*} documents
*/
setDraggedDocuments(state, documents) {
state.draggedDocuments = documents;
},
/**
* Update document's properties
* @param {*} state
* @param {*} param1
*/
update(state, {document, newProperties}) {
for(var property in newProperties) {
document[property] = newProperties[property];
}
},
ignoreFeed(state, {feed, ignored}) {
feed.is_ignored = ignored;
}
}

14
resources/js/store/modules/documents/state.js vendored Executable file
View File

@@ -0,0 +1,14 @@
export default {
/**
* Documents list
*/
documents: [],
/**
* Selected documents
*/
selectedDocuments: [],
/**
* Documents being dragged
*/
draggedDocuments: []
}

View File

@@ -0,0 +1,228 @@
import { collect } from "collect.js";
/**
* Feed items actions
*/
export default {
/**
* Load feed items for specified feeds
*/
async index({ getters, commit }, documents) {
const feeds = collect(documents)
.pluck("feeds")
.flatten(1)
.pluck("id")
.all();
let nextPage = 0;
let feedItems = [];
const currentlySelectedItem = getters.selectedFeedItem;
if (feeds.length > 0) {
const data = await api.get(route("feed_item.index"), {
feeds: feeds,
});
feedItems = data.data;
nextPage = data.next_page_url !== null ? data.current_page + 1 : 0;
}
if (currentlySelectedItem) {
const itemFeeds = collect(currentlySelectedItem.feeds)
.pluck("id")
.flatten(1);
if (itemFeeds.intersect(feeds)) {
let collection = collect(feedItems);
if (!collection.where("id", currentlySelectedItem.id).first()) {
feedItems.push(currentlySelectedItem);
}
}
}
commit("setNextPage", nextPage);
commit("setFeeds", feeds);
commit("setFeedItems", feedItems);
},
/**
* Infinite loading
*/
async loadMoreFeedItems({ getters, commit }) {
if (!getters.nextPage || !getters.feeds) {
return;
}
const items = getters.feedItems;
const data = await api.get(route("feed_item.index"), {
page: getters.nextPage,
feeds: getters.feeds,
});
const newItems = [...items, ...data.data];
commit(
"setNextPage",
data.next_page_url !== null ? data.current_page + 1 : 0
);
commit("setFeeds", getters.feeds);
commit("setFeedItems", newItems);
},
/**
* Change selected feed items
*/
async selectFeedItems({ commit }, feedItems) {
if (feedItems.length === 1) {
const data = await api.get(route("feed_item.show", feedItems[0]));
commit("update", { feedItem: feedItems[0], newProperties: data });
}
commit("setSelectedFeedItems", feedItems);
},
/**
* Select first unread item in current list
*/
selectFirstUnreadFeedItem({ getters, dispatch }, exclude) {
if (!exclude) {
exclude = [];
}
const nextFeedItem = collect(getters.feedItems)
.where("feed_item_states_count", ">", 0)
.whereNotIn("id", exclude)
.first();
if (nextFeedItem) {
dispatch("selectFeedItems", [nextFeedItem]);
} else {
dispatch(
"documents/selectFirstDocumentWithUnreadItems",
{ selectFirstUnread: true },
{
root: true,
}
);
}
},
selectNextUnreadFeedItem({ getters, dispatch }, currentId) {
if (!currentId) {
return dispatch("selectFirstUnreadFeedItem");
}
const nextFeedItem = collect(getters.feedItems)
.where("feed_item_states_count", ">", 0)
.skipUntil((item) => item.id === currentId)
.skip(1)
.shift();
if (nextFeedItem) {
dispatch("selectFeedItems", [nextFeedItem]);
} else {
dispatch(
"documents/selectFirstDocumentWithUnreadItems",
{ selectFirstUnread: true },
{
root: true,
}
);
}
},
/**
* Mark feed items as read
*/
markAsRead({ dispatch, commit, getters }, data) {
const nextPage = getters.nextPage;
// Avoid unwanted reloading
commit("setNextPage", null);
if ("feed_items" in data) {
dispatch("selectNextUnreadFeedItem", data.feed_items[0]);
} else if ("documents" in data) {
dispatch(
"documents/selectFirstDocumentWithUnreadItems",
{ exclude: data.documents },
{ root: true }
);
}
api.post(route("feed_item.mark_as_read"), data).then(function (
response
) {
dispatch("updateUnreadFeedItemsCount", response);
commit("setNextPage", nextPage);
});
},
/**
* Change number of unread feed items everywhere it's necessary
*/
updateUnreadFeedItemsCount({ commit, dispatch, getters }, data) {
if ("updated_feed_items" in data && data.updated_feed_items !== null) {
const feedItems = collect(getters.feedItems).whereIn(
"id",
data.updated_feed_items
);
feedItems.each(function (feedItem) {
commit("update", {
feedItem: feedItem,
newProperties: {
feed_item_states_count: 0,
},
});
});
}
if ("documents" in data) {
for (var documentId in data.documents) {
dispatch(
"documents/update",
{
documentId: documentId,
newProperties: {
feed_item_states_count: data.documents[documentId],
},
},
{ root: true }
);
}
}
if ("folders" in data) {
for (var folderId in data.folders) {
dispatch(
"folders/updateProperties",
{
folderId: folderId,
newProperties: {
feed_item_states_count: data.folders[folderId],
},
},
{ root: true }
);
}
}
if ("groups" in data) {
for (var groupId in data.groups) {
dispatch(
"groups/updateProperties",
{
groupId: groupId,
newProperties: {
feed_item_states_count: data.groups[groupId],
},
},
{ root: true }
);
}
}
},
};

View File

@@ -0,0 +1,48 @@
export default {
/**
* Feed items present in current selection (folder, document, feed)
*/
feedItems: state => {
return state.feedItems;
},
/**
* Return first feed item in current list
*/
feedItem: state => {
return collect(state.feedItems).first();
},
/**
* Return currently selected feed items
*/
selectedFeedItems: state => {
if(!state.selectedFeedItems) {
return [];
}
return state.selectedFeedItems;
},
/**
* Return the first selected feed item
*/
selectedFeedItem: state => {
return collect(state.selectedFeedItems).first();
},
/**
* Return next page #
*/
nextPage: state => {
return state.nextPage;
},
/**
* Return feeds associated with current feed items
*/
feeds: state => {
return state.feeds;
},
/**
* Return a boolean value indicating if we can load more feed items
*/
canLoadMore: state => {
return state.nextPage > 1;
}
};

12
resources/js/store/modules/feeditems/index.js vendored Executable file
View File

@@ -0,0 +1,12 @@
import state from "./state";
import getters from "./getters";
import actions from "./actions";
import mutations from "./mutations";
export default {
'namespaced': true,
state,
getters,
actions,
mutations
}

View File

@@ -0,0 +1,52 @@
export default {
/**
* Set feed items list
* @param {*} state
* @param {*} feedItems
*/
setFeedItems(state, feedItems) {
state.feedItems = feedItems;
},
/**
* Set next page #
* @param {*} state
* @param {*} page
*/
setNextPage(state, page) {
state.nextPage = page;
},
/**
* Set feeds associated to current feed items
* @param {*} state
* @param {*} feeds
*/
setFeeds(state, feeds) {
state.feeds = feeds;
},
/**
* Set selected feed items
* @param {*} state
* @param {*} feedItems
*/
setSelectedFeedItems(state, feedItems) {
if(!feedItems) {
feedItems = [];
}
state.selectedFeedItems = feedItems;
},
/**
* Update feed item's properties
* @param {*} state
* @param {*} param1
*/
update(state, {feedItem, newProperties}) {
for(var property in newProperties) {
feedItem[property] = newProperties[property];
}
},
};

18
resources/js/store/modules/feeditems/state.js vendored Executable file
View File

@@ -0,0 +1,18 @@
export default {
/**
* Feed items present in current selection (folder, document, feed)
*/
feedItems: [],
/**
* Next page #
*/
nextPage: 0,
/**
* Feeds associated with current feed items
*/
feeds: [],
/**
* Selected feed items
*/
selectedFeedItems: [],
};

231
resources/js/store/modules/folders/actions.js vendored Executable file
View File

@@ -0,0 +1,231 @@
/**
* Actions on folders
*/
export default {
/*
* -------------------------------------------------------------------------
* ----| CRUD |-------------------------------------------------------------
* -------------------------------------------------------------------------
*/
/**
* Display a listing of the resource.
*/
async index({ getters, commit, dispatch }, { folders, show }) {
commit("setFolders", folders);
if (show) {
if (show === "unread_items") {
show = getters.getUnreadItemsFolder;
}
dispatch("show", { folder: show });
} else {
dispatch("show", {});
}
},
/**
* Display the specified resource.
*/
show(
{ commit, getters, dispatch },
{ folder, deselectDocuments = true, updateFeedItems = true }
) {
const currentSelectedFolder = getters.selectedFolder;
if (!folder) {
folder = currentSelectedFolder;
} else if (Number.isInteger(folder)) {
folder = getters.folders.find((f) => f.id === folder);
}
commit("setSelectedFolder", folder);
if (deselectDocuments) {
dispatch("documents/selectDocuments", [], { root: true });
}
api.get(route("folder.show", folder)).then(function (response) {
dispatch(
"documents/index",
{ documents: response, updateFeedItems: updateFeedItems },
{ root: true }
);
});
},
/**
* Load folder's details
*/
async loadDetails({ dispatch }, folder) {
if (!folder.details_loaded && !folder.details_loading) {
dispatch("updateProperties", {
folderId: folder.id,
newProperties: {
details_loading: true,
},
});
let response = await api.get(route("folder.details", folder));
response.details_loaded = true;
response.details_loading = false;
dispatch("updateProperties", {
folderId: folder.id,
newProperties: response,
});
}
},
/**
* Store a newly created resource in storage.
*/
store({ dispatch }, { title, parent_id, group_id }) {
return api
.post(route("folder.store"), {
title,
parent_id,
group_id,
})
.then((data) => {
dispatch("index", { folders: data });
})
.catch((error) => console.error(error));
},
/**
* Update the specified resource in storage.
*/
async update({ dispatch }, { folder, newProperties }) {
const data = await api.put(
route("folder.update", folder),
newProperties
);
dispatch("updateProperties", {
folderId: folder.id,
newProperties: data,
});
},
/**
* Update the specified resource in storage.
*/
updateProperties({ commit, getters }, { folderId, newProperties }) {
const folder = getters.folders.find((f) => f.id == folderId);
if (!folder) {
return;
}
commit("update", { folder: folder, newProperties: newProperties });
},
updatePermission({ dispatch }, { folder, user, ability, granted }) {
api.post(route("folder.set_permission", folder), {
ability: ability,
granted: granted,
user_id: user,
}).then(function (response) {
dispatch("updateProperties", {
folderId: folder.id,
newProperties: response,
});
});
},
/**
* Remove the specified resource from storage.
*/
async destroy({ dispatch }, folder) {
const data = await api.delete(route("folder.destroy", folder));
dispatch("index", { folders: data });
},
/**
* Toggle specified folder expanded/collapsed
*/
toggleExpanded({ dispatch }, folder) {
dispatch("update", {
folder: folder,
newProperties: {
...folder,
...{
is_expanded: !folder.is_expanded,
},
},
});
},
/**
* Toggle specified folder's branch expanded/collapsed
*/
async toggleBranch({ commit }, folder) {
const response = await api.post(route("folder.toggle_branch", folder));
commit("setFolders", response);
},
/*
* -------------------------------------------------------------------------
* ----| Drag'n'drop |------------------------------------------------------
* -------------------------------------------------------------------------
*/
/**
* Remind folder being dragged
*/
startDraggingFolder({ commit }, folder) {
commit("setDraggedFolder", folder);
},
/**
* Forget folder being dragged
*/
stopDraggingFolder({ commit }) {
commit("setDraggedFolder", null);
},
/**
* Drop something into specified folder
*/
async dropIntoFolder({ getters, dispatch }, folder) {
var sourceFolder = getters.draggedFolder;
if (!sourceFolder) {
sourceFolder = getters.selectedFolder;
await dispatch(
"documents/dropIntoFolder",
{ sourceFolder: sourceFolder, targetFolder: folder },
{ root: true }
);
return;
}
if (
sourceFolder.parent_id === folder.id ||
sourceFolder.id === folder.id
) {
return;
}
const newProperties = {
parent_id: folder.id,
};
await dispatch("update", {
folder: sourceFolder,
newProperties: {
...sourceFolder,
...newProperties,
},
}).then(function () {
dispatch("groups/show", {}, { root: true });
});
},
};

37
resources/js/store/modules/folders/getters.js vendored Executable file
View File

@@ -0,0 +1,37 @@
/**
* Folders getters
*/
export default {
/**
* Return folders tree
*/
folders: state => {
return state.folders;
},
/**
* Return currently selected folder
*/
selectedFolder: state => {
var folders = state.folders ? state.folders : [];
return folders.find(folder => folder.is_selected);
},
/**
* Return folder being dragged
*/
draggedFolder: state => {
return state.draggedFolder;
},
/**
* Return a folder by its id
*/
getFolderById: (state) => (id) => {
return state.folders.find(folder => folder.id === id);
},
/**
* Return the "unread items" folder
*/
getUnreadItemsFolder: (state) => {
return state.folders.find(folder => folder.type === 'unread_items');
}
}

12
resources/js/store/modules/folders/index.js vendored Executable file
View File

@@ -0,0 +1,12 @@
import state from "./state";
import getters from "./getters";
import actions from "./actions";
import mutations from "./mutations";
export default {
'namespaced': true,
state,
getters,
actions,
mutations
}

View File

@@ -0,0 +1,52 @@
/**
* Folders mutations
*/
export default {
/**
* Store folders list
* @param {*} state
* @param {*} folders
*/
setFolders(state, folders) {
state.folders = folders;
},
/**
* Unselect all folders, and mark specified folder as selected
* @param {*} state
* @param {*} folder
*/
setSelectedFolder(state, folder) {
state.folders.find((f) => (f.is_selected = false));
folder.is_selected = true;
},
/**
* Update folder's properties
* @param {*} state
* @param {*} param1
*/
update(state, { folder, newProperties }) {
for (var property in newProperties) {
folder[property] = newProperties[property];
}
if (folder.type === "unread_items") {
if (folder.feed_item_states_count) {
folder.iconColor = "folder-unread-not-empty";
} else {
folder.iconColor = "folder-unread";
}
}
},
/**
* Store folder being dragged during a drag'n'drop process
* @param {*} state
* @param {*} folder
*/
setDraggedFolder(state, folder) {
state.draggedFolder = folder;
},
};

10
resources/js/store/modules/folders/state.js vendored Executable file
View File

@@ -0,0 +1,10 @@
export default {
/**
* Folders tree
*/
folders: [],
/**
* Folder being dragged
*/
draggedFolder: null
}

137
resources/js/store/modules/groups/actions.js vendored Executable file
View File

@@ -0,0 +1,137 @@
/**
* Actions on groups
*/
export default {
/*
* -------------------------------------------------------------------------
* ----| CRUD |-------------------------------------------------------------
* -------------------------------------------------------------------------
*/
/**
* Display a listing of the resource - Groups in which user is active
*/
async indexActive({ commit }) {
const data = await api.get(route("group.index_active"));
commit("setGroups", data);
},
/**
* Display a listing of the resource - Groups associated with this user
*/
async indexMyGroups({ commit }) {
const data = await api.get(route("group.my_groups"));
commit("setGroups", data);
},
/**
* Display the specified resource.
*/
show({ getters, dispatch, rootGetters }, { group, folder }) {
const currentSelectedGroup = getters.selectedGroup;
if (!group) {
group = currentSelectedGroup;
} else if (Number.isInteger(group)) {
group = getters.groups.find((g) => g.id === group);
}
dispatch("selectGroup", group);
dispatch("documents/selectDocuments", [], { root: true });
api.get(route("group.show", group)).then(function (folders) {
if (folder) {
dispatch(
"folders/index",
{ folders: folders, show: folder },
{ root: true }
);
} else {
dispatch("folders/index", { folders: folders }, { root: true });
}
});
},
/**
* Mark specified group as selected
*/
selectGroup({ commit, getters }, group) {
if (Number.isInteger(group)) {
group = getters.groups.find((g) => g.id === group);
}
commit("setSelectedGroup", group);
},
/**
* Update a group
*/
updateGroup({ dispatch }, { group, newProperties }) {
api.put(route("group.update", group), newProperties).then(function (
response
) {
dispatch("updateProperties", {
groupId: group.id,
newProperties: response,
});
});
},
/**
* Delete group
*/
deleteGroup({ commit }, group) {
api.delete(route("group.destroy", group)).then(function (response) {
commit("setGroups", response);
});
},
/**
* Update the specified resource in storage.
*/
updateProperties({ commit, getters }, { groupId, newProperties }) {
const group = getters.groups.find((g) => g.id == groupId);
if (!group) {
console.warn("Group #" + groupId + " not found");
return;
}
commit("update", { group: group, newProperties: newProperties });
},
/**
* Create a group
*/
createGroup({ commit }, properties) {
api.post(route("group.store"), properties).then(function (response) {
commit("setGroups", response);
});
},
/**
* Update my groups positions
*/
updatePositions({ getters, commit }, { positions }) {
for (var groupId in positions) {
const group = getters.groups.find((g) => g.id == groupId);
if (!group) {
console.warn("Group #" + groupId + " not found");
return;
}
commit("updatePosition", {
group: group,
position: positions[groupId],
});
}
api.post(route("group.update_positions"), {
positions: positions,
});
},
};

19
resources/js/store/modules/groups/getters.js vendored Executable file
View File

@@ -0,0 +1,19 @@
/**
* Groups getters
*/
export default {
/**
* Return groups
*/
groups: state => {
return state.groups;
},
/**
* Return currently selected group
*/
selectedGroup: state => {
var groups = state.groups ? state.groups : [];
return groups.find(group => group.is_selected);
}
}

12
resources/js/store/modules/groups/index.js vendored Executable file
View File

@@ -0,0 +1,12 @@
import state from "./state";
import getters from "./getters";
import actions from "./actions";
import mutations from "./mutations";
export default {
'namespaced': true,
state,
getters,
actions,
mutations
}

View File

@@ -0,0 +1,39 @@
/**
* Groups mutations
*/
export default {
/**
* Store groups list
* @param {*} state
* @param {*} groups
*/
setGroups(state, groups) {
state.groups = groups;
},
/**
* Unselect all groups, and mark specified group as selected
* @param {*} state
* @param {*} group
*/
setSelectedGroup(state, group) {
state.groups.find(g => (g.is_selected = false));
group.is_selected = true;
},
/**
* Update group's properties
* @param {*} state
* @param {*} param1
*/
update(state, { group, newProperties }) {
for (var property in newProperties) {
group[property] = newProperties[property];
}
},
updatePosition(state, { group, position }) {
group.pivot.position = position;
}
};

6
resources/js/store/modules/groups/state.js vendored Executable file
View File

@@ -0,0 +1,6 @@
export default {
/**
* Groups
*/
groups: []
}