blog: move to a seperate package

This commit is contained in:
undefined committed 2023-02-27 19:48:45 +08:00
1 parent 58d2f59f19
commit 1a4fde4119
18 files changed
+251 -253

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
import {
_, Context, DiscussionNotFoundError, DocumentModel, Filter,
Handler, NumberKeys, ObjectId, OplogModel, paginate,
param, PRIV, Types, UserModel,
} from 'hydrooj';
export const TYPE_BLOG: 70 = 70;
export interface BlogDoc {
docType: 70;
docId: ObjectId;
owner: number;
title: string;
content: string;
ip: string;
updateAt: Date;
nReply: number;
views: number;
reply: any[];
react: Record<string, number>;
}
declare module 'hydrooj' {
interface Model {
blog: typeof BlogModel;
}
interface DocType {
[TYPE_BLOG]: BlogDoc;
}
}
export class BlogModel {
static async add(
owner: number, title: string, content: string, ip?: string,
): Promise<ObjectId> {
const payload: Partial<BlogDoc> = {
content,
owner,
title,
ip,
nReply: 0,
updateAt: new Date(),
views: 0,
};
const res = await DocumentModel.add(
'system', payload.content!, payload.owner!, TYPE_BLOG,
null, null, null, _.omit(payload, ['domainId', 'content', 'owner']),
);
payload.docId = res;
return payload.docId;
}
static async get(did: ObjectId): Promise<BlogDoc> {
return await DocumentModel.get('system', TYPE_BLOG, did);
}
static edit(did: ObjectId, title: string, content: string): Promise<BlogDoc> {
const payload = { title, content };
return DocumentModel.set('system', TYPE_BLOG, did, payload);
}
static inc(did: ObjectId, key: NumberKeys<BlogDoc>, value: number): Promise<BlogDoc | null> {
return DocumentModel.inc('system', TYPE_BLOG, did, key, value);
}
static del(did: ObjectId): Promise<never> {
return Promise.all([
DocumentModel.deleteOne('system', TYPE_BLOG, did),
DocumentModel.deleteMultiStatus('system', TYPE_BLOG, { docId: did }),
]) as any;
}
static count(query: Filter<BlogDoc>) {
return DocumentModel.count('system', TYPE_BLOG, query);
}
static getMulti(query: Filter<BlogDoc> = {}) {
return DocumentModel.getMulti('system', TYPE_BLOG, query)
.sort({ _id: -1 });
}
static async addReply(did: ObjectId, owner: number, content: string, ip: string): Promise<ObjectId> {
const [[, drid]] = await Promise.all([
DocumentModel.push('system', TYPE_BLOG, did, 'reply', content, owner, { ip }),
DocumentModel.incAndSet('system', TYPE_BLOG, did, 'nReply', 1, { updateAt: new Date() }),
]);
return drid;
}
static setStar(did: ObjectId, uid: number, star: boolean) {
return DocumentModel.setStatus('system', TYPE_BLOG, did, uid, { star });
}
static getStatus(did: ObjectId, uid: number) {
return DocumentModel.getStatus('system', TYPE_BLOG, did, uid);
}
static setStatus(did: ObjectId, uid: number, $set) {
return DocumentModel.setStatus('system', TYPE_BLOG, did, uid, $set);
}
}
global.Hydro.model.blog = BlogModel;
class BlogHandler extends Handler {
ddoc?: BlogDoc;
@param('did', Types.ObjectId, true)
async _prepare(domainId: string, did: ObjectId) {
if (did) {
this.ddoc = await BlogModel.get(did);
if (!this.ddoc) throw new DiscussionNotFoundError(domainId, did);
}
}
}
class BlogUserHandler extends BlogHandler {
@param('uid', Types.Int)
@param('page', Types.PositiveInt, true)
async get(domainId: string, uid: number, page = 1) {
const [ddocs, dpcount] = await paginate(
BlogModel.getMulti({ owner: uid }),
page,
10,
);
const udoc = await UserModel.getById(domainId, uid);
this.response.template = 'blog_main.html';
this.response.body = {
ddocs,
dpcount,
udoc,
page,
};
}
}
class BlogDetailHandler extends BlogHandler {
@param('did', Types.ObjectId)
async get(domainId: string, did: ObjectId) {
const dsdoc = this.user.hasPriv(PRIV.PRIV_USER_PROFILE)
? await BlogModel.getStatus(did, this.user._id)
: null;
const udoc = await UserModel.getById(domainId, this.ddoc!.owner);
if (!dsdoc?.view) {
await Promise.all([
BlogModel.inc(did, 'views', 1),
BlogModel.setStatus(did, this.user._id, { view: true }),
]);
}
this.response.template = 'blog_detail.html';
this.response.body = {
ddoc: this.ddoc, dsdoc, udoc,
};
}
async post() {
this.checkPriv(PRIV.PRIV_USER_PROFILE);
}
@param('did', Types.ObjectId)
async postStar(domainId: string, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, true);
this.back({ star: true });
}
@param('did', Types.ObjectId)
async postUnstar(domainId: string, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, false);
this.back({ star: false });
}
}
class BlogEditHandler extends BlogHandler {
async get() {
this.response.template = 'blog_edit.html';
this.response.body = { ddoc: this.ddoc };
}
@param('title', Types.Title)
@param('content', Types.Content)
async postCreate(domainId: string, title: string, content: string) {
await this.limitRate('add_blog', 3600, 60);
const did = await BlogModel.add(this.user._id, title, content, this.request.ip);
this.response.body = { did };
this.response.redirect = this.url('blog_detail', { uid: this.user._id, did });
}
@param('did', Types.ObjectId)
@param('title', Types.Title)
@param('content', Types.Content)
async postUpdate(domainId: string, did: ObjectId, title: string, content: string) {
if (!this.user.own(this.ddoc!)) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM);
await Promise.all([
BlogModel.edit(did, title, content),
OplogModel.log(this, 'blog.edit', this.ddoc),
]);
this.response.body = { did };
this.response.redirect = this.url('blog_detail', { uid: this.user._id, did });
}
@param('did', Types.ObjectId)
async postDelete(domainId: string, did: ObjectId) {
if (!this.user.own(this.ddoc!)) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM);
await Promise.all([
BlogModel.del(did),
OplogModel.log(this, 'blog.delete', this.ddoc),
]);
this.response.redirect = this.url('blog_main', { uid: this.ddoc!.owner });
}
}
export async function apply(ctx: Context) {
ctx.Route('blog_main', '/blog/:uid', BlogUserHandler);
ctx.Route('blog_create', '/blog/:uid/create', BlogEditHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('blog_detail', '/blog/:uid/:did', BlogDetailHandler);
ctx.Route('blog_edit', '/blog/:uid/:did/edit', BlogEditHandler, PRIV.PRIV_USER_PROFILE);
ctx.inject('UserDropdown', 'blog_main', {
icon: 'book', displayName: 'Blog', args: (h) => ({ uid: h.user._id }),
}, PRIV.PRIV_USER_PROFILE);
ctx.i18n.load('zh', {
"{0}'s blog": '{0} 的博客',
Blog: '博客',
blog_detail: '博客详情',
blog_edit: '编辑博客',
blog_main: '博客',
});
ctx.i18n.load('zh_TW', {
"{0}'s blog": '{0} 的部落格',
Blog: '部落格',
blog_detail: '部落格詳情',
blog_edit: '編輯部落格',
blog_main: '部落格',
});
ctx.i18n.load('kr', {
"{0}'s blog": '{0}의 블로그',
Blog: '블로그',
blog_main: '블로그',
blog_detail: '블로그 상세',
blog_edit: '블로그 수정',
});
ctx.i18n.load('en', {
blog_main: 'Blog',
blog_detail: 'Blog Detail',
blog_edit: 'Edit Blog',
});
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "@hydrooj/blog",
"version": "0.0.1",
"license": "AGPL-3.0-or-later"
}
+65
View File
@@ -0,0 +1,65 @@
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__body">
<ul class="supplementary list">
<li>
<a class="discussion-node-tag" href="{{ url('blog_main', uid=udoc._id) }}">
<span class="v-center icon icon-flag"></span>
{{ _("{0}'s blog").format(udoc.uname) }}
</a>
</li>
<li><h2>{{ ddoc.title }}</h2></li>
<li>
{{ user.render_inline(udoc) }}
@
{{ datetimeSpan(ddoc._id)|safe }}
</li>
</ul>
<div class="typo topic__content" data-emoji-enabled>
{{ ddoc['content']|markdown|safe }}
</div>
</div>
<ul class="section__footer supplementary dot list">
{% if handler.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM) or handler.user.own(ddoc) %}
<li><a href="{{ url('blog_edit', uid=udoc._id, did=ddoc.docId) }}">
<span class="icon icon-edit"></span> {{ _('Edit') }}
</a></li>
{% endif %}
<li>{{ _('{0} views').format(ddoc.views) }}</li>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<li><form class="form--inline" method="post">
<input type="hidden" name="operation" value="{% if not dsdoc['star'] %}star{% else %}unstar{% endif %}">
<button class="star{% if dsdoc['star'] %} activated{% endif %}" type="submit">
<span class="starred--hide"><span class="icon icon-star--outline"></span> {{ _('Star') }}</span>
<span class="starred--show"><span class="icon icon-star"></span> {{ _('Unstar') }}</span>
</button>
</form></li>
{% endif %}
<li><a href="{{ url('wiki_help', anchor='contact') }}">
<span class="icon icon-warning"></span> {{ _('Report') }}
</a></li>
</ul>
</div>
</div>
<div class="medium-3 columns">
<div class="section side">
<div class="profile__bg user-profile-bg--1"></div>
<div class="section__body">
<div class="media sidebar-user-profile">
<div class="media__left">
<img src="{{ avatarUrl(udoc.avatar, 80) }}" width="80" height="80" class="medium user-profile-avatar">
</div>
<div class="media__body">
<p>
{{ user.render_inline(udoc, avatar=false, modbadge=false) }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% import "components/form.html" as form with context %}
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__body">
<form method="post">
<div class="row">
<div class="medium-12 columns">
<label>
{{ _('Title') }}
<input
name="title" placeholder="{{ _('title') }}"
value="{{ ddoc.title|default('') }}"
class="textbox" autofocus>
</label>
</div>
</div>
{{ form.form_textarea({
columns:12,
label:'Content',
name:'content',
value:ddoc.content|default(''),
hotkeys:'ctrl+enter:submit',
markdown:true,
required:true,
extra_style:'height: 500px',
extra_textarea_class:'auto-resize'
}) }}
<div class="row"><div class="columns">
<button name="operation" value="{{ 'update' if ddoc else 'create' }}" data-default-submit type="submit" class="rounded primary button">
{{ _('Update') if ddoc else _('Create') }} (Ctrl+Enter)
</button>
{% if ddoc and (handler.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM) or handler.user.own(ddoc)) %}
<button name="operation" value="delete" type="submit" class="rounded button">
{{ _('Delete') }}
</button>
{% endif %}
<button type="button" class="rounded button" onclick="window.history.go(-1)">
{{ _('Cancel') }}
</button>
</div></div>
</form>
</div>
</div>
</div>
<div class="medium-3 columns">
{% include 'components/md_hint.html' %}
</div>
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% import "components/nothing.html" as nothing with context %}
{% import "components/user.html" as user with context %}
{% import "components/paginator.html" as paginator with context %}
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
{% if not ddocs.length %}
{{ nothing.render('no blog yet...') }}
{% else %}
<ol class="section__list discussion__list">
{%- for ddoc in ddocs -%}
<li class="section__list__item discussion__item">
<div class="media">
<div class="media__left">
<div class="discussion__replies numbox">
<div class="numbox__num large">{{ datetimeSpan(ddoc._id, false, 'D')|safe }}</div>
<div class="numbox__text">{{ datetimeSpan(ddoc._id, false, 'YYYY-M')|safe }}</div>
</div>
</div>
<div class="media__body top">
<h1 class="discussion__title"><a href="{{ url('blog_detail', uid=udoc._id, did=ddoc._id, anchor=ddoc.updateAt.getTime()) }}" data-emoji-enabled>{{ ddoc['title'] }}</a></h1>
<ul class="supplementary list">
<li>{{ _('{0} views').format(ddoc.views) }}</li>
<li>{{ datetimeSpan(ddoc.updateAt)|safe }}</li>
</ul>
</div>
</div>
</li>
{%- endfor -%}
</ol>
{% if page and dpcount %}{{ paginator.render(page, dpcount) }}{% endif %}
{% endif %}
</div>
</div>
<div class="medium-3 columns">
<div class="section side">
{% if udoc.blogpic %}<div class="discussion-node__bg {{ udoc.blogpic }}"></div>{% endif %}
<div class="section__header">
<h1 class="section__title">{{ _("{0}'s blog").format(udoc.uname) }}</h1>
</div>
<div class="section__body">
{% if handler.user._id == udoc._id %}
<p><a href="{{ url('blog_create', uid=udoc._id) }}" class="expanded primary button">{{ _('Create a Post') }}</a></p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}