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:
parent
3b9a0f782e
commit
d359ad27aa
10 changed files with 141 additions and 157 deletions
11
cmd/media.go
11
cmd/media.go
|
@ -131,6 +131,8 @@ func handleUploadMedia(c echo.Context) error {
|
|||
func handleGetMedia(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
query = c.FormValue("query")
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
|
@ -143,11 +145,18 @@ func handleGetMedia(c echo.Context) error {
|
|||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
out := models.PageResults{
|
||||
Results: res,
|
||||
Total: total,
|
||||
Page: pg.Page,
|
||||
PerPage: pg.PerPage,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
|
|
|
@ -244,8 +244,8 @@ export const deleteCampaign = async (id) => http.delete(`/api/campaigns/${id}`,
|
|||
{ loading: models.campaigns });
|
||||
|
||||
// Media.
|
||||
export const getMedia = async () => http.get('/api/media',
|
||||
{ loading: models.media, store: models.media });
|
||||
export const getMedia = async (params) => http.get('/api/media',
|
||||
{ params, loading: models.media, store: models.media });
|
||||
|
||||
export const uploadMedia = (data) => http.post('/api/media', data,
|
||||
{ loading: models.media });
|
||||
|
|
|
@ -766,93 +766,20 @@ section.analytics {
|
|||
|
||||
/* Media gallery */
|
||||
.media-files {
|
||||
.thumbs {
|
||||
display: flex;
|
||||
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;
|
||||
max-width: 125px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
background-color: rgba($white, .70);
|
||||
color: $grey;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 5px 15px;
|
||||
|
||||
white-space: nowrap;
|
||||
.thumb.box {
|
||||
display: inline-block;
|
||||
padding: 5px;
|
||||
min-width: 140px;
|
||||
max-height: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
.ext {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -41,34 +41,67 @@
|
|||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section gallery">
|
||||
<div v-for="group in items" :key="group.title">
|
||||
<h3 class="title is-5">{{ group.title }}</h3>
|
||||
<section class="wrap gallery mt-6">
|
||||
<b-table :data="media.results" :hoverable="true" :loading="loading.media"
|
||||
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">
|
||||
<div v-for="m in group.items" :key="m.id" class="box thumb">
|
||||
<a @click="(e) => onMediaSelect(m, e)" :href="m.url" target="_blank" class="link">
|
||||
<img v-if="m.thumbUrl" :src="m.thumbUrl" :title="m.filename" />
|
||||
<template v-else>
|
||||
<span class="ext" :title="m.filename">{{ m.filename.split(".").pop() }}</span><br />
|
||||
<template #top-left>
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<form @submit.prevent="onQueryMedia">
|
||||
<div>
|
||||
<b-field>
|
||||
<b-input v-model="queryParams.query" name="query" expanded
|
||||
icon="magnify" ref="query" data-cy="query" />
|
||||
<p class="controls">
|
||||
<b-button native-type="submit" type="is-primary" icon-left="magnify"
|
||||
data-cy="btn-query" />
|
||||
</p>
|
||||
</b-field>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span class="caption is-size-5" :title="m.filename">
|
||||
{{ m.filename }}
|
||||
|
||||
<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>
|
||||
|
||||
<div class="actions has-text-right">
|
||||
<a :href="m.url" target="_blank">
|
||||
<b-icon icon="arrow-top-right" size="is-small" />
|
||||
</a>
|
||||
<a href="#" @click.prevent="$utils.confirm(null, () => deleteMedia(m.id))">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<template #empty v-if="!loading.media">
|
||||
<empty-placeholder />
|
||||
</template>
|
||||
</b-table>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
@ -76,9 +109,13 @@
|
|||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import dayjs from 'dayjs';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
},
|
||||
|
||||
name: 'Media',
|
||||
|
||||
props: {
|
||||
|
@ -93,6 +130,11 @@ export default Vue.extend({
|
|||
},
|
||||
toUpload: 0,
|
||||
uploaded: 0,
|
||||
|
||||
queryParams: {
|
||||
page: 1,
|
||||
query: '',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -101,6 +143,18 @@ export default Vue.extend({
|
|||
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) {
|
||||
// If the component is open in the modal mode, close the modal and
|
||||
// fire the selection event.
|
||||
|
@ -127,9 +181,9 @@ export default Vue.extend({
|
|||
}
|
||||
},
|
||||
|
||||
deleteMedia(id) {
|
||||
onDeleteMedia(id) {
|
||||
this.$api.deleteMedia(id).then(() => {
|
||||
this.$api.getMedia();
|
||||
this.getMedia();
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -140,9 +194,14 @@ export default Vue.extend({
|
|||
this.uploaded = 0;
|
||||
this.form.files = [];
|
||||
|
||||
this.$api.getMedia();
|
||||
this.getMedia();
|
||||
}
|
||||
},
|
||||
|
||||
onPageChange(p) {
|
||||
this.queryParams.page = p;
|
||||
this.getMedia();
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
@ -154,33 +213,6 @@ export default Vue.extend({
|
|||
}
|
||||
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() {
|
||||
|
|
|
@ -166,7 +166,7 @@
|
|||
{{ $utils.niceDate(props.row.updatedAt) }}
|
||||
</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>
|
||||
<a :href="`/api/subscribers/${props.row.id}/export`" data-cy="btn-download">
|
||||
<b-tooltip :label="$t('subscribers.downloadData')" type="is-dark">
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/knadh/listmonk/internal/media"
|
||||
|
@ -10,15 +12,24 @@ import (
|
|||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// GetAllMedia returns all uploaded media.
|
||||
func (c *Core) GetAllMedia(provider string, s media.Store) ([]media.Media, error) {
|
||||
// QueryMedia returns media entries optionally filtered by a query string.
|
||||
func (c *Core) QueryMedia(provider string, s media.Store, query string, offset, limit int) ([]media.Media, int, error) {
|
||||
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",
|
||||
"name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
|
||||
for i := 0; i < len(out); i++ {
|
||||
out[i].URL = s.GetURL(out[i].Filename)
|
||||
|
||||
|
@ -26,8 +37,9 @@ func (c *Core) GetAllMedia(provider string, s media.Store) ([]media.Media, error
|
|||
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.
|
||||
|
|
|
@ -19,6 +19,8 @@ type Media struct {
|
|||
Provider string `json:"provider"`
|
||||
Meta models.JSON `db:"meta" json:"meta"`
|
||||
URL string `json:"url"`
|
||||
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
// Store represents functions to store and retrieve media (files).
|
||||
|
|
|
@ -85,8 +85,8 @@ type Queries struct {
|
|||
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`
|
||||
|
||||
InsertMedia *sqlx.Stmt `query:"insert-media"`
|
||||
GetAllMedia *sqlx.Stmt `query:"get-all-media"`
|
||||
GetMedia *sqlx.Stmt `query:"get-media"`
|
||||
QueryMedia *sqlx.Stmt `query:"query-media"`
|
||||
DeleteMedia *sqlx.Stmt `query:"delete-media"`
|
||||
|
||||
CreateTemplate *sqlx.Stmt `query:"create-template"`
|
||||
|
|
|
@ -904,8 +904,9 @@ SELECT id FROM tpl;
|
|||
-- 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;
|
||||
|
||||
-- name: get-all-media
|
||||
SELECT * FROM media WHERE provider=$1 ORDER BY created_at DESC;
|
||||
-- name: query-media
|
||||
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
|
||||
SELECT * FROM media WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
|
||||
|
|
|
@ -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_camp_id; CREATE INDEX idx_camp_media_camp_id ON campaign_media(campaign_id);
|
||||
|
||||
|
||||
-- links
|
||||
DROP TABLE IF EXISTS links CASCADE;
|
||||
CREATE TABLE links (
|
||||
|
|
Loading…
Reference in a new issue