Create photos sidebar tab

Signed-off-by: Louis Chemineau <louis@chmn.me>
This commit is contained in:
Louis Chemineau 2023-11-07 16:51:42 +01:00
parent 5d5bcee6a6
commit 88f9312750
No known key found for this signature in database
9 changed files with 659 additions and 10 deletions

View File

@ -27,6 +27,7 @@ namespace OCA\Photos\AppInfo;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\Files\Event\LoadSidebar;
use OCA\Photos\Listener\AlbumsManagementEventListener;
use OCA\Photos\Listener\ExifMetadataProvider;
use OCA\Photos\Listener\OriginalDateTimeMetadataProvider;
@ -43,6 +44,7 @@ use OCP\FilesMetadata\Event\MetadataBackgroundEvent;
use OCP\FilesMetadata\Event\MetadataLiveEvent;
use OCP\Group\Events\GroupDeletedEvent;
use OCP\Group\Events\UserRemovedEvent;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
use OCP\Share\Events\ShareDeletedEvent;
use OCP\SystemTag\MapperEvent;
use OCP\User\Events\UserDeletedEvent;
@ -79,6 +81,10 @@ class Application extends App implements IBootstrap {
/** Register $principalBackend for the DAV collection */
$context->registerServiceAlias('principalBackend', Principal::class);
$context->registerEventListener(LoadSidebar::class, LoadSidebarScripts::class);
$context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);
// Metadata
$context->registerEventListener(MetadataLiveEvent::class, ExifMetadataProvider::class);
$context->registerEventListener(MetadataLiveEvent::class, SizeMetadataProvider::class);

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023, Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Photos\Listener;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
/**
* @template-implements IEventListener<Event>
*/
class CSPListener implements IEventListener {
public function __construct(
) {
}
public function handle(Event $event): void {
if (!($event instanceof AddContentSecurityPolicyEvent)) {
return;
}
$csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
$event->addPolicy($csp);
}
}

View File

@ -20,7 +20,7 @@ declare(strict_types=1);
*
*/
namespace OCA\Photos\Listener;
namespace OCA\Photos\MetadataProvider;
use OCA\Photos\AppInfo\Application;
use OCP\EventDispatcher\Event;
@ -80,11 +80,11 @@ class ExifMetadataProvider implements IEventListener {
}
if ($rawExifData && array_key_exists('EXIF', $rawExifData)) {
$event->getMetadata()->setArray('photos-exif', $this->base64Encode($rawExifData['EXIF']));
$event->getMetadata()->setArray('photos-exif', $this->sanitizeEntries($rawExifData['EXIF']));
}
if ($rawExifData && array_key_exists('IFD0', $rawExifData)) {
$event->getMetadata()->setArray('photos-ifd0', $this->base64Encode($rawExifData['IFD0']));
$event->getMetadata()->setArray('photos-ifd0', $this->sanitizeEntries($rawExifData['IFD0']));
}
if (
@ -142,14 +142,26 @@ class ExifMetadataProvider implements IEventListener {
/**
* Exif data can contain anything.
* This method will base 64 encode any non UTF-8 string in an array.
* This will also remove control characters from UTF-8 strings.
*/
private function base64Encode(array $data): array {
private function sanitizeEntries(array $data): array {
$cleanData = [];
foreach ($data as $key => $value) {
if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) {
$data[$key] = 'base64:'.base64_encode($value);
$value = 'base64:'.base64_encode($value);
} elseif (is_string($value)) {
// TODO: Can be remove when the Sidebar use the @nextcloud/files to fetch and parse the DAV response.
$value = preg_replace('/[[:cntrl:]]/u', '', $value);
}
if (preg_match('/[^a-zA-Z]/', $key) !== 0) {
$key = preg_replace('/[^a-zA-Z]/', '_', $key);
}
$cleanData[$key] = $value;
}
return $data;
return $cleanData;
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023, Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Photos\Listener;
use OCA\Files\Event\LoadSidebar;
use OCA\Photos\AppInfo\Application;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IRequest;
use OCP\Util;
/**
* @template-implements IEventListener<Event>
*/
class LoadSidebarScripts implements IEventListener {
public function __construct(
private IRequest $request,
) {
}
public function handle(Event $event): void {
if (!($event instanceof LoadSidebar)) {
return;
}
// Only load sidebar tab in the photos app.
if (!preg_match('/^photos\.page\..+/', $this->request->getParams()['_route'])) {
return;
}
Util::addScript(Application::APP_ID, 'photos-sidebar');
}
}

45
package-lock.json generated
View File

@ -10,7 +10,7 @@
"license": "agpl",
"dependencies": {
"@essentials/request-timeout": "^1.3.0",
"@mdi/svg": "^7.1.96",
"@mdi/svg": "^7.3.67",
"@nextcloud/auth": "^2.1.0",
"@nextcloud/axios": "^2.1.0",
"@nextcloud/dialogs": "^4.1.0",
@ -28,6 +28,7 @@
"camelcase": "^7.0.0",
"debounce": "^1.2.1",
"he": "^1.2.0",
"leaflet-defaulticon-compatibility": "^0.1.2",
"path-posix": "^1.0.0",
"qs": "^6.11.2",
"url-parse": "^1.5.10",
@ -36,6 +37,7 @@
"vue-router": "^3.6.5",
"vue-template-compiler": "^2.7.14",
"vue-virtual-grid": "^2.5.0",
"vue2-leaflet": "^2.7.1",
"vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0",
"webdav": "^4.11.0"
@ -3112,8 +3114,9 @@
}
},
"node_modules/@mdi/svg": {
"version": "7.1.96",
"license": "Apache-2.0"
"version": "7.3.67",
"resolved": "https://registry.npmjs.org/@mdi/svg/-/svg-7.3.67.tgz",
"integrity": "sha512-KNr7D8jbu8DEprgRckVywVBkajsGGqocFjOzlekv35UedLjpkMDTkFO8VYnhnLySL0QaPBa568fe8BZsB0TBJQ=="
},
"node_modules/@nextcloud/auth": {
"version": "2.1.0",
@ -4802,6 +4805,12 @@
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.13",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
"integrity": "sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ==",
"peer": true
},
"node_modules/@types/graceful-fs": {
"version": "4.1.6",
"dev": true,
@ -4889,6 +4898,15 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/leaflet": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.7.tgz",
"integrity": "sha512-FOfKB1ALYUDnXkH7LfTFreWiZr9R7GErqGP+8lYQGWr2GFq5+jy3Ih0M7e9j41cvRN65kLALJ4dc43yZwyl/6g==",
"peer": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/mdast": {
"version": "3.0.11",
"license": "MIT",
@ -14537,6 +14555,17 @@
"node": "> 0.8"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"peer": true
},
"node_modules/leaflet-defaulticon-compatibility": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.2.tgz",
"integrity": "sha512-IrKagWxkTwzxUkFIumy/Zmo3ksjuAu3zEadtOuJcKzuXaD76Gwvg2Z1mLyx7y52ykOzM8rAH5ChBs4DnfdGa6Q=="
},
"node_modules/leven": {
"version": "3.1.0",
"dev": true,
@ -20417,6 +20446,16 @@
"vue": "^2.5.0"
}
},
"node_modules/vue2-leaflet": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/vue2-leaflet/-/vue2-leaflet-2.7.1.tgz",
"integrity": "sha512-K7HOlzRhjt3Z7+IvTqEavIBRbmCwSZSCVUlz9u4Rc+3xGCLsHKz4TAL4diAmfHElCQdPPVdZdJk8wPUt2fu6WQ==",
"peerDependencies": {
"@types/leaflet": "^1.5.7",
"leaflet": "^1.3.4",
"vue": "^2.5.17"
}
},
"node_modules/vuex": {
"version": "3.6.2",
"license": "MIT",

View File

@ -39,7 +39,7 @@
},
"dependencies": {
"@essentials/request-timeout": "^1.3.0",
"@mdi/svg": "^7.1.96",
"@mdi/svg": "^7.3.67",
"@nextcloud/auth": "^2.1.0",
"@nextcloud/axios": "^2.1.0",
"@nextcloud/dialogs": "^4.1.0",
@ -57,6 +57,7 @@
"camelcase": "^7.0.0",
"debounce": "^1.2.1",
"he": "^1.2.0",
"leaflet-defaulticon-compatibility": "^0.1.2",
"path-posix": "^1.0.0",
"qs": "^6.11.2",
"url-parse": "^1.5.10",
@ -65,6 +66,7 @@
"vue-router": "^3.6.5",
"vue-template-compiler": "^2.7.14",
"vue-virtual-grid": "^2.5.0",
"vue2-leaflet": "^2.7.1",
"vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0",
"webdav": "^4.11.0"

View File

@ -0,0 +1,122 @@
<!--
- @copyright Copyright (c) 2023 Louis Chemineau <louis@chmn.me>
-
- @author Louis Chemineau <louis@chmn.me>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<LMap class="location-map"
:zoom="previewZoom"
:center="center"
:options="{
scrollWheelZoom: false,
zoomControl: false,
dragging: false,
attributionControl: false,
}"
@scroll.prevent="">
<LTileLayer :url="url" />
<LControlAttribution position="bottomright"
:prefix="attribution" />
<LMarker :lat-lng="center">
<LTooltip :options="{
direction: 'top',
permanent: 'true',
offset: [-16,-14]}">
{{ name }}
</LTooltip>
</LMarker>
</LMap>
</template>
<script>
import {
LControlAttribution,
LTooltip,
LMap,
LMarker,
LTileLayer,
} from 'vue2-leaflet'
// Leaflet icon patch
import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css' // Re-uses images from ~leaflet package
import 'leaflet/dist/leaflet.css'
// eslint-disable-next-line
import 'leaflet-defaulticon-compatibility'
export default {
name: 'LocationMap',
components: {
LControlAttribution,
LTileLayer,
LMap,
LMarker,
LTooltip,
},
props: {
/**
* The latitude of the location
*/
latitude: {
type: Number,
required: true,
},
/**
* The longitude of the location
*/
longitude: {
type: Number,
required: true,
},
/**
* The name of the location
*/
name: {
type: String,
default: '',
},
},
data() {
return {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
// The zoom level of the map in the messages list
previewZoom: 13,
attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',
}
},
computed: {
/** @return {[number, number]} */
center() {
return [this.latitude, this.longitude]
},
},
}
</script>
<style scoped lang="scss">
.location-map {
position: relative;
margin: 16px;
border-radius: var(--border-radius-large);
height: 250px;
width: 90%;
}
</style>

101
src/sidebar.js Normal file
View File

@ -0,0 +1,101 @@
/**
* @copyright Copyright (c) 2023 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import Vue from 'vue'
// eslint-disable-next-line n/no-missing-import, import/no-unresolved
import InformationSlabSymbol from '@mdi/svg/svg/information-slab-symbol.svg?raw'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import { getRequestToken } from '@nextcloud/auth'
import { generateFilePath } from '@nextcloud/router'
import { registerDavProperty } from '@nextcloud/files'
Vue.prototype.t = t
Vue.prototype.n = n
__webpack_nonce__ = btoa(getRequestToken() ?? '')
__webpack_public_path__ = generateFilePath('photos', '', 'js/')
registerDavProperty('nc:metadata-photos-original_date_time', { nc: 'http://nextcloud.org/ns' })
registerDavProperty('nc:metadata-photos-exif', { nc: 'http://nextcloud.org/ns' })
registerDavProperty('nc:metadata-photos-ifd0', { nc: 'http://nextcloud.org/ns' })
registerDavProperty('nc:metadata-photos-gps', { nc: 'http://nextcloud.org/ns' })
registerDavProperty('nc:metadata-photos-place', { nc: 'http://nextcloud.org/ns' })
// Init Photos tab component
let PhotosTabView = null
let PhotosTabInstance = null
const photosTab = new OCA.Files.Sidebar.Tab({
id: 'photos',
name: t('photos', 'Details'),
iconSvg: InformationSlabSymbol,
async mount(el, fileInfo, context) {
// only load if needed
if (PhotosTabView === null) {
const { default: PhotosTab } = await import('./views/PhotosTab.vue')
PhotosTabView = PhotosTabView ?? Vue.extend(PhotosTab)
}
// destroy previous instance if available
if (PhotosTabInstance) {
PhotosTabInstance.$destroy()
}
PhotosTabInstance = new PhotosTabView({
// Better integration with vue parent component
parent: context,
})
// No need to await this, we will show a loading indicator instead
PhotosTabInstance.update(fileInfo)
PhotosTabInstance.$mount(el)
},
update(fileInfo) {
PhotosTabInstance.update(fileInfo)
},
destroy() {
PhotosTabInstance.$destroy()
PhotosTabInstance = null
},
})
window.addEventListener('DOMContentLoaded', async function() {
if (OCA.Files && OCA.Files.Sidebar) {
OCA.Files.Sidebar.registerTab(photosTab)
const { default: PhotosTab } = await import(/* webpackPreload: true */ './views/PhotosTab.vue')
PhotosTabView = PhotosTabView ?? Vue.extend(PhotosTab)
}
/**
*
* @param metadataArray
*/
function parseMetadataArray(metadataArray) {
return metadataArray?.reduce((parsedArray, metadata) => ({ ...parsedArray, [metadata.nodeName]: metadata.textContent }), {})
}
OC.Files.getClient().addFileInfoParser(function(response) {
return {
'metadata-photos-original_date_time': response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-original_date_time`],
'metadata-photos-exif': parseMetadataArray(response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-exif`]),
'metadata-photos-ifd0': parseMetadataArray(response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-ifd0`]),
'metadata-photos-gps': parseMetadataArray(response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-gps`]),
'metadata-photos-place': response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-place`],
}
})
})

259
src/views/PhotosTab.vue Normal file
View File

@ -0,0 +1,259 @@
<!--
- @copyright Copyright (c) 2023 Louis Chemineau <louis@chmn.me>
-
- @author Louis Chemineau <louis@chmn.me>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="photo-details-container">
<div v-if="originalDateTime || (ifd0 && ifd0.ImageWidth && ifd0.ImageLength)" class="photo-detail photo-detail__file">
<CalendarOutline />
<span>
<div v-if="originalDateTime">{{ t('photos', 'Taken on {date} at {time}', { date: takenDate, time: takenTime }) }}</div>
<div class="photo-detail--secondary">{{ size }}<span v-if="ifd0 && (ifd0.ImageWidth && ifd0.ImageLength)"> {{ pixelCount }} {{ ifd0.ImageWidth }} x {{ ifd0.ImageLength }}</span></div>
</span>
</div>
<div v-if="place" class="photo-detail photo-detail__gps">
<div class="photo-detail__gps__title">
<MapMarker /> {{ place }}
</div>
<LocationMap class="photo-detail__gps__map"
:latitude="gps.latitude"
:longitude="gps.longitude"
:name="place" />
</div>
<div v-if="ifd0 && (ifd0.Make || ifd0.Model) || irisInfo.length !== 0" class="photo-detail photo-detail__camera">
<CameraIris />
<span>
<div v-if="ifd0.Make || ifd0.Model">{{ ifd0.Make }} {{ ifd0.Model }}</div>
<div v-if="irisInfo.length !== 0" class="photo-detail--secondary">{{ irisInfo }}</div>
</span>
</div>
</div>
</template>
<script>
import CalendarOutline from 'vue-material-design-icons/CalendarOutline.vue'
import MapMarker from 'vue-material-design-icons/MapMarker.vue'
import CameraIris from 'vue-material-design-icons/CameraIris.vue'
import { translate as t } from '@nextcloud/l10n'
import { formatFileSize } from '@nextcloud/files'
import moment from '@nextcloud/moment'
import LocationMap from '../components/LocationMap.vue'
export default {
name: 'PhotosTab',
components: {
CalendarOutline,
MapMarker,
CameraIris,
LocationMap,
},
data() {
return {
fileInfo: null,
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
// The zoom level of the map in the messages list
previewZoom: 13,
// The zoom level of the map in the new openstreetmap tab upon
// Opening the link
linkZoom: 18,
attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',
}
},
computed: {
/**
* @return {object}
*/
exif() {
return this.fileInfo['metadata-photos-exif']
},
/**
* @return {object}
*/
ifd0() {
return this.fileInfo['metadata-photos-ifd0']
},
/**
* @return {object}
*/
place() {
return this.fileInfo['metadata-photos-place']
},
/**
* @return {object}
*/
gps() {
const gps = this.fileInfo['metadata-photos-gps']
if (!gps) {
return undefined
}
return {
latitude: Number.parseFloat(gps.latitude || 0),
longitude: Number.parseFloat(gps.longitude || 0),
altitude: Number.parseFloat(gps.altitude || 0),
}
},
/**
* @return {object}
*/
originalDateTime() {
return this.fileInfo['metadata-photos-original_date_time'] * 1000
},
/**
* @return {string}
*/
takenDate() {
return moment(this.originalDateTime).format('ll')
},
/**
* @return {string}
*/
takenTime() {
return moment(this.originalDateTime).format('LT')
},
/**
* @return {number}
*/
focal() {
if (!this.exif) {
return 0
}
const [a, b] = this.exif.FNumber.split('/')
return a / b
},
/**
* @return {number}
*/
focalLength() {
if (!this.exif) {
return 0
}
const [a, b] = this.exif.FocalLength.split('/')
return a / b
},
/**
* @return {string}
*/
size() {
return formatFileSize(this.fileInfo.size)
},
/**
* @return {string}
*/
normalizedExposureTime() {
if (!this.exif) {
return 0
}
const [a, b] = this.exif.ExposureTime.split('/')
return Math.round(b / a)
},
/**
* @return {string}
*/
irisInfo() {
const info = []
if (this.focal) {
info.push(`ƒ/${this.focal}`)
}
if (this.normalizedExposureTime) {
info.push(`1/${this.normalizedExposureTime}`)
}
if (this.focalLength) {
info.push(`${this.focalLength}mm`)
}
if (this.exif && this.exif.ISOSpeedRatings) {
info.push(`ISO${this.exif.ISOSpeedRatings}`)
}
return info.join(' ⸱ ')
},
/**
* @return {string}
*/
pixelCount() {
let count = this.ifd0.ImageWidth * this.ifd0.ImageLength
let round = 0
while (count / 1000 > 1) {
count /= 1000
round++
}
const unit = ['', 'K', 'M']
return `${Math.round(count)} ${unit[round]}P`
},
},
methods: {
/**
* Update current fileInfo and fetch new activities
*
* @param {object} fileInfo the current file FileInfo
*/
async update(fileInfo) {
this.fileInfo = fileInfo
},
t,
},
}
</script>
<style scoped lang="scss">
.photo-details-container {
padding: 8px;
.photo-detail {
margin: 16px 0;
display: flex;
flex-direction: row;
&--secondary {
color: var(--color-text-lighter);
}
&__gps {
flex-direction: column;
&__title {
display: flex;
}
&__map {
display: flex;
}
}
.material-design-icon {
margin-right: 8px;
}
}
}
</style>