Refactor media management.

- Change tiled UI to table UI.
- Add support for search and pagination.
- Important: This breaks the `GET /api/media` API to introduce pagination
  fields. Media items are now moved into `{ data: results[] }`.
This commit is contained in:
Kailash Nadh 2023-05-21 15:19:12 +05:30
parent 3b9a0f782e
commit d359ad27aa
10 changed files with 141 additions and 157 deletions

View file

@ -131,6 +131,8 @@ func handleUploadMedia(c echo.Context) error {
func handleGetMedia(c echo.Context) error { func handleGetMedia(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
pg = app.paginator.NewFromURL(c.Request().URL.Query())
query = c.FormValue("query")
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
) )
@ -143,11 +145,18 @@ func handleGetMedia(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out}) return c.JSON(http.StatusOK, okResp{out})
} }
out, err := app.core.GetAllMedia(app.constants.MediaUpload.Provider, app.media) res, total, err := app.core.QueryMedia(app.constants.MediaUpload.Provider, app.media, query, pg.Offset, pg.Limit)
if err != nil { if err != nil {
return err return err
} }
out := models.PageResults{
Results: res,
Total: total,
Page: pg.Page,
PerPage: pg.PerPage,
}
return c.JSON(http.StatusOK, okResp{out}) return c.JSON(http.StatusOK, okResp{out})
} }

View file

@ -244,8 +244,8 @@ export const deleteCampaign = async (id) => http.delete(`/api/campaigns/${id}`,
{ loading: models.campaigns }); { loading: models.campaigns });
// Media. // Media.
export const getMedia = async () => http.get('/api/media', export const getMedia = async (params) => http.get('/api/media',
{ loading: models.media, store: models.media }); { params, loading: models.media, store: models.media });
export const uploadMedia = (data) => http.post('/api/media', data, export const uploadMedia = (data) => http.post('/api/media', data,
{ loading: models.media }); { loading: models.media });

View file

@ -766,93 +766,20 @@ section.analytics {
/* Media gallery */ /* Media gallery */
.media-files { .media-files {
.thumbs { img {
display: flex; max-width: 125px;
flex-wrap: wrap;
flex-direction: column;
flex-flow: row wrap;
.thumb {
margin: 10px;
max-height: 90px;
overflow: hidden;
position: relative;
width: 250px;
min-height: 250px;
text-align: center;
.link {
display: block;
}
.ext {
display: block;
margin-top: 60px;
font-size: 2rem;
text-transform: uppercase;
}
.filename {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: $grey;
}
img {
max-width: 250px;
}
.caption {
background-color: rgba($white, .70);
color: $grey;
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 5px 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
background-color: rgba($white, .70);
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 2px 5px;
display: none;
a {
margin-left: 10px;
}
}
&:hover .actions {
display: block;
}
}
.box {
padding: 10px;
}
}
}
.modal .media-files {
.thumb {
min-width: 175px;
width: 175px;
min-height: 175px;
} }
.gallery { .thumb.box {
padding-left: 0; display: inline-block;
padding-right: 0; padding: 5px;
min-width: 140px;
max-height: 140px;
overflow: hidden;
text-align: center;
}
.ext {
text-transform: uppercase;
} }
} }

View file

@ -41,34 +41,67 @@
</form> </form>
</section> </section>
<section class="section gallery"> <section class="wrap gallery mt-6">
<div v-for="group in items" :key="group.title"> <b-table :data="media.results" :hoverable="true" :loading="loading.media"
<h3 class="title is-5">{{ group.title }}</h3> default-sort="createdAt" :paginated="true" backend-pagination pagination-position="both"
@page-change="onPageChange"
:current-page="media.page" :per-page="media.perPage" :total="media.total">
<div class="thumbs"> <template #top-left>
<div v-for="m in group.items" :key="m.id" class="box thumb"> <div class="columns">
<a @click="(e) => onMediaSelect(m, e)" :href="m.url" target="_blank" class="link"> <div class="column is-6">
<img v-if="m.thumbUrl" :src="m.thumbUrl" :title="m.filename" /> <form @submit.prevent="onQueryMedia">
<template v-else> <div>
<span class="ext" :title="m.filename">{{ m.filename.split(".").pop() }}</span><br /> <b-field>
</template> <b-input v-model="queryParams.query" name="query" expanded
<span class="caption is-size-5" :title="m.filename"> icon="magnify" ref="query" data-cy="query" />
{{ m.filename }} <p class="controls">
</span> <b-button native-type="submit" type="is-primary" icon-left="magnify"
</a> data-cy="btn-query" />
</p>
<div class="actions has-text-right"> </b-field>
<a :href="m.url" target="_blank"> </div>
<b-icon icon="arrow-top-right" size="is-small" /> </form>
</a>
<a href="#" @click.prevent="$utils.confirm(null, () => deleteMedia(m.id))">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</div> </div>
</div> </div>
</div> </template>
<hr />
</div> <b-table-column v-slot="props" field="name" width="40%"
:label="$t('globals.fields.name')">
<a @click="(e) => onMediaSelect(props.row, e)" :href="props.row.url"
target="_blank" class="link" :title="props.row.filename">
{{ props.row.filename }}
</a>
</b-table-column>
<b-table-column v-slot="props" field="thumb" width="30%">
<a @click="(e) => onMediaSelect(props.row, e)" :href="props.row.url"
target="_blank" class="thumb box">
<img v-if="props.row.thumbUrl" :src="props.row.thumbUrl" :title="props.row.filename" />
<span v-else class="ext">
{{ props.row.filename.split(".").pop() }}
</span>
</a>
</b-table-column>
<b-table-column v-slot="props" field="created_at" width="25%"
:label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt, true) }}
</b-table-column>
<b-table-column v-slot="props" field="actions" width="5%" cell-class="has-text-right">
<a href="" @click.prevent="$utils.confirm(null, () => onDeleteMedia(props.row.id))"
data-cy="btn-delete">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
<template #empty v-if="!loading.media">
<empty-placeholder />
</template>
</b-table>
</section> </section>
</section> </section>
</template> </template>
@ -76,9 +109,13 @@
<script> <script>
import Vue from 'vue'; import Vue from 'vue';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import dayjs from 'dayjs'; import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
export default Vue.extend({ export default Vue.extend({
components: {
EmptyPlaceholder,
},
name: 'Media', name: 'Media',
props: { props: {
@ -93,6 +130,11 @@ export default Vue.extend({
}, },
toUpload: 0, toUpload: 0,
uploaded: 0, uploaded: 0,
queryParams: {
page: 1,
query: '',
},
}; };
}, },
@ -101,6 +143,18 @@ export default Vue.extend({
this.form.files.splice(i, 1); this.form.files.splice(i, 1);
}, },
getMedia() {
this.$api.getMedia({
page: this.queryParams.page,
query: this.queryParams.query,
});
},
onQueryMedia() {
this.queryParams.page = 1;
this.getMedia();
},
onMediaSelect(m, e) { onMediaSelect(m, e) {
// If the component is open in the modal mode, close the modal and // If the component is open in the modal mode, close the modal and
// fire the selection event. // fire the selection event.
@ -127,9 +181,9 @@ export default Vue.extend({
} }
}, },
deleteMedia(id) { onDeleteMedia(id) {
this.$api.deleteMedia(id).then(() => { this.$api.deleteMedia(id).then(() => {
this.$api.getMedia(); this.getMedia();
}); });
}, },
@ -140,9 +194,14 @@ export default Vue.extend({
this.uploaded = 0; this.uploaded = 0;
this.form.files = []; this.form.files = [];
this.$api.getMedia(); this.getMedia();
} }
}, },
onPageChange(p) {
this.queryParams.page = p;
this.getMedia();
},
}, },
computed: { computed: {
@ -154,33 +213,6 @@ export default Vue.extend({
} }
return false; return false;
}, },
// Filters the list of media items by months into:
// [{"title": "Jan 2020", items: [...]}, ...]
items() {
const out = [];
if (!this.media || !(this.media instanceof Array)) {
return out;
}
let lastStamp = '';
let lastIndex = 0;
this.media.forEach((m) => {
if (this.$props.type === 'image' && !m.thumbUrl) {
return;
}
const stamp = dayjs(m.createdAt).format('MMM YYYY');
if (stamp !== lastStamp) {
out.push({ title: stamp, items: [] });
lastStamp = stamp;
lastIndex = out.length;
}
out[lastIndex - 1].items.push(m);
});
return out;
},
}, },
mounted() { mounted() {

View file

@ -166,7 +166,7 @@
{{ $utils.niceDate(props.row.updatedAt) }} {{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column> </b-table-column>
<b-table-column v-slot="props" label="Actions" cell-class="actions" align="right"> <b-table-column v-slot="props" cell-class="actions" align="right">
<div> <div>
<a :href="`/api/subscribers/${props.row.id}/export`" data-cy="btn-download"> <a :href="`/api/subscribers/${props.row.id}/export`" data-cy="btn-download">
<b-tooltip :label="$t('subscribers.downloadData')" type="is-dark"> <b-tooltip :label="$t('subscribers.downloadData')" type="is-dark">

View file

@ -1,7 +1,9 @@
package core package core
import ( import (
"fmt"
"net/http" "net/http"
"strings"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/media"
@ -10,24 +12,34 @@ import (
"gopkg.in/volatiletech/null.v6" "gopkg.in/volatiletech/null.v6"
) )
// GetAllMedia returns all uploaded media. // QueryMedia returns media entries optionally filtered by a query string.
func (c *Core) GetAllMedia(provider string, s media.Store) ([]media.Media, error) { func (c *Core) QueryMedia(provider string, s media.Store, query string, offset, limit int) ([]media.Media, int, error) {
out := []media.Media{} out := []media.Media{}
if err := c.q.GetAllMedia.Select(&out, provider); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError, if query != "" {
query = strings.ToLower(query)
}
if err := c.q.QueryMedia.Select(&out, fmt.Sprintf("%%%s%%", query), provider, offset, limit); err != nil {
return out, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", c.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.media}", "error", pqErrMsg(err))) "name", "{globals.terms.media}", "error", pqErrMsg(err)))
} }
for i := 0; i < len(out); i++ { total := 0
out[i].URL = s.GetURL(out[i].Filename) if len(out) > 0 {
total = out[0].Total
if out[i].Thumb != "" { for i := 0; i < len(out); i++ {
out[i].ThumbURL = null.String{Valid: true, String: s.GetURL(out[i].Thumb)} out[i].URL = s.GetURL(out[i].Filename)
if out[i].Thumb != "" {
out[i].ThumbURL = null.String{Valid: true, String: s.GetURL(out[i].Thumb)}
}
} }
} }
return out, nil return out, total, nil
} }
// GetMedia returns a media item. // GetMedia returns a media item.

View file

@ -19,6 +19,8 @@ type Media struct {
Provider string `json:"provider"` Provider string `json:"provider"`
Meta models.JSON `db:"meta" json:"meta"` Meta models.JSON `db:"meta" json:"meta"`
URL string `json:"url"` URL string `json:"url"`
Total int `db:"total" json:"-"`
} }
// Store represents functions to store and retrieve media (files). // Store represents functions to store and retrieve media (files).

View file

@ -85,8 +85,8 @@ type Queries struct {
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"` DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`
InsertMedia *sqlx.Stmt `query:"insert-media"` InsertMedia *sqlx.Stmt `query:"insert-media"`
GetAllMedia *sqlx.Stmt `query:"get-all-media"`
GetMedia *sqlx.Stmt `query:"get-media"` GetMedia *sqlx.Stmt `query:"get-media"`
QueryMedia *sqlx.Stmt `query:"query-media"`
DeleteMedia *sqlx.Stmt `query:"delete-media"` DeleteMedia *sqlx.Stmt `query:"delete-media"`
CreateTemplate *sqlx.Stmt `query:"create-template"` CreateTemplate *sqlx.Stmt `query:"create-template"`

View file

@ -904,8 +904,9 @@ SELECT id FROM tpl;
-- name: insert-media -- name: insert-media
INSERT INTO media (uuid, filename, thumb, content_type, provider, meta, created_at) VALUES($1, $2, $3, $4, $5, $6, NOW()) RETURNING id; INSERT INTO media (uuid, filename, thumb, content_type, provider, meta, created_at) VALUES($1, $2, $3, $4, $5, $6, NOW()) RETURNING id;
-- name: get-all-media -- name: query-media
SELECT * FROM media WHERE provider=$1 ORDER BY created_at DESC; SELECT COUNT(*) OVER () AS total, * FROM media
WHERE ($1 = '' OR filename ILIKE $1) AND provider=$2 ORDER BY created_at DESC OFFSET $3 LIMIT $4;
-- name: get-media -- name: get-media
SELECT * FROM media WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END; SELECT * FROM media WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;

View file

@ -164,6 +164,7 @@ CREATE TABLE campaign_media (
DROP INDEX IF EXISTS idx_camp_media_id; CREATE UNIQUE INDEX idx_camp_media_id ON campaign_media (campaign_id, media_id); DROP INDEX IF EXISTS idx_camp_media_id; CREATE UNIQUE INDEX idx_camp_media_id ON campaign_media (campaign_id, media_id);
DROP INDEX IF EXISTS idx_camp_media_camp_id; CREATE INDEX idx_camp_media_camp_id ON campaign_media(campaign_id); DROP INDEX IF EXISTS idx_camp_media_camp_id; CREATE INDEX idx_camp_media_camp_id ON campaign_media(campaign_id);
-- links -- links
DROP TABLE IF EXISTS links CASCADE; DROP TABLE IF EXISTS links CASCADE;
CREATE TABLE links ( CREATE TABLE links (