Add tags view

cache
Varun Patil 2022-10-06 16:28:35 -07:00
parent 8e0d818337
commit f5baf36109
10 changed files with 304 additions and 32 deletions

View File

@ -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'],

View File

@ -104,4 +104,12 @@ class PageController extends Controller {
public function thisday() {
return $this->main();
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function tags() {
return $this->main();
}
}

View File

@ -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) {

View File

@ -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>

View File

@ -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

View File

@ -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;
}

View File

@ -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'),
}),
},
],
})

View File

@ -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)),
}]
}

View File

@ -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,
},
}

View File

@ -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;