mirror of
https://github.com/knadh/listmonk.git
synced 2024-09-20 07:16:33 +08:00
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
11
cmd/media.go
11
cmd/media.go
|
@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
Loading…
Reference in a new issue