Separate album downloading logic from user display. Allow downloading individual user albums.

This commit is contained in:
Antoine Bartuccio 2025-02-18 13:54:48 +01:00
parent e46cba7a06
commit 93a5c3a02a
7 changed files with 152 additions and 142 deletions

View File

@ -1,59 +0,0 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { picturesFetchPictures } from "#openapi";
/**
* @typedef AlbumConfig
* @property {number} albumId id of the album to visualize
* @property {number} maxPageSize maximum number of elements to show on a page
**/
/**
* Create a family graph of an user
* @param {AlbumConfig} config
**/
window.loadAlbum = (config) => {
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", () => ({
pictures: {},
page: Number.parseInt(initialUrlParams.get("page")) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false,
async init() {
await this.fetchPictures();
this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push;
this.fetchPictures();
});
window.addEventListener("popstate", () => {
this.pushstate = History.Replace;
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) ||
1;
});
},
async fetchPictures() {
this.loading = true;
this.pictures = (
await picturesFetchPictures({
query: {
// biome-ignore lint/style/useNamingConvention: API is in snake_case
album_id: config.albumId,
page: this.page,
// biome-ignore lint/style/useNamingConvention: API is in snake_case
page_size: config.maxPageSize,
},
})
).data;
this.loading = false;
},
nbPages() {
return Math.ceil(this.pictures.count / config.maxPageSize);
},
}));
});
};

View File

@ -0,0 +1,52 @@
import { paginated } from "#core:utils/api";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import {
type PictureSchema,
type PicturesFetchPicturesData,
picturesFetchPictures,
} from "#openapi";
interface AlbumConfig {
albumId: number;
maxPageSize: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", (config: AlbumConfig) => ({
pictures: [] as PictureSchema[],
page: Number.parseInt(initialUrlParams.get("page")) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false,
config: {} as AlbumConfig,
async init() {
await this.fetchPictures();
this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push;
});
window.addEventListener("popstate", () => {
this.pushstate = History.Replace;
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
});
this.config = config;
},
async fetchPictures() {
this.loading = true;
this.pictures = await paginated(picturesFetchPictures, {
query: {
// biome-ignore lint/style/useNamingConvention: API is in snake_case
album_id: config.albumId,
} as PicturesFetchPicturesData["query"],
});
this.loading = false;
},
nbPages() {
return Math.ceil(this.pictures.length / config.maxPageSize);
},
}));
});

View File

@ -0,0 +1,46 @@
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import type { PictureSchema } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("pictures_download", () => ({
isDownloading: false,
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const incrementProgressBar = (_total: number): undefined => {
bar.value++;
return undefined;
};
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: interpolate(
gettext("pictures.%(extension)s"),
{ extension: "zip" },
true,
),
excludeAcceptAllOption: false,
});
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.pictures.map((p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),
);
await zipWriter.close();
this.isDownloading = false;
},
}));
});

View File

@ -1,6 +1,4 @@
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api";
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import { import {
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
@ -8,26 +6,22 @@ import {
} from "#openapi"; } from "#openapi";
interface PagePictureConfig { interface PagePictureConfig {
userId?: number; userId: number;
albumId?: number;
} }
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", (config: PagePictureConfig) => ({ Alpine.data("user_pictures", (config: PagePictureConfig) => ({
isDownloading: false,
loading: true, loading: true,
pictures: [] as PictureSchema[], pictures: [] as PictureSchema[],
albums: {} as Record<string, PictureSchema[]>, albums: {} as Record<string, PictureSchema[]>,
async init() { async init() {
const query: PicturesFetchPicturesData["query"] = {}; this.pictures = await paginated(picturesFetchPictures, {
query: {
if (config.userId) { // biome-ignore lint/style/useNamingConvention: from python api
query.users_identified = [config.userId]; users_identified: [config.userId],
} else { } as PicturesFetchPicturesData["query"],
query.album_id = config.albumId; });
}
this.pictures = await paginated(picturesFetchPictures, { query: query });
this.albums = this.pictures.reduce( this.albums = this.pictures.reduce(
(acc: Record<string, PictureSchema[]>, picture: PictureSchema) => { (acc: Record<string, PictureSchema[]>, picture: PictureSchema) => {
@ -41,42 +35,5 @@ document.addEventListener("alpine:init", () => {
); );
this.loading = false; this.loading = false;
}, },
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const incrementProgressBar = (_total: number): undefined => {
bar.value++;
return undefined;
};
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: interpolate(
gettext("pictures.%(extension)s"),
{ extension: "zip" },
true,
),
excludeAcceptAllOption: false,
});
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.pictures.map((p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),
);
await zipWriter.close();
this.isDownloading = false;
},
})); }));
}); });

View File

@ -7,8 +7,8 @@
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script type="module" src="{{ static('bundled/sas/album-index.js') }}"></script> <script type="module" src="{{ static('bundled/sas/album-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/sas/user/pictures-index.ts') }}"></script> <script type="module" src="{{ static('bundled/sas/pictures-download-index.ts') }}"></script>
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}
@ -64,15 +64,17 @@
<br> <br>
{% endif %} {% endif %}
<div x-data="user_pictures({ albumId: {{ album.id }} })"> <div x-data="pictures({
{{ download_button() }} albumId: {{ album.id }},
</div> maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
})">
{{ download_button("Download album") }}
<div x-data="pictures">
<h4>{% trans %}Pictures{% endtrans %}</h4> <h4>{% trans %}Pictures{% endtrans %}</h4>
<div class="photos" :aria-busy="loading"> <div class="photos" :aria-busy="loading">
<template x-for="picture in pictures.results"> <template x-for="picture in pictures.slice((page - 1) * config.maxPageSize, config.maxPageSize * page)">
<a :href="`/sas/picture/${picture.id}`"> <a :href="picture.sas_url">
<div <div
class="photo" class="photo"
:class="{not_moderated: !picture.is_moderated}" :class="{not_moderated: !picture.is_moderated}"
@ -117,13 +119,6 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
window.addEventListener("DOMContentLoaded", () => {
loadAlbum({
albumId: {{ album.id }},
maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
});
});
// Todo: migrate to alpine.js if we have some time // Todo: migrate to alpine.js if we have some time
$("form#upload_form").submit(function (event) { $("form#upload_form").submit(function (event) {
let formData = new FormData($(this)[0]); let formData = new FormData($(this)[0]);

View File

@ -1,13 +1,30 @@
{% macro download_button() %} {# Helper macro to create a download button for a
<div x-show="pictures.length > 0" x-cloak> record of albums with alpine
<button
:disabled="isDownloading" This needs to be used inside an alpine environment.
class="btn btn-blue" Downloaded pictures will be `pictures` from the
@click="downloadZip()" parent data store.
>
<i class="fa fa-download"></i> Note:
{% trans %}Download all pictures{% endtrans %} This requires importing `bundled/sas/pictures-download-index.ts`
</button>
<progress x-ref="progress" x-show="isDownloading"></progress> Parameters:
name (str): name displayed on the button, will be translated
#}
{% macro download_button(name) %}
<div x-data="pictures_download">
<div x-show="pictures.length > 0" x-cloak>
<button
:disabled="isDownloading"
class="btn btn-blue"
@click="downloadZip()"
>
<i class="fa fa-download"></i>
{%- if name != "" -%}
{% trans %}{{ name }}{% endtrans %}
{%- endif -%}
</button>
<progress x-ref="progress" x-show="isDownloading"></progress>
</div>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@ -7,6 +7,7 @@
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static('bundled/sas/user/pictures-index.ts') }}"></script> <script type="module" src="{{ static('bundled/sas/user/pictures-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/sas/pictures-download-index.ts') }}"></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -16,16 +17,21 @@
{% block content %} {% block content %}
<main x-data="user_pictures({ userId: {{ object.id }} })"> <main x-data="user_pictures({ userId: {{ object.id }} })">
{% if user.id == object.id %} {% if user.id == object.id %}
{{ download_button() }} {{ download_button("Download all my pictures") }}
{% endif %} {% endif %}
<template x-for="[album, pictures] in Object.entries(albums)" x-cloak> <template x-for="[album, pictures] in Object.entries(albums)" x-cloak>
<section> <section>
<br /> <br />
<h4 x-text="album"></h4> <div class="row">
<h4 x-text="album"></h4>
{% if user.id == object.id %}
{{ download_button("") }}
{% endif %}
</div>
<div class="photos"> <div class="photos">
<template x-for="picture in pictures"> <template x-for="picture in pictures">
<a :href="`/sas/picture/${picture.id}`"> <a :href="picture.sas_url">
<div <div
class="photo" class="photo"
:class="{not_moderated: !picture.is_moderated}" :class="{not_moderated: !picture.is_moderated}"
@ -47,7 +53,3 @@
<div class="photos" :aria-busy="loading"></div> <div class="photos" :aria-busy="loading"></div>
</main> </main>
{% endblock content %} {% endblock content %}
{% block script %}
{{ super() }}
{% endblock script %}