Browse Source

Merge pull request #1903 from pixelfed/staging

Add Announcements/Newsroom feature
daniel 5 years ago
parent
commit
d4d85634fb

+ 150 - 0
app/Http/Controllers/AdminController.php

@@ -9,6 +9,7 @@ use App\{
   Instance,
   Media,
   Like,
+  Newsroom,
   OauthClient,
   Profile,
   Report,
@@ -258,4 +259,153 @@ class AdminController extends Controller
       $message->save();
       return;
     }
+
+    public function newsroomHome(Request $request)
+    {
+      $newsroom = Newsroom::latest()->paginate(10);
+      return view('admin.newsroom.home', compact('newsroom'));
+    }
+
+    public function newsroomCreate(Request $request)
+    {
+      return view('admin.newsroom.create');
+    }
+
+    public function newsroomEdit(Request $request, $id)
+    {
+      $news = Newsroom::findOrFail($id);
+      return view('admin.newsroom.edit', compact('news'));
+    }
+
+    public function newsroomDelete(Request $request, $id)
+    {
+      $news = Newsroom::findOrFail($id);
+      $news->delete();
+      return redirect('/i/admin/newsroom');
+    }
+
+    public function newsroomUpdate(Request $request, $id)
+    {
+      $this->validate($request, [
+        'title' => 'required|string|min:1|max:100',
+        'summary' => 'nullable|string|max:200',
+        'body'  => 'nullable|string'
+      ]);
+      $changed = false;
+      $changedFields = [];
+      $news = Newsroom::findOrFail($id);
+      $fields = [
+        'title' => 'string',
+        'summary' => 'string',
+        'body' => 'string',
+        'category' => 'string',
+        'show_timeline' => 'boolean',
+        'auth_only' => 'boolean',
+        'show_link' => 'boolean',
+        'force_modal' => 'boolean',
+        'published' => 'published'
+      ];
+      foreach($fields as $field => $type) {
+        switch ($type) {
+          case 'string':
+            if($request->{$field} != $news->{$field}) {
+              if($field == 'title') {
+                $news->slug = str_slug($request->{$field});
+              }
+              $news->{$field} = $request->{$field};
+              $changed = true;
+              array_push($changedFields, $field);
+            }
+            break;
+
+          case 'boolean':
+            $state = $request->{$field} == 'on' ? true : false;
+            if($state != $news->{$field}) {
+              $news->{$field} = $state;
+              $changed = true;
+              array_push($changedFields, $field);
+            }
+            break;
+          case 'published':
+            $state = $request->{$field} == 'on' ? true : false;
+            $published = $news->published_at != null;
+            if($state != $published) {
+              $news->published_at = $state ? now() : null;
+              $changed = true;
+              array_push($changedFields, $field);
+            }
+            break;
+          
+        }
+      }
+
+      if($changed) {
+        $news->save();
+      }
+      $redirect = $news->published_at ? $news->permalink() : $news->editUrl();
+      return redirect($redirect);
+    }
+
+
+    public function newsroomStore(Request $request)
+    {
+      $this->validate($request, [
+        'title' => 'required|string|min:1|max:100',
+        'summary' => 'nullable|string|max:200',
+        'body'  => 'nullable|string'
+      ]);
+      $changed = false;
+      $changedFields = [];
+      $news = new Newsroom();
+      $fields = [
+        'title' => 'string',
+        'summary' => 'string',
+        'body' => 'string',
+        'category' => 'string',
+        'show_timeline' => 'boolean',
+        'auth_only' => 'boolean',
+        'show_link' => 'boolean',
+        'force_modal' => 'boolean',
+        'published' => 'published'
+      ];
+      foreach($fields as $field => $type) {
+        switch ($type) {
+          case 'string':
+            if($request->{$field} != $news->{$field}) {
+              if($field == 'title') {
+                $news->slug = str_slug($request->{$field});
+              }
+              $news->{$field} = $request->{$field};
+              $changed = true;
+              array_push($changedFields, $field);
+            }
+            break;
+
+          case 'boolean':
+            $state = $request->{$field} == 'on' ? true : false;
+            if($state != $news->{$field}) {
+              $news->{$field} = $state;
+              $changed = true;
+              array_push($changedFields, $field);
+            }
+            break;
+          case 'published':
+            $state = $request->{$field} == 'on' ? true : false;
+            $published = $news->published_at != null;
+            if($state != $published) {
+              $news->published_at = $state ? now() : null;
+              $changed = true;
+              array_push($changedFields, $field);
+            }
+            break;
+          
+        }
+      }
+
+      if($changed) {
+        $news->save();
+      }
+      $redirect = $news->published_at ? $news->permalink() : $news->editUrl();
+      return redirect($redirect);
+    }
 }

+ 94 - 0
app/Http/Controllers/NewsroomController.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Auth;
+use App\Newsroom;
+use Illuminate\Support\Str;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Redis;
+
+class NewsroomController extends Controller
+{
+
+	public function index(Request $request)
+	{
+		if(Auth::check()) {
+			$posts = Newsroom::whereNotNull('published_at')->latest()->paginate(9);
+		} else {
+			$posts = Newsroom::whereNotNull('published_at')
+				->whereAuthOnly(false)
+				->latest()
+				->paginate(3);
+		}
+		return view('site.news.home', compact('posts'));
+	}
+
+	public function show(Request $request, $year, $month, $slug)
+	{
+		$post = Newsroom::whereNotNull('published_at')
+			->whereSlug($slug)
+			->whereYear('published_at', $year)
+			->whereMonth('published_at', $month)
+			->firstOrFail();
+		abort_if($post->auth_only && !$request->user(), 404);
+		return view('site.news.post.show', compact('post'));
+	}
+
+	public function search(Request $request)
+	{
+		abort(404);
+		$this->validate($request, [
+			'q'			=> 'nullable'
+		]);
+	}
+
+	public function archive(Request $request)
+	{
+		abort(404);
+		return view('site.news.archive.index');
+	}
+
+	public function timelineApi(Request $request)
+	{
+		abort_if(!Auth::check(), 404);
+
+		$key = 'newsroom:read:profileid:' . $request->user()->profile_id;
+		$read = Redis::smembers($key);
+
+		$posts = Newsroom::whereNotNull('published_at')
+			->whereShowTimeline(true)
+			->whereNotIn('id', $read)
+			->orderBy('id', 'desc')
+			->take(9)
+			->get()
+			->map(function($post) {
+				return [
+					'id' => $post->id,
+					'title' => Str::limit($post->title, 25),
+					'summary' => $post->summary,
+					'url' => $post->show_link ? $post->permalink() : null,
+					'published_at' => $post->published_at->format('F m, Y')
+				];
+			});
+		return response()->json($posts, 200, [], JSON_PRETTY_PRINT);
+	}
+
+	public function markAsRead(Request $request)
+	{
+		abort_if(!Auth::check(), 404);
+
+		$this->validate($request, [
+			'id' => 'required|integer|min:1'
+		]);
+
+		$news = Newsroom::whereNotNull('published_at')
+			->findOrFail($request->input('id'));
+
+		$key = 'newsroom:read:profileid:' . $request->user()->profile_id;
+
+		Redis::sadd($key, $news->id);
+
+		return response()->json(['code' => 200]);
+	}
+}

+ 27 - 0
app/Newsroom.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Newsroom extends Model
+{
+    protected $table = 'newsroom';
+    protected $fillable = ['title'];
+
+    protected $dates = ['published_at'];
+
+    public function permalink()
+    {
+    	$year = $this->published_at->year;
+    	$month = $this->published_at->format('m');
+    	$slug = $this->slug;
+
+    	return url("/site/newsroom/{$year}/{$month}/{$slug}");
+    }
+
+    public function editUrl()
+    {
+        return url("/i/admin/newsroom/edit/{$this->id}");
+    }
+}

+ 45 - 0
database/migrations/2019_12_10_023604_create_newsroom_table.php

@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateNewsroomTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('newsroom', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->nullable();
+            $table->string('header_photo_url')->nullable();
+            $table->string('title')->nullable();
+            $table->string('slug')->nullable()->unique()->index();
+            $table->string('category')->default('update');
+            $table->text('summary')->nullable();
+            $table->text('body')->nullable();
+            $table->text('body_rendered')->nullable();
+            $table->string('link')->nullable();
+            $table->boolean('force_modal')->default(false);
+            $table->boolean('show_timeline')->default(false);
+            $table->boolean('show_link')->default(false);
+            $table->boolean('auth_only')->default(true);
+            $table->timestamp('published_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('site_news');
+    }
+}

BIN
public/js/compose-classic.js


BIN
public/js/compose.js


BIN
public/js/profile.js


BIN
public/js/status.js


BIN
public/js/timeline.js


BIN
public/mix-manifest.json


+ 155 - 0
resources/assets/js/components/AnnouncementsCard.vue

@@ -0,0 +1,155 @@
+<template>
+<div>
+	<transition name="fade">
+		<div v-if="announcements.length" class="card border shadow-none mb-3" style="max-width: 18rem;">
+			<div class="card-body">
+				<div class="card-title mb-0">
+					<span class="font-weight-bold">{{announcement.title}}</span>
+					<span class="float-right cursor-pointer" title="Close" @click="close"><i class="fas fa-times text-lighter"></i></span>
+				</div>
+				<p class="card-text">
+					<span style="font-size:13px;">{{announcement.summary}}</span>
+				</p>
+				<p class="d-flex align-items-center justify-content-between mb-0">
+					<a v-if="announcement.url" :href="announcement.url" class="small font-weight-bold mb-0">Read more</a>
+					<span v-else></span>
+					<span>
+						<span :class="[showPrev ? 'btn btn-outline-secondary btn-sm py-0':'btn btn-outline-secondary btn-sm py-0 disabled']" :disabled="showPrev == false" @click="loadPrev()">
+							<i class="fas fa-chevron-left fa-sm"></i>
+						</span>
+						<span class="btn btn-outline-success btn-sm py-0 mx-1" title="Mark as Read" data-toggle="tooltip" data-placement="bottom" @click="markAsRead()">
+							<i class="fas fa-check fa-sm"></i>
+						</span>
+						<span :class="[showNext ? 'btn btn-outline-secondary btn-sm py-0':'btn btn-outline-secondary btn-sm py-0 disabled']" :disabled="showNext == false" @click="loadNext()">
+							<i class="fas fa-chevron-right fa-sm"></i>
+						</span>
+					</span>
+				</p>
+			</div>
+		</div>
+	</transition>
+</div>
+</template>
+
+<style type="text/css" scoped>
+.fade-enter-active, .fade-leave-active {
+  transition: opacity .5s;
+}
+.fade-enter, .fade-leave-to {
+  opacity: 0;
+}
+</style>
+
+<script type="text/javascript">
+export default {
+	data() {
+		return {
+			announcements: [],
+			announcement: {},
+			cursor: 0,
+			showNext: true,
+			showPrev: false
+		}
+	},
+
+	mounted() {
+		this.fetchAnnouncements();
+	},
+
+	updated() {
+		$('[data-toggle="tooltip"]').tooltip()
+	},
+
+	methods: {
+		fetchAnnouncements() {
+			let self = this;
+			let key = 'metro-tips-closed';
+			let cached = JSON.parse(window.localStorage.getItem(key));
+			axios.get('/api/pixelfed/v1/newsroom/timeline')
+			.then(res => {
+				self.announcements = res.data.filter(p => {
+					if(cached) {
+						return cached.indexOf(p.id) == -1;
+					} else {
+						return true;
+					}
+				});
+				self.announcement = self.announcements[0]
+				if(self.announcements.length == 1) {
+					self.showNext = false;
+				}
+			})
+		},
+
+		loadNext() {
+			if(!this.showNext) {
+				return;
+			}
+			this.cursor += 1;
+			this.announcement = this.announcements[this.cursor];
+			if((this.cursor + 1) == this.announcements.length) {
+				this.showNext = false;
+			}
+			if(this.cursor >= 1) {
+				this.showPrev = true;
+			}
+		},
+
+		loadPrev() {
+			if(!this.showPrev) {
+				return;
+			}
+			this.cursor -= 1;
+			this.announcement = this.announcements[this.cursor];
+			if(this.cursor == 0) {
+				this.showPrev = false;
+			}
+			if(this.cursor < this.announcements.length) {
+				this.showNext = true;
+			}
+		},
+
+		closeNewsroomPost(id, index) {
+			let key = 'metro-tips-closed';
+			let ctx = [];
+			let cached = window.localStorage.getItem(key);
+			if(cached) {
+				ctx = JSON.parse(cached);
+			}
+			ctx.push(id);
+			window.localStorage.setItem(key, JSON.stringify(ctx));
+			this.newsroomPosts = this.newsroomPosts.filter(res => {
+				return res.id !== id
+			});
+			if(this.newsroomPosts.length == 0) {
+				this.showTips = false;
+			} else {
+				this.newsroomPost = [ this.newsroomPosts[0] ];
+			}
+		},
+
+		close() {
+			window.localStorage.setItem('metro-tips', false);
+			this.$emit('show-tips', false);
+		},
+
+		markAsRead() {
+			let vm = this;
+			axios.post('/api/pixelfed/v1/newsroom/markasread', {
+				id: this.announcement.id
+			})
+			.then(res => {
+				let cur = vm.cursor;
+				vm.announcements.splice(cur, 1);
+				vm.announcement = vm.announcements[0];
+				vm.cursor = 0;
+				vm.showPrev = false;
+				vm.showNext = vm.announcements.length > 1;
+			})
+			.catch(err => {
+				swal('Oops, Something went wrong', 'There was a problem with your request, please try again later.', 'error');
+			});
+		}
+	}
+}
+</script>

+ 4 - 2
resources/assets/js/components/NotificationCard.vue

@@ -1,5 +1,6 @@
 <template>
-	<div>
+<div>
+	<transition name="fade">
 		<div class="card notification-card shadow-none border">
 			<div class="card-header bg-white">
 				<p class="mb-0 d-flex align-items-center justify-content-between">
@@ -57,7 +58,8 @@
 				</div>
 			</div>
 		</div>
-	</div>
+	</transition>
+</div>
 </template>
 
 <style type="text/css" scoped></style>

+ 6 - 55
resources/assets/js/components/Timeline.vue

@@ -90,41 +90,6 @@
 								<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu(status)">
 									<span class="fas fa-ellipsis-h text-lighter"></span>
 								</button>
-								<!-- <div class="dropdown-menu dropdown-menu-right">
-									<a class="dropdown-item font-weight-bold" :href="status.url">Go to post</a>
-									<!-- <a class="dropdown-item font-weight-bold" href="#">Share</a>
-									<a class="dropdown-item font-weight-bold" href="#">Embed</a> ->
-									<span v-if="statusOwner(status) == false">
-										<a class="dropdown-item font-weight-bold" :href="reportUrl(status)">Report</a>
-										<a class="dropdown-item font-weight-bold" v-on:click="muteProfile(status)">Mute Profile</a>
-										<a class="dropdown-item font-weight-bold" v-on:click="blockProfile(status)">Block Profile</a>
-									</span>
-									<span v-if="statusOwner(status) == true">
-										<a class="dropdown-item font-weight-bold text-danger" v-on:click="deletePost(status)">Delete</a>
-									</span>
-									<span v-if="profile.is_admin == true && modes.mod == true">
-										<div class="dropdown-divider"></div>
-										<a v-if="!statusOwner(status)" class="dropdown-item font-weight-bold text-danger" v-on:click="deletePost(status)">Delete</a>
-										<div class="dropdown-divider"></div>
-										<h6 class="dropdown-header">Mod Tools</h6>
-										<a class="dropdown-item font-weight-bold" v-on:click="moderatePost(status, 'autocw')">
-											<p class="mb-0" data-toggle="tooltip" data-placement="bottom" title="Adds a CW to every post made by this account.">Enforce CW</p>
-										</a>
-										<a class="dropdown-item font-weight-bold" v-on:click="moderatePost(status, 'noautolink')">
-											<p class="mb-0" title="Do not transform mentions, hashtags or urls into HTML.">No Autolinking</p>
-										</a>
-										<a class="dropdown-item font-weight-bold" v-on:click="moderatePost(status, 'unlisted')">
-											<p class="mb-0" title="Removes account from public/network timelines.">Unlisted Posts</p>
-										</a>
-										<a class="dropdown-item font-weight-bold" v-on:click="moderatePost(status, 'disable')">
-											<p class="mb-0" title="Temporarily disable account until next time user log in.">Disable Account</p>
-										</a>
-										<a class="dropdown-item font-weight-bold" v-on:click="moderatePost(status, 'suspend')">
-											<p class="mb-0" title="This prevents any new interactions, without deleting existing data.">Suspend Account</p>
-										</a>
-
-									</span>
-								</div> -->
 							</div>
 						</div>
 
@@ -281,21 +246,13 @@
 				</div>
 
 				<div class="mb-4">
-					<a class="btn btn-light btn-block btn-sm font-weight-bold text-dark mb-3 border" href="/i/compose" data-toggle="modal" data-target="#composeModal"><i class="far fa-plus-square pr-3 fa-lg pt-1"></i> Compose Post</a>
+					<a class="btn btn-light btn-block btn-sm font-weight-bold text-dark mb-3 border bg-white" href="/i/compose" data-toggle="modal" data-target="#composeModal">
+						<i class="far fa-plus-square pr-3 fa-lg pt-1"></i> Compose Post
+					</a>
 				</div>
 
-				<div v-if="showTips" class="mb-4 card-tips">
-					<div class="card border shadow-none mb-3" style="max-width: 18rem;">
-						<div class="card-body">
-							<div class="card-title">
-								<span class="font-weight-bold">Tip: Hide follower counts</span>
-								<span class="float-right cursor-pointer" @click.prevent="hideTips()"><i class="fas fa-times text-lighter"></i></span>
-							</div>
-							<p class="card-text">
-								<span style="font-size:13px;">You can hide followers or following count and lists on your profile.</span>
-								<br><a href="/settings/privacy/" class="small font-weight-bold">Privacy Settings</a></p>
-						</div>
-					</div>
+				<div v-if="showTips && !loading" class="mb-4 card-tips">
+					<announcements-card v-on:show-tips="showTips = $event"></announcements-card>
 				</div>
 
 				<div v-show="modes.notify == true && !loading" class="mb-4">
@@ -565,7 +522,6 @@
 		beforeMount() {
 			this.fetchProfile();
 			this.fetchTimelineApi();
-
 		},
 
 		mounted() {
@@ -1359,11 +1315,6 @@
 				this.$refs.ctxModModal.hide();
 			},
 
-			hideTips() {
-				this.showTips = false;
-				window.localStorage.setItem('metro-tips', false);
-			},
-
 			formatCount(count) {
 				return App.util.format.count(count);
 			},
@@ -1431,7 +1382,7 @@
 				return _.truncate(caption, {
 					length: len
 				});
-			}
+			},
 		}
 	}
 </script>

+ 5 - 0
resources/assets/js/timeline.js

@@ -36,4 +36,9 @@ Vue.component(
 Vue.component(
     'timeline',
     require('./components/Timeline.vue').default
+);
+
+Vue.component(
+    'announcements-card',
+    require('./components/AnnouncementsCard.vue').default
 );

+ 135 - 0
resources/views/admin/newsroom/create.blade.php

@@ -0,0 +1,135 @@
+@extends('admin.partial.template-full')
+
+@section('section')
+<div class="row">
+	<div class="col-12">
+		<div class="d-flex justify-content-between align-items-center">
+
+			<div class="title">
+				<p class="h1 font-weight-bold">Newsroom</p>
+				<p class="lead mb-0">Create Announcement</p>
+			</div>
+			<div>
+				<a class="btn btn-outline-secondary px-2" style="font-size:13px;" href="{{route('admin.newsroom.home')}}"><i class="fas fa-chevron-left fa-sm text-lighter mr-1"></i> Back to Newsroom </a>
+			</div>
+		</div>
+		<hr>
+	</div>
+	<div class="col-md-7 border-right">
+		<div>
+			<form method="post">
+				@csrf
+				<div class="form-group">
+					<label for="title" class="small font-weight-bold text-muted text-uppercase">Title</label>
+					<input type="text" class="form-control" id="title" name="title">
+					<p class="help-text mb-0 small font-weight-bold text-lighter">We recommend titles shorter than 80 characters.</p>
+				</div>
+				<div class="form-group">
+					<label for="summary" class="small font-weight-bold text-muted text-uppercase">Summary</label>
+					<textarea class="form-control" id="summary" name="summary" rows="3"></textarea>
+				</div>
+				<div class="form-group">
+					<label for="body" class="small font-weight-bold text-muted text-uppercase">Body</label>
+					<textarea class="form-control" id="body" name="body" rows="6"></textarea>
+					<p class="help-text mb-0 small font-weight-bold text-lighter">Click <a href="#">here</a> to enable the rich text editor.</p>
+				</div>
+				<div class="form-group">
+					<label for="category" class="small font-weight-bold text-muted text-uppercase">Category</label>
+					<input type="text" class="form-control" id="category" name="category" value="update">
+				</div>
+			</div>
+
+		</div>
+		<div class="col-md-5">
+			<label class="small font-weight-bold text-muted text-uppercase">Preview</label>
+			<div class="card border shadow-none mb-3">
+				<div class="card-body">
+					<div class="card-title mb-0">
+						<span class="font-weight-bold" id="preview_title">Untitled</span>
+						<span class="float-right cursor-pointer" title="Close"><i class="fas fa-times text-lighter"></i></span>
+					</div>
+					<p class="card-text">
+						<span style="font-size:13px;" id="preview_summary">Add a summary</span>
+					</p>
+					<p class="d-flex align-items-center justify-content-between mb-0">
+						<a href="#" class="small font-weight-bold mb-0">Read more</a>
+						<span>
+							<span class="btn btn-outline-secondary btn-sm py-0 disabled">
+								<i class="fas fa-chevron-left fa-sm"></i>
+							</span>
+							<span class="btn btn-outline-success btn-sm py-0 mx-1" title="Mark as Read" data-toggle="tooltip" data-placement="bottom">
+								<i class="fas fa-check fa-sm"></i>
+							</span>
+							<span class="btn btn-outline-secondary btn-sm py-0">
+								<i class="fas fa-chevron-right fa-sm"></i>
+							</span>
+						</span>
+					</p>
+				</div>
+			</div>
+			<hr>
+			<p class="mt-3">
+				<button type="submit" class="btn btn-primary btn-block font-weight-bold py-1 px-4">Save</button>
+			</p>
+			<div class="form-group">
+				<div class="custom-control custom-switch">
+					<input type="checkbox" class="custom-control-input" id="published" name="published">
+					<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="published">Published</label>
+				</div>
+			</div>  
+			<div class="form-group">
+				<div class="custom-control custom-switch">
+					<input type="checkbox" class="custom-control-input" id="show_timeline" name="show_timeline">
+					<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="show_timeline">Show On Timelines</label>
+				</div>
+			</div>  
+			<div class="form-group">
+				<div class="custom-control custom-switch">
+					<input type="checkbox" class="custom-control-input" id="auth_only" name="auth_only">
+					<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="auth_only">Logged in users only</label>
+				</div>
+			</div>  
+			<div class="form-group">
+				<div class="custom-control custom-switch">
+					<input type="checkbox" class="custom-control-input" id="show_link" name="show_link">
+					<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="show_link">Show Read More Link</label>
+				</div>
+			</div>  
+{{-- <div class="form-group">
+<div class="custom-control custom-switch">
+<input type="checkbox" class="custom-control-input" id="force_modal" name="force_modal">
+<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="force_modal">Show Modal on timelines</label>
+</div>
+</div> --}}
+</form>
+</div>
+</div>
+<form id="delete-form" method="post">
+	@method('delete')
+	@csrf
+</form>
+@endsection
+
+@push('scripts')
+<script type="text/javascript">
+	$('#title').on('change keyup paste',function(e) {
+		let el = $(this);
+		let title = el.val()
+		$('#preview_title').text(title);
+	});
+
+	$('#summary').on('change keyup paste',function(e) {
+		let el = $(this);
+		let title = el.val()
+		$('#preview_summary').text(title);
+	});
+
+	$('#btn-delete').on('click', function(e) {
+		e.preventDefault();
+		if(window.confirm('Are you sure you want to delete this post?') == true) {
+			document.getElementById('delete-form').submit();
+		}
+	})
+
+</script>
+@endpush

+ 141 - 0
resources/views/admin/newsroom/edit.blade.php

@@ -0,0 +1,141 @@
+@extends('admin.partial.template-full')
+
+@section('section')
+<div class="row">
+	<div class="col-12">
+		<div class="d-flex justify-content-between align-items-center">
+
+			<div class="title">
+				<p class="h1 font-weight-bold">Newsroom</p>
+				<p class="lead mb-0">Edit Announcement</p>
+			</div>
+			<div>
+				<a class="btn btn-outline-secondary px-2" style="font-size:13px;" href="{{route('admin.newsroom.home')}}"><i class="fas fa-chevron-left fa-sm text-lighter mr-1"></i> Back to Newsroom </a>
+			</div>
+		</div>
+		<hr>
+	</div>
+	<div class="col-md-7 border-right">
+		<div>
+			<form method="post">
+				@csrf
+				<div class="form-group">
+					<label for="title" class="small font-weight-bold text-muted text-uppercase">Title</label>
+					<input type="text" class="form-control" id="title" name="title" value="{{$news->title}}">
+					<p class="help-text mb-0 small font-weight-bold text-lighter">We recommend titles shorter than 80 characters.</p>
+				</div>
+				<div class="form-group">
+					<label for="summary" class="small font-weight-bold text-muted text-uppercase">Summary</label>
+					<textarea class="form-control" id="summary" name="summary" rows="3">{{$news->summary}}</textarea>
+				</div>
+				<div class="form-group">
+					<label for="body" class="small font-weight-bold text-muted text-uppercase">Body</label>
+					<textarea class="form-control" id="body" name="body" rows="6">{{$news->body}}</textarea>
+					<p class="help-text mb-0 small font-weight-bold text-lighter">Click <a href="#">here</a> to enable the rich text editor.</p>
+				</div>
+				<div class="form-group">
+					<label for="category" class="small font-weight-bold text-muted text-uppercase">Category</label>
+					<input type="text" class="form-control" id="category" name="category" value="{{$news->category}}">
+				</div>
+		</div>
+		
+	</div>
+	<div class="col-md-5">
+		<label class="small font-weight-bold text-muted text-uppercase">Preview</label>
+		<div class="card border shadow-none mb-3">
+			<div class="card-body">
+				<div class="card-title mb-0">
+					<span class="font-weight-bold" id="preview_title">{{$news->title}}</span>
+					<span class="float-right cursor-pointer" title="Close"><i class="fas fa-times text-lighter"></i></span>
+				</div>
+				<p class="card-text">
+					<span style="font-size:13px;" id="preview_summary">{{$news->summary}}</span>
+				</p>
+				<p class="d-flex align-items-center justify-content-between mb-0">
+					<a href="#" class="small font-weight-bold mb-0">Read more</a>
+					<span>
+						<span class="btn btn-outline-secondary btn-sm py-0 disabled">
+							<i class="fas fa-chevron-left fa-sm"></i>
+						</span>
+						<span class="btn btn-outline-success btn-sm py-0 mx-1" title="Mark as Read" data-toggle="tooltip" data-placement="bottom">
+							<i class="fas fa-check fa-sm"></i>
+						</span>
+						<span class="btn btn-outline-secondary btn-sm py-0">
+							<i class="fas fa-chevron-right fa-sm"></i>
+						</span>
+					</span>
+				</p>
+			</div>
+		</div>
+		<hr>
+		<p class="mt-3">
+			<button type="submit" class="btn btn-primary btn-block font-weight-bold py-1 px-4">Save</button>
+		</p>
+		<div class="form-group">
+			<div class="custom-control custom-switch">
+				<input type="checkbox" class="custom-control-input" id="published" name="published" {{$news->published_at ? 'checked="checked"' : ''}}>
+				<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="published">Published</label>
+			</div>
+		</div>	
+		<div class="form-group">
+			<div class="custom-control custom-switch">
+				<input type="checkbox" class="custom-control-input" id="show_timeline" name="show_timeline" {{$news->show_timeline ? 'checked="checked"' : ''}}>
+				<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="show_timeline">Show On Timelines</label>
+			</div>
+		</div>	
+		<div class="form-group">
+			<div class="custom-control custom-switch">
+				<input type="checkbox" class="custom-control-input" id="auth_only" name="auth_only" {{$news->auth_only ? 'checked="checked"' : ''}}>
+				<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="auth_only">Logged in users only</label>
+			</div>
+		</div>	
+		<div class="form-group">
+			<div class="custom-control custom-switch">
+				<input type="checkbox" class="custom-control-input" id="show_link" name="show_link" {{$news->show_link ? 'checked="checked"' : ''}}>
+				<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="show_link">Show Read More Link</label>
+			</div>
+		</div>	
+		{{-- <div class="form-group">
+			<div class="custom-control custom-switch">
+				<input type="checkbox" class="custom-control-input" id="force_modal" name="force_modal" {{$news->force_modal ? 'checked="checked"' : ''}}>
+				<label class="custom-control-label font-weight-bold text-uppercase text-muted" for="force_modal">Show Modal on timelines</label>
+			</div>
+		</div> --}}
+		<hr>	
+	</form>
+		<p class="mt-1 d-flex justify-content-between">
+			<button type="button" class="btn btn-outline-secondary btn-sm font-weight-bold py-1 px-3">Preview</button>
+			<button type="button" class="btn btn-outline-danger btn-sm font-weight-bold py-1 px-3" id="btn-delete">Delete</button>
+		</p>
+		
+	</div>
+</div>
+<form id="delete-form" method="post">
+@method('delete')
+@csrf
+</form>
+@endsection
+
+@push('scripts')
+<script type="text/javascript">
+	$('#title').on('change keyup paste',function(e) {
+		let el = $(this);
+		let title = el.val()
+		$('#preview_title').text(title);
+	});
+
+	$('#summary').on('change keyup paste',function(e) {
+		let el = $(this);
+		let title = el.val()
+		$('#preview_summary').text(title);
+	});
+
+	$('#btn-delete').on('click', function(e) {
+		e.preventDefault();
+		if(window.confirm('Are you sure you want to delete this post?') == true) {
+			document.getElementById('delete-form').submit();
+		}
+	})
+
+</script>
+@endpush

+ 62 - 0
resources/views/admin/newsroom/home.blade.php

@@ -0,0 +1,62 @@
+@extends('admin.partial.template-full')
+
+@section('section')
+<div class="d-flex justify-content-between align-items-center">
+  
+  <div class="title">
+    <p class="h1 font-weight-bold">Newsroom</p>
+    <p class="lead mb-0">Manage News and Platform Tips</p>
+  </div>
+
+  <div>
+    <a class="btn btn-outline-success px-4" style="font-size:13px;" href="{{route('admin.newsroom.create')}}">New Announcement</a>
+    <a class="btn btn-outline-secondary px-2 mr-3" style="font-size:13px;" href="/site/newsroom">View Newsroom <i class="fas fa-chevron-right fa-sm text-lighter ml-1"></i></a>
+  </div>
+</div>
+
+<div class="my-5 row">
+  <div class="col-md-8 offset-md-2">
+    <div class="card">
+      <div class="card-header bg-light lead font-weight-bold">
+        Announcements
+      </div>
+      @if($newsroom->count() > 0)
+      <ul class="list-group list-group-flush">
+        @foreach($newsroom as $news)
+          <li class="list-group-item d-flex align-items-center justify-content-between">
+            <div>
+              <p class="mb-0 font-weight-bold">{{str_limit($news->title,30)}}</p>
+              <p class="mb-0 small">{{str_limit($news->summary, 40)}}</p>
+            </div>
+            <div>
+              @if($news->published_at != null)
+              <span class="btn btn-success btn-sm px-2 py-0 font-weight-bold mr-3">PUBLISHED</span>
+              @else
+              <span class="btn btn-outline-secondary btn-sm px-2 py-0 font-weight-bold mr-3">DRAFT</span>
+              @endif
+              <a class="btn btn-outline-lighter btn-sm mr-2" title="Edit Post" data-toggle="tooltip" data-placement="bottom" href="{{$news->editUrl()}}">
+                <i class="fas fa-edit"></i>
+              </a>
+              @if($news->published_at)
+              <a class="btn btn-outline-lighter btn-sm" title="View Post" data-toggle="tooltip" data-placement="bottom" href="{{$news->permalink()}}">
+                <i class="fas fa-eye"></i>
+              </a>
+              @endif
+            </div>
+          </li>
+        @endforeach
+      </ul>
+      @else
+      <div class="card-body text-center">
+        <p class="lead mb-0 p-5">No Announcements Found!</p>
+      </div>
+      @endif
+    </div>
+    <div class="d-flex justify-content-center mt-4">
+      {!!$newsroom->links()!!}
+    </div>
+  </div>
+
+</div>
+
+@endsection

+ 4 - 5
resources/views/admin/partial/topnav.blade.php

@@ -1,4 +1,4 @@
-<nav class="navbar navbar-expand-lg navbar-light bg-white">
+<nav class="navbar navbar-expand-lg navbar-light bg-light">
   <div class="container">
     <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#topbarNav" aria-controls="topbarNav" aria-expanded="false" aria-label="Toggle navigation">
       <span class="navbar-toggler-icon"></span>
@@ -11,9 +11,6 @@
         <li class="nav-item mx-2 {{request()->is('*messages*')?'active':''}}">
           <a class="nav-link font-weight-lighter text-muted" href="{{route('admin.messages')}}">Messages</a>
         </li>
-        <li class="nav-item mx-2 {{request()->is('*hashtags*')?'active':''}}">
-          <a class="nav-link font-weight-lighter text-muted" href="{{route('admin.hashtags')}}">Hashtags</a>
-        </li>
         <li class="nav-item mx-2 {{request()->is('*instances*')?'active':''}}">
           <a class="nav-link font-weight-lighter text-muted" href="{{route('admin.instances')}}">Instances</a>
         </li>
@@ -32,13 +29,15 @@
         <li class="nav-item mx-2 {{request()->is('*users*')?'active':''}}">
           <a class="nav-link font-weight-lighter text-muted" href="{{route('admin.users')}}">Users</a>
         </li>
-        <li class="nav-item dropdown mx-2 {{request()->is(['*settings*','*discover*'])?'active':''}}">
+        <li class="nav-item dropdown mx-2 {{request()->is(['*settings*','*discover*', '*site-news*'])?'active':''}}">
           <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
             More
           </a>
           <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
             <a class="dropdown-item font-weight-bold {{request()->is('*apps*')?'active':''}}" href="{{route('admin.apps')}}">Apps</a>
             <a class="dropdown-item font-weight-bold {{request()->is('*discover*')?'active':''}}" href="{{route('admin.discover')}}">Discover</a>
+            <a class="dropdown-item font-weight-bold {{request()->is('*hashtags*')?'active':''}}" href="{{route('admin.hashtags')}}">Hashtags</a>
+            <a class="dropdown-item font-weight-bold {{request()->is('*site-news*')?'active':''}}" href="/i/admin/site-news">Newsroom</a>
             <div class="dropdown-divider"></div>
             <a class="dropdown-item font-weight-bold" href="/horizon">Horizon</a>
             {{-- <a class="dropdown-item font-weight-bold" href="#">Websockets</a> --}}

+ 7 - 0
resources/views/site/news/archive/index.blade.php

@@ -0,0 +1,7 @@
+@extends('site.news.partial.layout')
+
+@section('body')
+<div class="container">
+	<p class="text-center">Archive here</p>
+</div>
+@endsection

+ 26 - 0
resources/views/site/news/home.blade.php

@@ -0,0 +1,26 @@
+@extends('site.news.partial.layout')
+
+@section('body')
+<div class="container">
+	<div class="row px-3">
+		@foreach($posts->slice(0,1) as $post)
+		<div class="col-12 bg-light d-flex justify-content-center align-items-center mt-2 mb-4" style="height:300px;">
+			<div class="mx-5">
+				<p class="small text-danger mb-0 text-uppercase">{{$post->category}}</p>
+				<p class="small text-muted">{{$post->published_at->format('F d, Y')}}</p>
+				<p class="h1" style="font-size: 2.6rem;font-weight: 700;"><a class="text-dark text-decoration-none" href="{{$post->permalink()}}">{{$post->title}}</a></p>
+			</div>
+		</div>
+		@endforeach
+		@foreach($posts->slice(1) as $post)
+		<div class="col-6 bg-light d-flex justify-content-center align-items-center mt-3 px-5" style="height:300px;">
+			<div class="mx-0">
+				<p class="small text-danger mb-0 text-uppercase">{{$post->category}}</p>
+				<p class="small text-muted">{{$post->published_at->format('F d, Y')}}</p>
+				<p class="h1" style="font-size: 2rem;font-weight: 700;"><a class="text-dark text-decoration-none" href="{{$post->permalink()}}">{{$post->title}}</a></p>
+			</div>
+		</div>
+		@endforeach
+	</div>
+</div>
+@endsection

+ 17 - 0
resources/views/site/news/partial/layout.blade.php

@@ -0,0 +1,17 @@
+@extends('layouts.anon')
+
+@section('content')
+ @include('site.news.partial.nav')
+ @yield('body');
+@endsection
+
+@push('styles')
+<style type="text/css">
+	html, body {
+		background: #fff;
+	}
+	.navbar-laravel {
+		box-shadow: none;
+	}
+</style>
+@endpush

+ 14 - 0
resources/views/site/news/partial/nav.blade.php

@@ -0,0 +1,14 @@
+<div class="container py-4">
+	<div class="col-12 d-flex justify-content-between border-bottom align-items-center pb-3 px-0">
+		<div>
+			<p class="h4 mb-0"><a href="/site/newsroom" class="text-dark text-decoration-none">Newsroom</a></p>
+		</div>
+		<div>
+			<a class="btn btn-outline-secondary btn-sm py-1" href="/"><i class="fas fa-chevron-left fa-sm text-lighter mr-2"></i> Back to Pixelfed</a>
+		</div>
+		{{-- <div>
+			<a href="/site/newsroom/search" class="small text-muted mr-4 text-decoration-none">Search Newsroom</a>
+			<a href="/site/newsroom/archive" class="small text-muted text-decoration-none">Archive</a>
+		</div> --}}
+	</div>
+</div>

+ 33 - 0
resources/views/site/news/post/show.blade.php

@@ -0,0 +1,33 @@
+@extends('site.news.partial.layout')
+
+@section('body')
+<div class="container mt-3">
+	<div class="row px-3">
+		<div class="col-12 bg-light d-flex justify-content-center align-items-center" style="min-height: 400px">
+			<div style="max-width: 550px;">
+				<p class="small text-danger mb-0 text-uppercase">{{$post->category}}</p>
+				<p class="small text-muted">{{$post->published_at->format('F d, Y')}}</p>
+				<p class="h1" style="font-size: 2.6rem;font-weight: 700;">{{$post->title}}</p>
+			</div>
+		</div>
+		<div class="col-12 mt-4">
+			<div class="d-flex justify-content-center">
+				<p class="lead text-center py-5" style="font-size:25px; font-weight: 200; max-width: 550px;">
+					{{$post->summary}}
+				</p>
+			</div>
+		</div>
+		@if($post->body)
+		<div class="col-12 mt-4">
+			<div class="d-flex justify-content-center border-top">
+				<p class="lead py-5" style="max-width: 550px;">
+					{!!$post->body!!}
+				</p>
+			</div>
+		</div>
+		@else
+		<div class="col-12 mt-4"></div>
+		@endif
+	</div>
+</div>
+@endsection

+ 13 - 0
routes/web.php

@@ -52,6 +52,13 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
     Route::get('messages/home', 'AdminController@messagesHome')->name('admin.messages');
     Route::get('messages/show/{id}', 'AdminController@messagesShow');
     Route::post('messages/mark-read', 'AdminController@messagesMarkRead');
+    Route::redirect('site-news', '/i/admin/newsroom');
+    Route::get('newsroom', 'AdminController@newsroomHome')->name('admin.newsroom.home');
+    Route::get('newsroom/create', 'AdminController@newsroomCreate')->name('admin.newsroom.create');
+    Route::get('newsroom/edit/{id}', 'AdminController@newsroomEdit');
+    Route::post('newsroom/edit/{id}', 'AdminController@newsroomUpdate');
+    Route::delete('newsroom/edit/{id}', 'AdminController@newsroomDelete');
+    Route::post('newsroom/create', 'AdminController@newsroomStore');
 });
 
 Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofactor', 'localization'])->group(function () {
@@ -113,6 +120,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
                 Route::get('notifications', 'ApiController@notifications');
                 Route::get('timelines/public', 'PublicApiController@publicTimelineApi');
                 Route::get('timelines/home', 'PublicApiController@homeTimelineApi');
+                Route::get('newsroom/timeline', 'NewsroomController@timelineApi');
+                Route::post('newsroom/markasread', 'NewsroomController@markAsRead');
             });
 
             Route::group(['prefix' => 'v2'], function() {
@@ -360,6 +369,10 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
             Route::view('report-something', 'site.help.report-something')->name('help.report-something');
             Route::view('data-policy', 'site.help.data-policy')->name('help.data-policy');
         });
+        Route::get('newsroom/{year}/{month}/{slug}', 'NewsroomController@show');
+        Route::get('newsroom/archive', 'NewsroomController@archive');
+        Route::get('newsroom/search', 'NewsroomController@search');
+        Route::get('newsroom', 'NewsroomController@index');
     });
 
     Route::group(['prefix' => 'timeline'], function () {