Skip to content

Commit 258db9b

Browse files
committed
Extract Exif data from assets, show map if possible
1 parent 745bfae commit 258db9b

12 files changed

Lines changed: 313 additions & 50 deletions

File tree

cspell.config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ words:
3333
- dialogs
3434
- dompurify
3535
- embeddable
36+
- exif
37+
- exifreader
3638
- Flavored
3739
- Forgejo
3840
- frontmatter

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@sveltia/ui": "^0.27.2",
3939
"@sveltia/utils": "^0.8.0",
4040
"deepmerge": "^4.3.1",
41+
"exifreader": "^4.31.1",
4142
"fast-deep-equal": "^3.1.3",
4243
"flat": "^6.0.1",
4344
"isomorphic-dompurify": "^2.25.0",

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/components/assets/shared/info-panel.svelte

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import mime from 'mime';
66
import { _, locale as appLocale } from 'svelte-i18n';
77
import AssetPreview from '$lib/components/assets/shared/asset-preview.svelte';
8+
import StaticMap from '$lib/components/common/static-map.svelte';
89
import { goto } from '$lib/services/app/navigation';
910
import { defaultAssetDetails, getAssetDetails, isMediaKind } from '$lib/services/assets';
1011
import { getCollectionLabel } from '$lib/services/contents/collection';
@@ -40,10 +41,19 @@
4041
let details = $state({ ...defaultAssetDetails });
4142
4243
const { path, size, kind, commitAuthor, commitDate } = $derived(asset);
43-
const { publicURL, repoBlobURL, dimensions, duration, usedEntries } = $derived(details);
44+
const { publicURL, repoBlobURL, dimensions, duration, createdDate, coordinates, usedEntries } =
45+
$derived(details);
4446
const { extension = '' } = $derived(getPathInfo(path));
4547
const canPreview = $derived(isMediaKind(kind) || path.endsWith('.pdf'));
4648
49+
/**
50+
* Format the date to a localized string.
51+
* @param {Date} date Date to format.
52+
* @returns {string} Formatted date string.
53+
*/
54+
const formatDate = (date) =>
55+
date.toLocaleString($appLocale ?? undefined, DATE_TIME_FORMAT_OPTIONS);
56+
4757
/**
4858
* Update the properties above.
4959
*/
@@ -127,20 +137,6 @@
127137
{/if}
128138
</p>
129139
</section>
130-
{#if commitAuthor}
131-
<section>
132-
<h4>{$_('sort_keys.commit_author')}</h4>
133-
<p>{commitAuthor.name || commitAuthor.login || commitAuthor.email}</p>
134-
</section>
135-
{/if}
136-
{#if commitDate}
137-
<section>
138-
<h4>{$_('sort_keys.commit_date')}</h4>
139-
<p>
140-
{commitDate.toLocaleString($appLocale ?? undefined, DATE_TIME_FORMAT_OPTIONS)}
141-
</p>
142-
</section>
143-
{/if}
144140
<section>
145141
<h4>{$_('used_in')}</h4>
146142
{#each usedEntries as entry (entry.sha)}
@@ -168,6 +164,30 @@
168164
<p>{$_('sort_keys.none')}</p>
169165
{/each}
170166
</section>
167+
{#if commitAuthor}
168+
<section>
169+
<h4>{$_('sort_keys.commit_author')}</h4>
170+
<p>{commitAuthor.name || commitAuthor.login || commitAuthor.email}</p>
171+
</section>
172+
{/if}
173+
{#if commitDate}
174+
<section>
175+
<h4>{$_('sort_keys.commit_date')}</h4>
176+
<p>{formatDate(commitDate)}</p>
177+
</section>
178+
{/if}
179+
{#if createdDate}
180+
<section>
181+
<h4>{$_('created_date')}</h4>
182+
<p>{formatDate(createdDate)}</p>
183+
</section>
184+
{/if}
185+
{#if coordinates}
186+
<section>
187+
<h4>{$_('location')}</h4>
188+
<StaticMap {coordinates} />
189+
</section>
190+
{/if}
171191
</div>
172192
173193
<style lang="scss">
@@ -187,7 +207,9 @@
187207
}
188208
189209
section {
190-
margin: 0 0 16px;
210+
&:not(:last-child) {
211+
margin: 0 0 16px;
212+
}
191213
192214
& > :global(*) {
193215
margin: 0 0 4px;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<!--
2+
@component
3+
Implement a static map using Leaflet, showing a marker at the specified coordinates.
4+
@see https://leafletjs.com/
5+
-->
6+
<script>
7+
import { onMount } from 'svelte';
8+
import { _ } from 'svelte-i18n';
9+
import { getUnpkgURL, loadModule } from '$lib/services/app/dependencies';
10+
11+
/**
12+
* @import { GeoCoordinates } from '$lib/types/private';
13+
*/
14+
15+
/**
16+
* @typedef {object} Props
17+
* @property {GeoCoordinates} coordinates GeoCoordinates of the location to show on the map.
18+
*/
19+
20+
/** @type {Props} */
21+
let {
22+
/* eslint-disable prefer-const */
23+
coordinates,
24+
/* eslint-enable prefer-const */
25+
} = $props();
26+
27+
/** @type {HTMLElement | undefined} */
28+
let mapElement = $state();
29+
30+
/** @type {import('leaflet').Map | undefined} */
31+
let map = undefined;
32+
33+
/**
34+
* Load the Leaflet library and initialize the map. We don’t bundle the library because of the
35+
* bundle size. The component may not be used often, and multiple map services, including Google
36+
* Maps and Here Maps, may be supported in the future.
37+
*/
38+
const init = async () => {
39+
if (!mapElement) {
40+
return;
41+
}
42+
43+
/** @type {import('leaflet')} */
44+
const leaflet = await loadModule('leaflet', 'dist/leaflet-src.esm.js');
45+
const iconUrl = `${getUnpkgURL('leaflet')}/dist/images/marker-icon-2x.png`;
46+
47+
map = leaflet.map(mapElement, { center: [0, 0], zoom: 2 });
48+
49+
leaflet
50+
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
51+
attribution:
52+
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
53+
})
54+
.addTo(map);
55+
56+
const { latitude, longitude } = coordinates;
57+
const icon = leaflet.icon({ iconUrl, iconSize: [25, 41] });
58+
59+
leaflet.marker([latitude, longitude], { icon }).addTo(map);
60+
map.setView([latitude, longitude], 12);
61+
62+
new ResizeObserver(() => {
63+
map?.invalidateSize();
64+
}).observe(mapElement);
65+
};
66+
67+
onMount(() => {
68+
init();
69+
});
70+
</script>
71+
72+
<div
73+
role="application"
74+
class="map"
75+
bind:this={mapElement}
76+
aria-label={$_('map_lat_lng', { values: coordinates })}
77+
></div>
78+
79+
<style lang="scss">
80+
// @todo Copy minimal styles from Leaflet to avoid loading the whole CSS file
81+
@import 'node_modules/leaflet/dist/leaflet.css';
82+
83+
.map {
84+
margin: var(--sui-focus-ring-width);
85+
border: 1px solid var(--sui-textbox-border-color);
86+
border-radius: var(--sui-textbox-border-radius);
87+
overflow: hidden;
88+
aspect-ratio: 1 / 1;
89+
background-clip: text;
90+
}
91+
92+
:global(.leaflet-container) {
93+
font-family: inherit !important;
94+
font-size: var(--sui-font-size-small) !important;
95+
}
96+
97+
:global(.leaflet-container a) {
98+
color: var(--sui-primary-accent-color-text) !important;
99+
text-decoration: none !important;
100+
}
101+
102+
:global(.leaflet-bar a) {
103+
border-color: var(--sui-button-border-color) !important;
104+
color: var(--sui-secondary-foreground-color) !important;
105+
background-color: var(--sui-button-background-color) !important;
106+
}
107+
108+
:global(.leaflet-control) {
109+
color: var(--sui-secondary-foreground-color) !important;
110+
background-color: var(--sui-secondary-background-color-translucent) !important;
111+
}
112+
113+
:global(.leaflet-control-attribution) {
114+
padding: 4px 8px;
115+
}
116+
117+
// Dark theme: https://stackoverflow.com/q/59819792
118+
:global(:root[data-theme='dark'] .leaflet-layer) {
119+
filter: invert(100%) hue-rotate(180deg);
120+
}
121+
</style>

src/lib/components/contents/details/widgets/map/map-editor.svelte

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
1919
/**
2020
* @import { GeoJSONStoreGeometries, TerraDraw } from 'terra-draw';
21-
* @import { WidgetEditorProps } from '$lib/types/private';
21+
* @import { GeoCoordinates, WidgetEditorProps } from '$lib/types/private';
2222
* @import { MapField } from '$lib/types/public';
2323
*/
2424
@@ -262,9 +262,7 @@
262262
263263
/**
264264
* Set the location on the map editor.
265-
* @param {object} args Arguments.
266-
* @param {number} args.latitude Latitude of the location to set.
267-
* @param {number} args.longitude Longitude of the location to set.
265+
* @param {GeoCoordinates} coordinates GeoCoordinates of the location to set.
268266
*/
269267
const setLocation = ({ latitude, longitude }) => {
270268
if (!draw) {
@@ -422,7 +420,7 @@
422420
</AlertDialog>
423421
424422
<style lang="scss">
425-
// @todo Copy minimal styles from Leaflet to avoid loading the whole CSS files
423+
// @todo Copy minimal styles from Leaflet to avoid loading the whole CSS file
426424
@import 'node_modules/leaflet/dist/leaflet.css';
427425
428426
.toolbar {

src/lib/locales/en.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,9 @@ export const strings = {
482482
dimensions: 'Dimensions',
483483
duration: 'Duration',
484484
used_in: 'Used in',
485+
created_date: 'Created Date',
486+
location: 'Location',
487+
map_lat_lng: 'Map showing latitude {latitude}, longitude {longitude}',
485488

486489
// Widgets
487490
select_file: 'Select File',

src/lib/locales/ja.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,9 @@ export const strings = {
488488
dimensions: '大きさ',
489489
duration: '再生時間',
490490
used_in: '使われているエントリー',
491+
created_date: '作成日時',
492+
location: '場所',
493+
map_lat_lng: '緯度 {latitude}、経度 {longitude} の地図',
491494

492495
// Widgets
493496
select_file: 'ファイルを選択',

src/lib/services/assets/index.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -672,18 +672,16 @@ export const getAssetDetails = async (asset) => {
672672
const { blobBaseURL } = get(backend)?.repository ?? {};
673673
const blobURL = await getAssetBlobURL(asset);
674674
const url = getAssetPublicURL(asset, { allowSpecial: true, pathOnly: true }) ?? blobURL;
675-
let dimensions;
676-
let duration;
675+
let metaData = {};
677676

678677
if (['image', 'video', 'audio'].includes(kind) && blobURL) {
679-
({ dimensions, duration } = await getMediaMetadata(blobURL, kind));
678+
metaData = await getMediaMetadata(asset, blobURL, kind);
680679
}
681680

682681
return {
682+
...metaData,
683683
publicURL: getAssetPublicURL(asset),
684684
repoBlobURL: blobBaseURL ? `${blobBaseURL}/${path}` : undefined,
685-
dimensions,
686-
duration,
687685
usedEntries: url ? await getEntriesByAssetURL(url) : [],
688686
};
689687
};

src/lib/services/utils/media/image.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { loadModule } from '$lib/services/app/dependencies';
22

33
/**
4-
* @import { ImageFitOption, InternalImageTransformationOptions } from '$lib/types/private';
4+
* @import {
5+
* ImageFitOption,
6+
* InternalImageTransformationOptions,
7+
* MediaDimensions,
8+
* } from '$lib/types/private';
59
* @import {
610
* RasterImageConversionFormat,
711
* RasterImageFormat,
@@ -24,7 +28,7 @@ export const RASTER_IMAGE_CONVERSION_FORMATS = ['webp'];
2428

2529
/**
2630
* Calculate the size of resized canvas.
27-
* @param {{ width: number, height: number }} source Source dimensions.
31+
* @param {MediaDimensions} source Source dimensions.
2832
* @param {{ width?: number, height?: number, fit?: ImageFitOption }} [target] Target dimensions and
2933
* fit option.
3034
* @returns {{ scale: number, width: number, height: number }} Scale and new width/height.
@@ -87,7 +91,7 @@ export const calculateResize = (
8791
/**
8892
* Resize a Canvas based on the given dimension.
8993
* @param {HTMLCanvasElement | OffscreenCanvas} canvas Canvas to be resized.
90-
* @param {{ width: number, height: number }} source Source dimensions.
94+
* @param {MediaDimensions} source Source dimensions.
9195
* @param {{ width?: number, height?: number, fit?: ImageFitOption }} [target] Target dimensions and
9296
* fit option.
9397
* @returns {{ scale: number, width: number, height: number }} Scale and new width/height.

0 commit comments

Comments
 (0)