Add tags view
parent
8e0d818337
commit
f5baf36109
|
@ -15,6 +15,14 @@ return [
|
|||
['name' => 'page#videos', 'url' => '/videos', 'verb' => 'GET'],
|
||||
['name' => 'page#archive', 'url' => '/archive', 'verb' => 'GET'],
|
||||
['name' => 'page#thisday', 'url' => '/thisday', 'verb' => 'GET'],
|
||||
['name' => 'page#tags', 'url' => '/tags/{name}', 'verb' => 'GET',
|
||||
'requirements' => [
|
||||
'name' => '.*',
|
||||
],
|
||||
'defaults' => [
|
||||
'name' => '',
|
||||
]
|
||||
],
|
||||
|
||||
// API
|
||||
['name' => 'api#days', 'url' => '/api/days', 'verb' => 'GET'],
|
||||
|
|
|
@ -104,4 +104,12 @@ class PageController extends Controller {
|
|||
public function thisday() {
|
||||
return $this->main();
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function tags() {
|
||||
return $this->main();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,10 @@
|
|||
:title="t('memories', 'On this day')">
|
||||
<CalendarIcon slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem :to="{name: 'tags'}"
|
||||
:title="t('memories', 'Tags')">
|
||||
<TagsIcon slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
<template #footer>
|
||||
<NcAppNavigationSettings :title="t('memories', 'Settings')">
|
||||
|
@ -74,6 +78,7 @@ import Star from 'vue-material-design-icons/Star.vue'
|
|||
import Video from 'vue-material-design-icons/Video.vue'
|
||||
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
|
||||
import CalendarIcon from 'vue-material-design-icons/Calendar.vue';
|
||||
import TagsIcon from 'vue-material-design-icons/Tag.vue';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
|
@ -92,6 +97,7 @@ import CalendarIcon from 'vue-material-design-icons/Calendar.vue';
|
|||
Video,
|
||||
ArchiveIcon,
|
||||
CalendarIcon,
|
||||
TagsIcon,
|
||||
},
|
||||
})
|
||||
export default class App extends Mixins(GlobalMixin) {
|
||||
|
|
|
@ -0,0 +1,159 @@
|
|||
<template>
|
||||
<div class="tag" v-bind:class="{
|
||||
hasPreview: previews.length > 0,
|
||||
onePreview: previews.length === 1,
|
||||
hasError: error,
|
||||
}"
|
||||
@click="openTag(data)"
|
||||
v-bind:style="{
|
||||
width: rowHeight + 'px',
|
||||
height: rowHeight + 'px',
|
||||
}">
|
||||
<div class="big-icon">
|
||||
<div class="name">{{ data.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="img-outer" v-for="info of previews" :key="info.fileid">
|
||||
<img
|
||||
:key="'fpreview-' + info.fileid"
|
||||
:src="getPreviewUrl(info.fileid, info.etag)"
|
||||
:class="{
|
||||
'p-loading': !(info.flag & c.FLAG_LOADED),
|
||||
'p-load-fail': info.flag & c.FLAG_LOAD_FAIL,
|
||||
}"
|
||||
@load="info.flag |= c.FLAG_LOADED"
|
||||
@error="info.flag |= c.FLAG_LOAD_FAIL" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Watch, Mixins } from 'vue-property-decorator';
|
||||
import { IPhoto, ITag } from '../types';
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { getPreviewUrl } from "../services/FileUtils";
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import GlobalMixin from '../mixins/GlobalMixin';
|
||||
|
||||
@Component({})
|
||||
export default class Tag extends Mixins(GlobalMixin) {
|
||||
@Prop() data: ITag;
|
||||
@Prop() rowHeight: number;
|
||||
|
||||
// Separate property because the one on data isn't reactive
|
||||
private previews: IPhoto[] = [];
|
||||
|
||||
// Error occured fetching thumbs
|
||||
private error = false;
|
||||
|
||||
/** Passthrough */
|
||||
private getPreviewUrl = getPreviewUrl;
|
||||
|
||||
mounted() {
|
||||
this.refreshPreviews();
|
||||
}
|
||||
|
||||
@Watch('data')
|
||||
dataChanged() {
|
||||
this.refreshPreviews();
|
||||
}
|
||||
|
||||
/** Refresh previews */
|
||||
async refreshPreviews() {
|
||||
// Reset state
|
||||
this.error = false;
|
||||
|
||||
// Get previews
|
||||
const url = `/apps/memories/api/days/*?limit=4&tag=${this.data.name}`;
|
||||
try {
|
||||
const res = await axios.get<IPhoto[]>(generateUrl(url));
|
||||
if (res.data.length < 4) {
|
||||
res.data = res.data.slice(0, 1);
|
||||
}
|
||||
res.data.forEach((p) => p.flag = 0);
|
||||
this.previews = res.data;
|
||||
} catch (e) {
|
||||
this.error = true;
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open tag */
|
||||
openTag(tag: ITag) {
|
||||
this.$router.push({ name: 'tags', params: { name: tag.name }});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.big-icon {
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 45%; width: 100%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
> .name {
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
width: 100%;
|
||||
padding: 0 5%;
|
||||
text-align: center;
|
||||
font-size: 1.08em;
|
||||
word-wrap: break-word;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 35%;
|
||||
line-height: 1em;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.previews {
|
||||
z-index: 3;
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
height: calc(100% - 4px);
|
||||
width: calc(100% - 4px);
|
||||
top: 2px; left: 2px;
|
||||
|
||||
> .img-outer {
|
||||
background-color: var(--color-background-dark);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
display: inline-block;
|
||||
|
||||
.tag.onePreview > & {
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
> img {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: brightness(50%);
|
||||
cursor: pointer;
|
||||
|
||||
transition: filter 0.2s ease-in-out;
|
||||
will-change: filter;
|
||||
transform: translateZ(0);
|
||||
|
||||
&.p-loading, &.p-load-fail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tag:hover & {
|
||||
filter: brightness(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -42,6 +42,12 @@
|
|||
:data="photo"
|
||||
:rowHeight="rowHeight"
|
||||
:key="photo.fileid" />
|
||||
|
||||
<Tag v-else-if="photo.flag & c.FLAG_IS_TAG"
|
||||
:data="photo"
|
||||
:rowHeight="rowHeight"
|
||||
:key="photo.fileid" />
|
||||
|
||||
<Photo v-else
|
||||
:data="photo"
|
||||
:rowHeight="rowHeight"
|
||||
|
@ -163,6 +169,7 @@ import * as dav from "../services/DavRequests";
|
|||
import * as utils from "../services/Utils";
|
||||
import axios from '@nextcloud/axios'
|
||||
import Folder from "./Folder.vue";
|
||||
import Tag from "./Tag.vue";
|
||||
import Photo from "./Photo.vue";
|
||||
import EditDate from "./EditDate.vue";
|
||||
import FolderTopMatter from "./FolderTopMatter.vue";
|
||||
|
@ -194,6 +201,7 @@ for (const [key, value] of Object.entries(API_ROUTES)) {
|
|||
@Component({
|
||||
components: {
|
||||
Folder,
|
||||
Tag,
|
||||
Photo,
|
||||
EditDate,
|
||||
FolderTopMatter,
|
||||
|
@ -506,6 +514,11 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
query.set('archive', '1');
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (this.$route.name === 'tags' && this.$route.params.name) {
|
||||
query.set('tag', this.$route.params.name);
|
||||
}
|
||||
|
||||
// Create query string and append to URL
|
||||
const queryStr = query.toString();
|
||||
if (queryStr) {
|
||||
|
@ -539,6 +552,9 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
if (head.dayId === this.TagDayID.FOLDERS) {
|
||||
head.name = this.t("memories", "Folders");
|
||||
return head.name;
|
||||
} else if (head.dayId === this.TagDayID.TAGS) {
|
||||
head.name = this.t("memories", "Tags");
|
||||
return head.name;
|
||||
}
|
||||
|
||||
// Make date string
|
||||
|
@ -564,6 +580,8 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
let data: IDay[] = [];
|
||||
if (this.$route.name === 'thisday') {
|
||||
data = await dav.getOnThisDayData();
|
||||
} else if (this.$route.name === 'tags' && !this.$route.params.name) {
|
||||
data = await dav.getTagsData();
|
||||
} else {
|
||||
data = (await axios.get<IDay[]>(generateUrl(this.appendQuery(url), params))).data;
|
||||
}
|
||||
|
@ -873,9 +891,9 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
photo.flag |= this.c.FLAG_IS_FAVORITE;
|
||||
delete photo.isfavorite;
|
||||
}
|
||||
if (photo.isfolder) {
|
||||
photo.flag |= this.c.FLAG_IS_FOLDER;
|
||||
delete photo.isfolder;
|
||||
if (photo.istag) {
|
||||
photo.flag |= this.c.FLAG_IS_TAG;
|
||||
delete photo.istag;
|
||||
}
|
||||
|
||||
// Move to next index of photo
|
||||
|
|
|
@ -1,27 +1,12 @@
|
|||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import { constants } from '../services/Utils';
|
||||
|
||||
@Component
|
||||
export default class GlobalMixin extends Vue {
|
||||
public readonly t = t;
|
||||
public readonly n = n;
|
||||
|
||||
public readonly c = {
|
||||
FLAG_PLACEHOLDER: 1 << 0,
|
||||
FLAG_LOADED: 1 << 1,
|
||||
FLAG_LOAD_FAIL: 1 << 2,
|
||||
FLAG_IS_VIDEO: 1 << 3,
|
||||
FLAG_IS_FAVORITE: 1 << 4,
|
||||
FLAG_IS_FOLDER: 1 << 5,
|
||||
FLAG_SELECTED: 1 << 6,
|
||||
FLAG_LEAVING: 1 << 7,
|
||||
FLAG_EXIT_LEFT: 1 << 8,
|
||||
FLAG_ENTER_RIGHT: 1 << 9,
|
||||
FLAG_FORCE_RELOAD: 1 << 10,
|
||||
}
|
||||
|
||||
public readonly TagDayID = {
|
||||
START: -(1 << 30),
|
||||
FOLDERS: -(1 << 30) + 1,
|
||||
}
|
||||
public readonly c = constants.c;
|
||||
public readonly TagDayID = constants.TagDayID;
|
||||
}
|
|
@ -46,16 +46,16 @@
|
|||
base: generateUrl('/apps/memories'),
|
||||
linkActiveClass: 'active',
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: Timeline,
|
||||
name: 'timeline',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Timeline'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: Timeline,
|
||||
name: 'timeline',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Timeline'),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
{
|
||||
path: '/folders/:path*',
|
||||
component: Timeline,
|
||||
name: 'folders',
|
||||
|
@ -99,5 +99,14 @@
|
|||
rootTitle: t('memories', 'On this day'),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/tags/:name*',
|
||||
component: Timeline,
|
||||
name: 'tags',
|
||||
props: route => ({
|
||||
rootTitle: t('memories', 'Tags'),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
|
@ -4,7 +4,8 @@ import { encodePath } from '@nextcloud/paths'
|
|||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import { genFileInfo } from './FileUtils'
|
||||
import { IDay, IFileInfo, IPhoto } from '../types';
|
||||
import { IDay, IFileInfo, IPhoto, ITag } from '../types';
|
||||
import { constants, hashCode } from './Utils';
|
||||
import axios from '@nextcloud/axios'
|
||||
import client from './DavClient';
|
||||
|
||||
|
@ -374,7 +375,7 @@ export async function downloadFilesByIds(fileIds: number[]) {
|
|||
* Get the onThisDay data
|
||||
* Query for last 120 years; should be enough
|
||||
*/
|
||||
export async function getOnThisDayData() {
|
||||
export async function getOnThisDayData(): Promise<IDay[]> {
|
||||
const diffs: { [dayId: number]: number } = {};
|
||||
const now = new Date();
|
||||
const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
|
@ -424,4 +425,34 @@ export async function getOnThisDayData() {
|
|||
}
|
||||
|
||||
return ans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of tags and convert to Days response
|
||||
*/
|
||||
export async function getTagsData(): Promise<IDay[]> {
|
||||
// Query for photos
|
||||
let data: {
|
||||
count: number;
|
||||
name: string;
|
||||
}[] = [];
|
||||
try {
|
||||
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/tags'));
|
||||
data = res.data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Convert to days response
|
||||
return [{
|
||||
dayid: constants.TagDayID.TAGS,
|
||||
count: data.length,
|
||||
detail: data.map((tag) => ({
|
||||
name: tag.name,
|
||||
count: tag.count,
|
||||
fileid: hashCode(tag.name),
|
||||
flag: constants.c.FLAG_IS_TAG,
|
||||
istag: true,
|
||||
} as ITag)),
|
||||
}]
|
||||
}
|
|
@ -25,4 +25,43 @@ export function getLongDateStr(date: Date, skipYear=false, time=false) {
|
|||
hour: time ? 'numeric' : undefined,
|
||||
minute: time ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a hash code from a string
|
||||
* @param {String} str The string to hash.
|
||||
* @return {Number} A 32bit integer
|
||||
* @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
|
||||
*/
|
||||
export function hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
let chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export const constants = {
|
||||
c: {
|
||||
FLAG_PLACEHOLDER: 1 << 0,
|
||||
FLAG_LOADED: 1 << 1,
|
||||
FLAG_LOAD_FAIL: 1 << 2,
|
||||
FLAG_IS_VIDEO: 1 << 3,
|
||||
FLAG_IS_FAVORITE: 1 << 4,
|
||||
FLAG_IS_FOLDER: 1 << 5,
|
||||
FLAG_IS_TAG: 1 << 6,
|
||||
FLAG_SELECTED: 1 << 7,
|
||||
FLAG_LEAVING: 1 << 8,
|
||||
FLAG_EXIT_LEFT: 1 << 9,
|
||||
FLAG_ENTER_RIGHT: 1 << 10,
|
||||
FLAG_FORCE_RELOAD: 1 << 11,
|
||||
},
|
||||
|
||||
TagDayID: {
|
||||
START: -(1 << 30),
|
||||
FOLDERS: -(1 << 30) + 1,
|
||||
TAGS: -(1 << 30) + 2,
|
||||
},
|
||||
}
|
|
@ -47,6 +47,8 @@ export type IPhoto = {
|
|||
isfavorite?: boolean;
|
||||
/** Is this a folder */
|
||||
isfolder?: boolean;
|
||||
/** Is this a tag */
|
||||
istag?: boolean;
|
||||
/** Optional datetaken epoch */
|
||||
datetaken?: number;
|
||||
}
|
||||
|
@ -60,6 +62,13 @@ export interface IFolder extends IPhoto {
|
|||
name: string;
|
||||
}
|
||||
|
||||
export interface ITag extends IPhoto {
|
||||
/** Name of tag */
|
||||
name: string;
|
||||
/** Number of images in this tag */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type IRow = {
|
||||
/** Vue Recycler identifier */
|
||||
id?: number;
|
||||
|
|
Loading…
Reference in New Issue