Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web-app-photos/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
20 changes: 20 additions & 0 deletions packages/web-app-photos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# web-app-photos

A photos dashboard for OpenCloud Web, built as a showcase for the Graph Search API and its
aggregation capabilities.

The start page greets the user with:

- **Memories**: "Do you remember?" strips with photos taken on this day in previous years
(range queries on `photo.takenDateTime`)
- **Library stats**: photo count, total size, camera and place counts (metric aggregations)
- **Photos over time**: a monthly histogram of when photos were taken
- **Cameras**: most used camera models (`terms` aggregation on `photo.cameraModel`)
- **Places**: most photographed places (geohash aggregation on `location`)
- **Tags**: most used tags

Each card displays the underlying aggregation as a small chip, making the demo self-explaining.

Currently the app renders mock data shaped exactly like the Graph Search API responses
(`SearchAggregation`, `SearchBucket`, metric values), so wiring it to a real backend is a matter
of swapping the data source in `src/composables/usePhotoLibrary.ts`.
1 change: 1 addition & 0 deletions packages/web-app-photos/extension.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
1 change: 1 addition & 0 deletions packages/web-app-photos/l10n/translations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
20 changes: 20 additions & 0 deletions packages/web-app-photos/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "web-app-photos",
"version": "0.1.0",
"private": true,
"description": "OpenCloud Web Photos",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
"build": "pnpm vite build",
"build:w": "pnpm vite build --watch --mode development",
"check:types": "vue-tsc --noEmit",
"test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest"
},
"devDependencies": {
"@opencloud-eu/web-client": "^7.0.0",
"@opencloud-eu/web-pkg": "^7.0.0",
"vue": "^3.4.21",
"vue3-gettext": "^4.0.0"
}
}
153 changes: 153 additions & 0 deletions packages/web-app-photos/src/StatisticsView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<template>
<div class="ext:h-full ext:overflow-y-auto ext:bg-role-surface">
<div class="ext:flex ext:flex-col ext:gap-8 ext:px-4 ext:py-4">
<header>
<photos-breadcrumb :items="[{ text: $gettext('Statistics') }]" />
<p
v-if="libraryFacts.length"
class="ext:m-0 ext:mt-3 ext:flex ext:flex-wrap ext:items-baseline ext:gap-x-2 ext:gap-y-1 ext:text-sm ext:text-role-on-surface-variant"
>
<template v-for="(item, index) in libraryFacts" :key="item.label">
<span class="ext:whitespace-nowrap">
<span class="ext:font-semibold ext:tabular-nums ext:text-role-on-surface">{{
item.value
}}</span>
{{ item.label }}
</span>
<span v-if="index < libraryFacts.length - 1" aria-hidden="true">·</span>
</template>
</p>
</header>

<div v-if="loading" class="ext:flex ext:justify-center ext:py-24">
<oc-spinner size="large" :aria-label="$gettext('Loading your photo library')" />
</div>

<div
v-else-if="error"
class="ext:rounded-xl ext:border ext:border-role-outline-variant ext:p-6 ext:text-sm ext:text-role-on-surface-variant"
>
{{ $gettext('The photo library could not be loaded.') }}
<span class="ext:font-mono ext:text-xs">{{ error }}</span>
</div>

<template v-else>
<div class="ext:grid ext:grid-cols-1 ext:gap-5 ext:lg:grid-cols-3">
<section-card :title="$gettext('Photos over time')" class="ext:lg:col-span-2">
<activity-chart v-if="monthly" :aggregation="monthly" @select="openMonth" />
</section-card>

<section-card :title="$gettext('Your cameras')">
<bar-list v-if="cameras" :aggregation="cameras" selectable @select="openCamera" />
<p v-else class="ext:m-0 ext:text-sm ext:text-role-on-surface-variant">
{{ $gettext('No camera information yet.') }}
</p>
</section-card>

<section-card :title="$gettext('Places')">
<bar-list
v-if="places?.buckets?.length"
:aggregation="places"
:format-key="placeLabel"
/>
<p v-else class="ext:m-0 ext:text-sm ext:text-role-on-surface-variant">
{{ $gettext('No photos with location data yet.') }}
</p>
</section-card>

<section-card :title="$gettext('Tags')">
<tag-chips v-if="tags?.buckets?.length" :aggregation="tags" @select="openTag" />
<p v-else class="ext:m-0 ext:text-sm ext:text-role-on-surface-variant">
{{ $gettext('No tagged photos yet.') }}
</p>
</section-card>

<section-card :title="$gettext('How you shoot')">
<ExifFacts v-if="exifFacts.length" :facts="exifFacts" />
<p v-else class="ext:m-0 ext:text-sm ext:text-role-on-surface-variant">
{{ $gettext('No camera metadata yet.') }}
</p>
</section-card>
</div>
</template>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useRouter } from '@opencloud-eu/web-pkg'
import { usePhotoLibrary } from './composables/usePhotoLibrary'
import { formatBytes, formatCount, formatCoordinates, geohashDecode } from './helpers'
import { placeName } from './places'
import ActivityChart from './components/ActivityChart.vue'
import BarList from './components/BarList.vue'
import ExifFacts from './components/ExifFacts.vue'
import PhotosBreadcrumb from './components/PhotosBreadcrumb.vue'
import SectionCard from './components/SectionCard.vue'
import TagChips from './components/TagChips.vue'

const { $gettext, current: currentLanguage } = useGettext()
const router = useRouter()

function openMonth(monthKey: string) {
router.push({ name: 'photos-timeline', query: { date: monthKey } })
}

/** KQL quoted strings know no escaping: a literal quote (or backslash) can
* not be expressed at all, so strip it instead of producing broken KQL */
function kqlValue(value: string): string {
return value.replace(/["\\]/g, '')
}

function openCamera(model: string) {
router.push({
name: 'photos-timeline',
query: { filter: `photo.cameraModel:"${kqlValue(model)}"` }
})
}

function openTag(tag: string) {
router.push({ name: 'photos-timeline', query: { filter: `tag:"${kqlValue(tag)}"` } })
}

const { load, loading, error, stats, cameras, monthly, places, tags, exifFacts } = usePhotoLibrary()

onMounted(() => {
load()
})

const libraryFacts = computed(() => {
const s = stats.value
if (!s) {
return []
}
const language = currentLanguage
const items: { value: string; label: string }[] = [
{ value: formatCount(s.totalPhotos, language), label: $gettext('photos') }
]
if (s.totalBytes) {
items.push({ value: formatBytes(s.totalBytes, language), label: '' })
}
if (s.cameraCount) {
items.push({ value: formatCount(s.cameraCount, language), label: $gettext('cameras') })
}
if (s.placeCount) {
items.push({ value: formatCount(s.placeCount, language), label: $gettext('places') })
}
if (s.videoCount) {
items.push({ value: formatCount(s.videoCount, language), label: $gettext('videos') })
}
return items
})

function placeLabel(key: string): string {
const known = placeName(key)
if (known) {
return known
}
const { latitude, longitude } = geohashDecode(key)
return `${key} · ${formatCoordinates(latitude, longitude)}`
}
</script>
107 changes: 107 additions & 0 deletions packages/web-app-photos/src/TimelineView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<template>
<div class="ext:flex ext:h-full ext:flex-col ext:bg-role-surface">
<header
class="ext:flex ext:flex-wrap ext:items-baseline ext:gap-x-3 ext:px-4 ext:pt-4 ext:pb-2"
>
<photos-breadcrumb :items="[{ text: $gettext('Timeline') }]" />
<span v-if="total !== null" class="ext:text-sm ext:text-role-on-surface-variant">
{{ countLabel }}
</span>
<button
v-if="filter"
type="button"
class="ext:flex ext:cursor-pointer ext:items-center ext:gap-1.5 ext:rounded-full ext:border-0 ext:bg-role-secondary-container ext:px-3 ext:py-1 ext:text-xs ext:text-role-on-secondary-container ext:hover:opacity-80"
:class="{ 'ext:font-mono': !filterLabel.known }"
:aria-label="$gettext('Remove filter')"
:title="$gettext('Remove filter')"
@click="clearFilter"
>
{{ filterLabel.text }}
<oc-icon name="close" size="small" fill-type="line" />
</button>
</header>
<div class="ext:min-h-0 ext:flex-1 ext:px-4 ext:pb-2">
<photo-timeline
ref="timeline"
:query="timelineQuery"
:filtered="!!filter"
class="ext:h-full"
@loaded="total = $event"
>
<template #leading>
<memory-strip
v-if="memoryGroups.length && !filter"
:groups="memoryGroups"
:mode="memoryMode"
class="ext:mb-6"
@select="showInTimeline"
/>
</template>
</photo-timeline>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useGettext } from 'vue3-gettext'
import { queryItemAsString, useRouteQuery, useRouter } from '@opencloud-eu/web-pkg'
import { formatCount } from './helpers'
import { Photo } from './types'
import { useMemories } from './composables/useMemories'
import MemoryStrip from './components/MemoryStrip.vue'
import PhotosBreadcrumb from './components/PhotosBreadcrumb.vue'
import PhotoTimeline from './components/PhotoTimeline.vue'

const { $ngettext, $gettext, current: currentLanguage } = useGettext()
const router = useRouter()

// an extra KQL restriction from the statistics page, e.g. a camera model
const filterQuery = useRouteQuery('filter')
const filter = computed(() => queryItemAsString(filterQuery.value) ?? '')
const timelineQuery = computed(() =>
filter.value ? `mediatype:image AND (${filter.value})` : 'mediatype:image'
)

// the statistics page links with two known KQL shapes; show those as plain
// text and fall back to the raw query (in mono) for anything else
const filterLabel = computed(() => {
const camera = /^photo\.cameraModel:"(.+)"$/.exec(filter.value)
if (camera) {
return { known: true, text: $gettext('Camera: %{ name }', { name: camera[1] }) }
}
const tag = /^tag:"(.+)"$/.exec(filter.value)
if (tag) {
return { known: true, text: $gettext('Tag: %{ name }', { name: tag[1] }) }
}
return { known: false, text: filter.value }
})

function clearFilter() {
const rest = { ...router.currentRoute.value.query }
delete rest.filter
router.replace({ query: rest })
}

const total = ref<number | null>(null)

const timeline = ref<InstanceType<typeof PhotoTimeline> | null>(null)

const { groups: memoryGroups, mode: memoryMode, load: loadMemories } = useMemories()

function showInTimeline(photo: Photo) {
timeline.value?.scrollToPhoto(photo)
}
onMounted(() => {
// fire and forget: the timeline must not wait for the anniversary lookups
loadMemories().catch(() => {
memoryGroups.value = []
})
})

const countLabel = computed(() =>
$ngettext('%{ n } photo', '%{ n } photos', total.value ?? 0, {
n: formatCount(total.value ?? 0, currentLanguage)
})
)
</script>
Loading