Edit blogs, and add blog icons and banners
#460
Merged
elegaanz
merged 14 commits from blog-edit
into master
5 years ago
@ -0,0 +1,4 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE blogs DROP COLUMN summary_html;
|
||||
ALTER TABLE blogs DROP COLUMN icon_id;
|
||||
ALTER TABLE blogs DROP COLUMN banner_id;
|
@ -0,0 +1,4 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE blogs ADD COLUMN summary_html TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE blogs ADD COLUMN icon_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL;
|
||||
ALTER TABLE blogs ADD COLUMN banner_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL;
|
@ -0,0 +1,33 @@
|
||||
-- This file should undo anything in `up.sql
|
||||
|
||||
CREATE TABLE blogs2 (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
actor_id VARCHAR NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
outbox_url VARCHAR NOT NULL UNIQUE,
|
||||
inbox_url VARCHAR NOT NULL UNIQUE,
|
||||
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
|
||||
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ap_url text not null default '' UNIQUE,
|
||||
private_key TEXT,
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
fqn TEXT NOT NULL DEFAULT '',
|
||||
CONSTRAINT blog_unique UNIQUE (actor_id, instance_id)
|
||||
);
|
||||
INSERT INTO blogs2 SELECT
|
||||
id,
|
||||
actor_id,
|
||||
title,
|
||||
summary,
|
||||
outbox_url,
|
||||
inbox_url,
|
||||
instance_id,
|
||||
creation_date,
|
||||
ap_url,
|
||||
private_key,
|
||||
public_key,
|
||||
fqn
|
||||
FROM blogs;
|
||||
DROP TABLE blogs;
|
||||
ALTER TABLE blogs2 RENAME TO blogs;
|
@ -0,0 +1,4 @@
|
||||
-- Your SQL goes here
|
||||
ALTER TABLE blogs ADD COLUMN summary_html TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE blogs ADD COLUMN icon_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL;
|
||||
ALTER TABLE blogs ADD COLUMN banner_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL;
|
@ -1,5 +1,6 @@
|
||||
use activitypub::collection::OrderedCollection;
|
||||
use atom_syndication::{Entry, FeedBuilder};
|
||||
use diesel::SaveChangesDsl;
|
||||
use rocket::{
|
||||
http::ContentType,
|
||||
request::LenientForm,
|
||||
@ -11,7 +12,10 @@ use validator::{Validate, ValidationError, ValidationErrors};
|
||||
|
||||
use plume_common::activity_pub::{ActivityStream, ApRequest};
|
||||
use plume_common::utils;
|
||||
use plume_models::{blog_authors::*, blogs::*, db_conn::DbConn, instance::Instance, posts::Post};
|
||||
use plume_models::{
|
||||
blog_authors::*, blogs::*, db_conn::DbConn, instance::Instance, medias::*, posts::Post,
|
||||
safe_string::SafeString, users::User, Connection,
|
||||
};
|
||||
use routes::{errors::ErrorPage, Page, PlumeRocket};
|
||||
use template_utils::Ructe;
|
||||
|
||||
@ -28,13 +32,10 @@ pub fn details(name: String, page: Option<Page>, rockets: PlumeRocket) -> Result
|
||||
|
||||
Ok(render!(blogs::details(
|
||||
&(&*conn, &intl.catalog, user.clone()),
|
||||
blog.clone(),
|
||||
blog,
|
||||
authors,
|
||||
articles_count,
|
||||
page.0,
|
||||
Page::total(articles_count as i32),
|
||||
user.and_then(|x| x.is_author_in(&*conn, &blog).ok())
|
||||
.unwrap_or(false),
|
||||
posts
|
||||
)))
|
||||
}
|
||||
@ -170,6 +171,141 @@ pub fn delete(name: String, rockets: PlumeRocket) -> Result<Redirect, Ructe> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromForm, Validate)]
|
||||
pub struct EditForm {
|
||||
#[validate(custom(function = "valid_slug", message = "Invalid name"))]
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub icon: Option<i32>,
|
||||
pub banner: Option<i32>,
|
||||
}
|
||||
|
||||
#[get("/~/<name>/edit")]
|
||||
pub fn edit(
|
||||
conn: DbConn,
|
||||
name: String,
|
||||
user: Option<User>,
|
||||
intl: I18n,
|
||||
) -> Result<Ructe, ErrorPage> {
|
||||
let blog = Blog::find_by_fqn(&*conn, &name)?;
|
||||
if user
|
||||
.clone()
|
||||
.and_then(|u| u.is_author_in(&*conn, &blog).ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let user = user.expect("blogs::edit: User was None while it shouldn't");
|
||||
let medias = Media::for_user(&*conn, user.id).expect("Couldn't list media");
|
||||
Ok(render!(blogs::edit(
|
||||
&(&*conn, &intl.catalog, Some(user)),
|
||||
&blog,
|
||||
medias,
|
||||
&EditForm {
|
||||
title: blog.title.clone(),
|
||||
summary: blog.summary.clone(),
|
||||
icon: blog.icon_id,
|
||||
banner: blog.banner_id,
|
||||
},
|
||||
ValidationErrors::default()
|
||||
)))
|
||||
} else {
|
||||
// TODO actually return 403 error code
|
||||
Ok(render!(errors::not_authorized(
|
||||
&(&*conn, &intl.catalog, user),
|
||||
i18n!(intl.catalog, "You are not allowed to edit this blog.")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the media is owned by `user` and is a picture
|
||||
fn check_media(conn: &Connection, id: i32, user: &User) -> bool {
|
||||
if let Ok(media) = Media::get(conn, id) {
|
||||
media.owner_id == user.id && media.category() == MediaCategory::Image
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/~/<name>/edit", data = "<form>")]
|
||||
pub fn update(
|
||||
conn: DbConn,
|
||||
name: String,
|
||||
user: Option<User>,
|
||||
intl: I18n,
|
||||
form: LenientForm<EditForm>,
|
||||
) -> Result<Redirect, Ructe> {
|
||||
let mut blog = Blog::find_by_fqn(&*conn, &name).expect("blog::update: blog not found");
|
||||
if user
|
||||
.clone()
|
||||
.and_then(|u| u.is_author_in(&*conn, &blog).ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let user = user.expect("blogs::edit: User was None while it shouldn't");
|
||||
form.validate()
|
||||
.and_then(|_| {
|
||||
if let Some(icon) = form.icon {
|
||||
if !check_media(&*conn, icon, &user) {
|
||||
let mut errors = ValidationErrors::new();
|
||||
errors.add(
|
||||
"",
|
||||
ValidationError {
|
||||
code: Cow::from("icon"),
|
||||
message: Some(Cow::from(i18n!(
|
||||
intl.catalog,
|
||||
"You can't use this media as blog icon."
|
||||
))),
|
||||
params: HashMap::new(),
|
||||
},
|
||||
);
|
||||
return Err(errors);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(banner) = form.banner {
|
||||
if !check_media(&*conn, banner, &user) {
|
||||
let mut errors = ValidationErrors::new();
|
||||
errors.add(
|
||||
"",
|
||||
ValidationError {
|
||||
code: Cow::from("banner"),
|
||||
message: Some(Cow::from(i18n!(
|
||||
intl.catalog,
|
||||
"You can't use this media as blog banner."
|
||||
))),
|
||||
params: HashMap::new(),
|
||||
},
|
||||
);
|
||||
return Err(errors);
|
||||
}
|
||||
}
|
||||
|
||||
blog.title = form.title.clone();
|
||||
blog.summary = form.summary.clone();
|
||||
blog.summary_html = SafeString::new(&utils::md_to_html(&form.summary, "", true).0);
|
||||
blog.icon_id = form.icon;
|
||||
blog.banner_id = form.banner;
|
||||
trinity-1686a
commented 5 years ago
Review
user can provide the id of a media they don't own, or of a non-picture media user can provide the id of a media they don't own, or of a non-picture media
|
||||
blog.save_changes::<Blog>(&*conn)
|
||||
.expect("Couldn't save blog changes");
|
||||
Ok(Redirect::to(uri!(details: name = name, page = _)))
|
||||
})
|
||||
.map_err(|err| {
|
||||
let medias = Media::for_user(&*conn, user.id).expect("Couldn't list media");
|
||||
render!(blogs::edit(
|
||||
&(&*conn, &intl.catalog, Some(user)),
|
||||
&blog,
|
||||
medias,
|
||||
&*form,
|
||||
err
|
||||
))
|
||||
})
|
||||
} else {
|
||||
// TODO actually return 403 error code
|
||||
Err(render!(errors::not_authorized(
|
||||
&(&*conn, &intl.catalog, user),
|
||||
i18n!(intl.catalog, "You are not allowed to edit this blog.")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/~/<name>/outbox")]
|
||||
pub fn outbox(name: String, conn: DbConn) -> Option<ActivityStream<OrderedCollection>> {
|
||||
let blog = Blog::find_by_fqn(&*conn, &name).ok()?;
|
||||
|
@ -5,7 +5,7 @@
|
||||
@use template_utils::*;
|
||||
@use routes::*;
|
||||
|
||||
@(ctx: BaseContext, blog: Blog, authors: &[User], total_articles: i64, page: i32, n_pages: i32, is_author: bool, posts: Vec<Post>)
|
||||
@(ctx: BaseContext, blog: Blog, authors: &[User], page: i32, n_pages: i32, posts: Vec<Post>)
|
||||
|
||||
@:base(ctx, blog.title.clone(), {}, {
|
||||
<a href="@uri!(blogs::details: name = &blog.fqn, page = _)">@blog.title</a>
|
||||
@ -19,17 +19,36 @@
|
||||
}
|
||||
</div>
|
||||
<div class="h-feed">
|
||||
<h1><span class="p-name">@blog.title</span> <small>~@blog.fqn</small></h1>
|
||||
<p>@blog.summary</p>
|
||||
<p>
|
||||
@i18n!(ctx.1, "There's one author on this blog: ", "There are {0} authors on this blog: "; authors.len())
|
||||
@for author in authors {
|
||||
<a class="author p-author" href="@uri!(user::details: name = &author.fqn)">@author.name()</a>
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
@i18n!(ctx.1, "There's one article on this blog", "There are {0} articles on this blog"; total_articles)
|
||||
</p>
|
||||
@if let Some(banner_url) = blog.banner_url(ctx.0) {
|
||||
<div class="cover" style="background-image: url('@Html(banner_url.clone())')"></div>
|
||||
<img class="hidden u-photo" src="@banner_url"/>
|
||||
}
|
||||
<div class="h-card">
|
||||
<div class="user">
|
||||
<div class="flex wrap">
|
||||
<div class="avatar medium" style="background-image: url('@blog.icon_url(ctx.0)');" aria-label="@i18n!(ctx.1, "{}'s icon"; &blog.title)"></div>
|
||||
<img class="hidden u-photo" src="@blog.icon_url(ctx.0)"/>
|
||||
|
||||
<h1 class="grow flex vertical">
|
||||
<span class="p-name">@blog.title</span>
|
||||
<small>~@blog.fqn</small>
|
||||
</h1>
|
||||
|
||||
@if ctx.2.clone().and_then(|u| u.is_author_in(ctx.0, &blog).ok()).unwrap_or(false) {
|
||||
<a href="@uri!(posts::new: blog = &blog.fqn)" class="button">@i18n!(ctx.1, "New article")</a>
|
||||
<a href="@uri!(blogs::edit: name = &blog.fqn)" class="button">@i18n!(ctx.1, "Edit")</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<main class="user-summary">
|
||||
<p>
|
||||
@i18n!(ctx.1, "There's one author on this blog: ", "There are {0} authors on this blog: "; authors.len())
|
||||
@for (i, author) in authors.iter().enumerate() {@if i >= 1 {, }
|
||||
<a class="author p-author" href="@uri!(user::details: name = &author.fqn)">@author.name()</a>}
|
||||
</p>
|
||||
@Html(blog.summary_html.clone())
|
||||
</main>
|
||||
Review
isn't the Avatar also optional? isn't the Avatar also optional?
trinity-1686a
commented 5 years ago
Review
icon_url returns either the blog avatar, or the default one (see https://github.com/Plume-org/Plume/pull/460/files#diff-a4a32b57517e9459ff561610609f8044R364 ) icon_url returns either the blog avatar, or the default one (see https://github.com/Plume-org/Plume/pull/460/files#diff-a4a32b57517e9459ff561610609f8044R364 )
Review
duh, thanks! duh, thanks!
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>
|
||||
@ -39,9 +58,6 @@
|
||||
@if posts.is_empty() {
|
||||
<p>@i18n!(ctx.1, "No posts to see here yet.")</p>
|
||||
}
|
||||
@if is_author {
|
||||
<a href="@uri!(posts::new: blog = &blog.fqn)" class="button inline-block">@i18n!(ctx.1, "New article")</a>
|
||||
}
|
||||
<div class="cards">
|
||||
@for article in posts {
|
||||
@:post_card(ctx, article)
|
||||
@ -50,11 +66,4 @@
|
||||
@paginate(ctx.1, page, n_pages)
|
||||
</section>
|
||||
</div>
|
||||
@if is_author {
|
||||
<h2>@i18n!(ctx.1, "Danger zone")</h2>
|
||||
<p>@i18n!(ctx.1, "Be very careful, any action taken here can't be reversed.")</p>
|
||||
<form method="post" action="@uri!(blogs::delete: name = &blog.fqn)">
|
||||
<input type="submit" class="inline-block button destructive" value="@i18n!(ctx.1, "Permanently delete this blog")">
|
||||
</form>
|
||||
}
|
||||
})
|
||||
|
@ -0,0 +1,42 @@
|
||||
@use validator::ValidationErrors;
|
||||
@use plume_models::blogs::Blog;
|
||||
@use plume_models::medias::Media;
|
||||
@use routes::blogs;
|
||||
@use routes::blogs::EditForm;
|
||||
@use routes::medias;
|
||||
@use template_utils::*;
|
||||
@use templates::base;
|
||||
@use templates::partials::image_select;
|
||||
|
||||
@(ctx: BaseContext, blog: &Blog, medias: Vec<Media>, form: &EditForm, errors: ValidationErrors)
|
||||
|
||||
@:base(ctx, i18n!(ctx.1, "Edit \"{}\""; &blog.title), {}, {
|
||||
<a href="@uri!(blogs::details: name = &blog.fqn, page = _)">@blog.title</a>
|
||||
}, {
|
||||
<h1>@i18n!(ctx.1, "Edit \"{}\""; &blog.title)</h1>
|
||||
<form method="post" action="@uri!(blogs::update: name = &blog.fqn)">
|
||||
<!-- Rocket hack to use various HTTP methods -->
|
||||
<input type=hidden name="_method" value="put">
|
||||
|
||||
@input!(ctx.1, title (text), "Title", form, errors.clone(), "minlenght=\"1\"")
|
||||
|
||||
<label for="summary">@i18n!(ctx.1, "Description")<small>@i18n!(ctx.1, "Markdown syntax is supported")</small></label>
|
||||
<textarea id="summary" name="summary" rows="20">@form.summary</textarea>
|
||||
|
||||
<p>
|
||||
@i18n!(ctx.1, "You can upload images to your gallery, to use them as blog icons or banners.")
|
||||
<a href="@uri!(medias::new)">@i18n!(ctx.1, "Upload images")</a>
|
||||
</p>
|
||||
|
||||
@:image_select(ctx, "icon", i18n!(ctx.1, "Blog icon"), true, medias.clone(), form.icon)
|
||||
@:image_select(ctx, "banner", i18n!(ctx.1, "Blog banner"), true, medias, form.banner)
|
||||
|
||||
<input type="submit" value="@i18n!(ctx.1, "Update blog")"/>
|
||||
</form>
|
||||
|
||||
<h2>@i18n!(ctx.1, "Danger zone")</h2>
|
||||
<p>@i18n!(ctx.1, "Be very careful, any action taken here can't be reversed.")</p>
|
||||
<form method="post" action="@uri!(blogs::delete: name = &blog.fqn)">
|
||||
<input type="submit" class="inline-block button destructive" value="@i18n!(ctx.1, "Permanently delete this blog")">
|
||||
</form>
|
||||
})
|
@ -0,0 +1,25 @@
|
||||
@use template_utils::*;
|
||||
@use plume_models::medias::*;
|
||||
|
||||
@(ctx: BaseContext, id: &str, title: String, optional: bool, medias: Vec<Media>, selected: Option<i32>)
|
||||
|
||||
<label for="@id">
|
||||
@title
|
||||
@if optional {
|
||||
<small>@i18n!(ctx.1, "Optional")</small>
|
||||
}
|
||||
</label>
|
||||
<select id="@id" name="@id">
|
||||
<option value="none" @if selected.is_none() { selected }>@i18n!(ctx.1, "None")</option>
|
||||
@for media in medias {
|
||||
@if media.category() == MediaCategory::Image {
|
||||
<option value="@media.id" @if selected.map(|c| c == media.id).unwrap_or(false) { selected }>
|
||||
@if !media.alt_text.is_empty() {
|
||||
@media.alt_text
|
||||
} else {
|
||||
@media.content_warning.unwrap_or(i18n!(ctx.1, "No description"))
|
||||
}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
</select>
|
Loading…
Reference in New Issue
I don't understand what this does, but apparently it did the trick
It is an option for the
#[derive(AsChangeset)]
, that tells diesel to considerOption
fields that areNone
to be considered asNULL
in SQL, instead of not being included in the query at all.Ok, doesn't this potentially break other things that relied on it before in our code?
not according to our extensive tests
Test in production ™️
It only affects the
AsChangeset
implementation, which is used whenUPDATE
ing blogs, so I think it is the only place we are doing that.