Groups: Discussions

This commit adds discussions (forums) to groups, similar to how it was implemented in the original VK
This commit is contained in:
Maxim Leshchenko 2021-12-15 00:27:17 +02:00
parent 64103ddbc5
commit 356c782f74
No known key found for this signature in database
GPG key ID: BB9C44A8733FBEEE
19 changed files with 750 additions and 9 deletions

View file

@ -0,0 +1,86 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Entities;
use openvk\Web\Models\RowModel;
use openvk\Web\Models\Repositories\Clubs;
use openvk\Web\Util\DateTime;
class Topic extends Postable
{
protected $tableName = "topics";
protected $upperNodeReferenceColumnName = "group";
/**
* May return fake owner (group), if flags are [1, (*)]
*
* @param bool $honourFlags - check flags
*/
function getOwner(bool $honourFlags = true, bool $real = false): RowModel
{
if($honourFlags && $this->isPostedOnBehalfOfGroup())
return $this->getClub();
return parent::getOwner($real);
}
function getClub(): Club
{
return (new Clubs)->get($this->getRecord()->group);
}
function getTitle(): string
{
return $this->getRecord()->title;
}
function isClosed(): bool
{
return (bool) $this->getRecord()->closed;
}
function isPinned(): bool
{
return (bool) $this->getRecord()->pinned;
}
function getPrettyId(): string
{
return $this->getRecord()->group . "_" . $this->getVirtualId();
}
function isPostedOnBehalfOfGroup(): bool
{
return ($this->getRecord()->flags & 0b10000000) > 0;
}
function isDeleted(): bool
{
return (bool) $this->getRecord()->deleted;
}
function canBeModifiedBy(User $user): bool
{
return $this->getOwner(false)->getId() === $user->getId() || $this->club->canBeModifiedBy($user);
}
function getLastComment(): ?Comment
{
$array = iterator_to_array($this->getLastComments(1));
return isset($array[0]) ? $array[0] : NULL;
}
function getUpdateTime(): DateTime
{
$lastComment = $this->getLastComment();
if(!is_null($lastComment))
return $lastComment->getPublicationTime();
else
return $this->getEditTime() ?? $this->getPublicationTime();
}
function deleteTopic(): void
{
$this->setDeleted(1);
$this->unwire();
$this->save();
}
}

View file

@ -0,0 +1,73 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Repositories;
use openvk\Web\Models\Entities\Topic;
use openvk\Web\Models\Entities\Club;
use Nette\Database\Table\ActiveRow;
use Chandler\Database\DatabaseConnection;
class Topics
{
private $context;
private $topics;
function __construct()
{
$this->context = DatabaseConnection::i()->getContext();
$this->topics = $this->context->table("topics");
}
private function toTopic(?ActiveRow $ar): ?Topic
{
return is_null($ar) ? NULL : new Topic($ar);
}
function get(int $id): ?Topic
{
return $this->toTopic($this->topics->get($id));
}
function getTopicById(int $club, int $topic): ?Topic
{
return $this->toTopic($this->topics->where(["group" => $club, "virtual_id" => $topic, "deleted" => 0])->fetch());
}
function getClubTopics(Club $club, int $page = 1, ?int $perPage = NULL): \Traversable
{
$perPage = $perPage ?? OPENVK_DEFAULT_PER_PAGE;
// Get pinned topics first
$query = "SELECT `id` FROM `topics` WHERE `pinned` = 1 AND `group` = ? AND `deleted` = 0 UNION SELECT `id` FROM `topics` WHERE `pinned` = 0 AND `group` = ? AND `deleted` = 0";
$query .= " LIMIT " . $perPage . " OFFSET " . ($page - 1) * $perPage;
foreach(DatabaseConnection::i()->getConnection()->query($query, $club->getId(), $club->getId()) as $topic) {
$topic = $this->get($topic->id);
if(!$topic) continue;
yield $topic;
}
}
function getClubTopicsCount(Club $club): int
{
return sizeof($this->topics->where([
"group" => $club->getId(),
"deleted" => false
]));
}
function find(Club $club, string $query): \Traversable
{
return new Util\EntityStream("Topic", $this->topics->where("title LIKE ? AND group = ? AND deleted = 0", "%$query%", $club->getId()));
}
function getLastTopics(Club $club, ?int $count = NULL): \Traversable
{
$topics = $this->topics->where([
"group" => $club->getId(),
"deleted" => false
])->page(1, $count ?? OPENVK_DEFAULT_PER_PAGE)->order("created DESC");
foreach($topics as $topic)
yield $this->toTopic($topic);
}
}

View file

@ -1,6 +1,6 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
namespace openvk\Web\Presenters; namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\{Comment, Photo, Video, User}; use openvk\Web\Models\Entities\{Comment, Photo, Video, User, Topic};
use openvk\Web\Models\Entities\Notifications\CommentNotification; use openvk\Web\Models\Entities\Notifications\CommentNotification;
use openvk\Web\Models\Repositories\Comments; use openvk\Web\Models\Repositories\Comments;
@ -11,6 +11,7 @@ final class CommentPresenter extends OpenVKPresenter
"photos" => "openvk\\Web\\Models\\Repositories\\Photos", "photos" => "openvk\\Web\\Models\\Repositories\\Photos",
"videos" => "openvk\\Web\\Models\\Repositories\\Videos", "videos" => "openvk\\Web\\Models\\Repositories\\Videos",
"notes" => "openvk\\Web\\Models\\Repositories\\Notes", "notes" => "openvk\\Web\\Models\\Repositories\\Notes",
"topics" => "openvk\\Web\\Models\\Repositories\\Topics",
]; ];
function renderLike(int $id): void function renderLike(int $id): void
@ -38,6 +39,9 @@ final class CommentPresenter extends OpenVKPresenter
$entity = $repo->get($eId); $entity = $repo->get($eId);
if(!$entity) $this->notFound(); if(!$entity) $this->notFound();
if($entity instanceof Topic && $entity->isClosed())
$this->notFound();
$flags = 0; $flags = 0;
if($this->postParam("as_group") === "on") if($this->postParam("as_group") === "on")
$flags |= 0b10000000; $flags |= 0b10000000;

View file

@ -2,7 +2,7 @@
namespace openvk\Web\Presenters; namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\{Club, Photo}; use openvk\Web\Models\Entities\{Club, Photo};
use openvk\Web\Models\Entities\Notifications\ClubModeratorNotification; use openvk\Web\Models\Entities\Notifications\ClubModeratorNotification;
use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers}; use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics};
final class GroupPresenter extends OpenVKPresenter final class GroupPresenter extends OpenVKPresenter
{ {
@ -28,6 +28,8 @@ final class GroupPresenter extends OpenVKPresenter
$this->template->club = $club; $this->template->club = $club;
$this->template->albums = (new Albums)->getClubAlbums($club, 1, 3); $this->template->albums = (new Albums)->getClubAlbums($club, 1, 3);
$this->template->albumsCount = (new Albums)->getClubAlbumsCount($club); $this->template->albumsCount = (new Albums)->getClubAlbumsCount($club);
$this->template->topics = (new Topics)->getLastTopics($club, 3);
$this->template->topicsCount = (new Topics)->getClubTopicsCount($club);
} }
} }
@ -204,6 +206,7 @@ final class GroupPresenter extends OpenVKPresenter
$club->setShortcode(empty($this->postParam("shortcode")) ? NULL : $this->postParam("shortcode")); $club->setShortcode(empty($this->postParam("shortcode")) ? NULL : $this->postParam("shortcode"));
$club->setWall(empty($this->postParam("wall")) ? 0 : 1); $club->setWall(empty($this->postParam("wall")) ? 0 : 1);
$club->setAdministrators_List_Display(empty($this->postParam("administrators_list_display")) ? 0 : $this->postParam("administrators_list_display")); $club->setAdministrators_List_Display(empty($this->postParam("administrators_list_display")) ? 0 : $this->postParam("administrators_list_display"));
$club->setEveryone_Can_Create_Topics(empty($this->postParam("everyone_can_create_topics")) ? 0 : 1);
$website = $this->postParam("website") ?? ""; $website = $this->postParam("website") ?? "";
if(empty($website)) if(empty($website))

View file

@ -0,0 +1,191 @@
<?php declare(strict_types=1);
namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\{Topic, Club, Comment, Photo, Video};
use openvk\Web\Models\Repositories\{Topics, Clubs};
final class TopicsPresenter extends OpenVKPresenter
{
private $topics;
private $clubs;
function __construct(Topics $topics, Clubs $clubs)
{
$this->topics = $topics;
$this->clubs = $clubs;
parent::__construct();
}
function renderBoard(int $id): void
{
$this->assertUserLoggedIn();
$club = $this->clubs->get($id);
if(!$club)
$this->notFound();
$this->template->club = $club;
$page = (int) ($this->queryParam("p") ?? 1);
$query = $this->queryParam("query");
if($query) {
$results = $this->topics->find($club, $query);
$this->template->topics = $results->page($page);
$this->template->count = $results->size();
} else {
$this->template->topics = $this->topics->getClubTopics($club, $page);
$this->template->count = $this->topics->getClubTopicsCount($club);
}
$this->template->paginatorConf = (object) [
"count" => $this->template->count,
"page" => $page,
"amount" => NULL,
"perPage" => OPENVK_DEFAULT_PER_PAGE,
];
}
function renderTopic(int $clubId, int $topicId): void
{
$this->assertUserLoggedIn();
$topic = $this->topics->getTopicById($clubId, $topicId);
if(!$topic)
$this->notFound();
$this->template->topic = $topic;
$this->template->club = $topic->getClub();
$this->template->count = $topic->getCommentsCount();
$this->template->page = (int) ($this->queryParam("p") ?? 1);
$this->template->comments = iterator_to_array($topic->getComments($this->template->page));
}
function renderCreate(int $clubId): void
{
$this->assertUserLoggedIn();
$club = $this->clubs->get($clubId);
if(!$club)
$this->notFound();
if(!$club->isEveryoneCanCreateTopics() && !$club->canBeModifiedBy($this->user->identity))
$this->notFound();
if($_SERVER["REQUEST_METHOD"] === "POST") {
$this->willExecuteWriteAction();
$title = $this->postParam("title");
if(!$title)
$this->flashFail("err", tr("failed_to_create_topic"), tr("no_title_specified"));
$flags = 0;
if($this->postParam("as_group") === "on")
$flags |= 0b10000000;
$topic = new Topic;
$topic->setGroup($club->getId());
$topic->setOwner($this->user->id);
$topic->setTitle(ovk_proc_strtr($title, 127));
$topic->setCreated(time());
$topic->setFlags($flags);
$topic->save();
// TODO move to trait
try {
$photo = NULL;
$video = NULL;
if($_FILES["_pic_attachment"]["error"] === UPLOAD_ERR_OK) {
$album = NULL;
if($wall > 0 && $wall === $this->user->id)
$album = (new Albums)->getUserWallAlbum($wallOwner);
$photo = Photo::fastMake($this->user->id, $this->postParam("text"), $_FILES["_pic_attachment"], $album);
}
if($_FILES["_vid_attachment"]["error"] === UPLOAD_ERR_OK) {
$video = Video::fastMake($this->user->id, $this->postParam("text"), $_FILES["_vid_attachment"]);
}
} catch(ISE $ex) {
$this->flash("err", "Не удалось опубликовать комментарий", "Файл медиаконтента повреждён или слишком велик.");
$this->redirect("/topic" . $topic->getPrettyId(), static::REDIRECT_TEMPORARY);
}
if(!empty($this->postParam("text")) || $photo || $video) {
try {
$comment = new Comment;
$comment->setOwner($this->user->id);
$comment->setModel(get_class($topic));
$comment->setTarget($topic->getId());
$comment->setContent($this->postParam("text"));
$comment->setCreated(time());
$comment->setFlags($flags);
$comment->save();
} catch (\LengthException $ex) {
$this->flash("err", "Не удалось опубликовать комментарий", "Комментарий слишком большой.");
$this->redirect("/topic" . $topic->getPrettyId(), static::REDIRECT_TEMPORARY);
}
if(!is_null($photo))
$comment->attach($photo);
if(!is_null($video))
$comment->attach($video);
}
$this->redirect("/topic" . $topic->getPrettyId(), static::REDIRECT_TEMPORARY);
}
$this->template->club = $club;
$this->template->graffiti = (bool) ovkGetQuirk("comments.allow-graffiti");
}
function renderEdit(int $clubId, int $topicId): void
{
$this->assertUserLoggedIn();
$topic = $this->topics->getTopicById($clubId, $topicId);
if(!$topic)
$this->notFound();
if(!$topic->canBeModifiedBy($this->user->identity))
$this->notFound();
if($_SERVER["REQUEST_METHOD"] === "POST") {
$this->willExecuteWriteAction();
$title = $this->postParam("title");
if(!$title)
$this->flashFail("err", tr("failed_to_change_topic"), tr("no_title_specified"));
$topic->setTitle(ovk_proc_strtr($title, 127));
$topic->setClosed(empty($this->postParam("close")) ? 0 : 1);
$topic->setPinned(empty($this->postParam("pin")) ? 0 : 1);
$topic->save();
$this->flash("succ", tr("changes_saved"), tr("topic_changes_saved_comment"));
$this->redirect("/topic" . $topic->getPrettyId(), static::REDIRECT_TEMPORARY);
}
$this->template->topic = $topic;
$this->template->club = $topic->getClub();
}
function renderDelete(int $clubId, int $topicId): void
{
$this->assertUserLoggedIn();
$this->assertNoCSRF();
$topic = $this->topics->getTopicById($clubId, $topicId);
if(!$topic)
$this->notFound();
if(!$topic->canBeModifiedBy($this->user->identity))
$this->notFound();
$this->willExecuteWriteAction();
$topic->deleteTopic();
$this->redirect("/board" . $topic->getClub()->getId(), static::REDIRECT_TEMPORARY);
}
}

View file

@ -77,6 +77,14 @@
<input type="checkbox" name="wall" value="1" {if $club->canPost()}checked{/if}/> {_group_allow_post_for_everyone} <input type="checkbox" name="wall" value="1" {if $club->canPost()}checked{/if}/> {_group_allow_post_for_everyone}
</td> </td>
</tr> </tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_discussions}: </span>
</td>
<td>
<input type="checkbox" name="everyone_can_create_topics" value="1" n:attr="checked => $club->isEveryoneCanCreateTopics()" /> {_everyone_can_create_topics}
</td>
</tr>
<tr> <tr>
<td width="120" valign="top"> <td width="120" valign="top">
<span class="nobold">{_group_administrators_list}: </span> <span class="nobold">{_group_administrators_list}: </span>

View file

@ -197,12 +197,31 @@
</div> </div>
<div> <div>
<b><a href="/album{$album->getPrettyId()}">{$album->getName()}</a></b><br> <b><a href="/album{$album->getPrettyId()}">{$album->getName()}</a></b><br>
<span class="nobold">Обновлён {$album->getEditTime() ?? $album->getCreationTime()}</span> <span class="nobold">{tr("updated_at", $album->getEditTime() ?? $album->getCreationTime())}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div n:if="$topicsCount > 0 || $club->isEveryoneCanCreateTopics() || $club->canBeModifiedBy($thisUser)">
<div class="content_title_expanded" onclick="hidePanel(this, {$topicsCount});">
{_discussions}
</div>
<div>
<div class="content_subtitle">
{tr("topics", $topicsCount)}
<div style="float: right;">
<a href="/board{$club->getId()}">{_"all_title"}</a>
</div>
</div>
<div>
<div n:foreach="$topics as $topic" style="border-bottom: #e6e6e6 solid 1px; padding: 2px;">
<b><a href="/topic{$topic->getPrettyId()}">{$topic->getTitle()}</a></b><br>
<span class="nobold">{tr("updated_at", $topic->getUpdateTime())}</span>
</div>
</div>
</div>
</div>
</div> </div>
{/block} {/block}

View file

@ -0,0 +1,61 @@
{extends "../@listView.xml"}
{var iterator = iterator_to_array($topics)}
{var page = $paginatorConf->page}
{block title}{_discussions} {$club->getCanonicalName()}{/block}
{block header}
<a href="{$club->getURL()}">{$club->getCanonicalName()}</a> » {_discussions}
<div n:if="$club->isEveryoneCanCreateTopics() || $club->canBeModifiedBy($thisUser)" style="float: right;">
<a href="/board{$club->getId()}/create">{_create_topic}</a>
</div>
{/block}
{block tabs}
<form style="margin-left: 12px;">
<input name="query" class="header_search_input" placeholder="{_"header_search"}" value="{$_GET['query'] ?? ''}" style="width: 90%" />
<input type="submit" class="button" value="{_"search_button"}" style="width: 7%" />
</form>
<p style="margin-left: 15px;">
<b>{tr("results", $count)}</b>
</p>
{/block}
{block actions}
{/block}
{* BEGIN ELEMENTS DESCRIPTION *}
{block link|strip|stripHtml}
/topic{$x->getPrettyId()}
{/block}
{block preview}
{/block}
{block name}
{$x->getTitle()}
<div n:if="$x->isPinned()" class="pinned-mark"></div>
{/block}
{block description}
<div style="float: left;">
{tr("messages", $x->getCommentsCount())}
</div>
{var lastComment = $x->getLastComment()}
<div n:if="$lastComment" class="avatar-list-item" style="float: right;">
<div class="avatar">
<a href="{$lastComment->getOwner()->getURL()}">
<img class="ava" src="{$lastComment->getOwner()->getAvatarUrl()}" />
</a>
</div>
<div class="info">
<a href="{$lastComment->getOwner()->getURL()}" class="title">{$lastComment->getOwner()->getCanonicalName()}</a>
<div class="subtitle">{_replied} {$lastComment->getPublicationTime()}</div>
</div>
</div>
{/block}

View file

@ -0,0 +1,96 @@
{extends "../@layout.xml"}
{block title}{_new_topic}{/block}
{block header}
<a href="{$club->getURL()}">{$club->getCanonicalName()}</a>
»
<a href="/board{$club->getId()}">{_discussions}</a>
»
{_new_topic}
{/block}
{block content}
<form method="POST" enctype="multipart/form-data">
<table cellspacing="7" cellpadding="0" width="80%" border="0" align="center">
<tbody>
<tr>
<td width="120" valign="top">
<span class="nobold">{_title}</span>
</td>
<td>
<input type="text" name="title" style="width: 100%;" />
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_text}</span>
</td>
<td>
<textarea id="wall-post-input1" name="text" style="width: 100%; resize: none;"></textarea>
<div n:if="$club->canBeModifiedBy($thisUser)" class="post-opts">
<label>
<input type="checkbox" name="as_group" onchange="onWallAsGroupClick(this)" /> {_post_as_group}
</label>
</div>
<div id="post-buttons1">
<div class="post-upload">
{_attachment}: <span>(unknown)</span>
</div>
<input type="file" class="postFileSel" id="postFilePic" name="_pic_attachment" accept="image/*" style="display: none;" />
<input type="file" class="postFileSel" id="postFileVid" name="_vid_attachment" accept="video/*" style="display: none;" />
<br/>
<div style="float: right; display: flex; flex-direction: column;">
<a href="javascript:void(u('#post-buttons1 #wallAttachmentMenu').toggleClass('hidden'));">
{_attach}
</a>
<div id="wallAttachmentMenu" class="hidden">
<a href="javascript:void(document.querySelector('#post-buttons1 input[name=_pic_attachment]').click());">
<img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/mimetypes/application-x-egon.png" />
{_attach_photo}
</a>
<a href="javascript:void(document.querySelector('#post-buttons1 input[name=_vid_attachment]').click());">
<img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/mimetypes/application-vnd.rn-realmedia.png" />
{_attach_video}
</a>
<a n:if="$graffiti ?? false" href="javascript:initGraffiti(1);">
<img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/actions/draw-brush.png" />
{_draw_graffiti}
</a>
</div>
</div>
</div>
</td>
</tr>
<tr>
<td>
</td>
<td>
<input type="hidden" name="hash" value="{$csrfToken}" />
<input type="submit" value="{_create_topic}" class="button" />
</td>
</tr>
</tbody>
</table>
<input type="hidden" name="hash" value="{$csrfToken}" />
</form>
<script>
$(document).ready(() => {
u("#post-buttons1 .postFileSel").on("change", function() {
handleUpload.bind(this, 1)();
});
setupWallPostInputHandlers(1);
});
</script>
{if $graffiti}
{script "js/node_modules/react/dist/react-with-addons.min.js"}
{script "js/node_modules/react-dom/dist/react-dom.min.js"}
{script "js/vnd_literallycanvas.js"}
{css "js/node_modules/literallycanvas/lib/css/literallycanvas.css"}
{/if}
{/block}

View file

@ -0,0 +1,53 @@
{extends "../@layout.xml"}
{block title}{_edit_topic} "{$topic->getTitle()}"{/block}
{block header}
<a href="{$club->getURL()}">{$club->getCanonicalName()}</a>
»
<a href="/board{$club->getId()}">{_discussions}</a>
»
{_edit_topic}
{/block}
{block content}
<div class="container_gray">
<b>{$topic->getTitle()}</b>
<br />
<a href="{$topic->getOwner()->getURL()}">{$topic->getOwner()->getCanonicalName()}</a>
</div>
<form method="POST" enctype="multipart/form-data" style="margin-top: 20px;">
<table cellspacing="7" cellpadding="0" width="80%" border="0" align="center">
<tbody>
<tr>
<td width="120" valign="top">
<span class="nobold">{_title}</span>
</td>
<td>
<input type="text" name="title" style="width: 100%;" value="{$topic->getTitle()}" />
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_topic_settings}</span>
</td>
<td>
<input type="checkbox" name="pin" n:attr="checked => $topic->isPinned()" /> {_pin_topic}<br />
<input type="checkbox" name="close" n:attr="checked => $topic->isClosed()" /> {_close_topic}
</td>
</tr>
<tr>
<td>
<a class="button" href="/topic{$topic->getPrettyId()}/delete?hash={urlencode($csrfToken)}">{_delete_topic}</a>
</td>
<td>
<input type="hidden" name="hash" value="{$csrfToken}" />
<input type="submit" value="{_save}" class="button" />
</td>
</tr>
</tbody>
</table>
<input type="hidden" name="hash" value="{$csrfToken}" />
</form>
{/block}

View file

@ -0,0 +1,25 @@
{extends "../@layout.xml"}
{block title}{_view_topic} "{$topic->getTitle()}"{/block}
{block header}
<a href="{$club->getURL()}">{$club->getCanonicalName()}</a>
»
<a href="/board{$club->getId()}">{_discussions}</a>
»
{_view_topic}
<div style="float: right;" n:if="$topic->canBeModifiedBy($thisUser)">
<a href="/topic{$club->getId()}_{$topic->getVirtualId()}/edit">{_edit_topic_action}</a>
</div>
{/block}
{block content}
<div class="container_gray">
<b>{$topic->getTitle()}</b>
<br />
<a href="{$topic->getOwner()->getURL()}">{$topic->getOwner()->getCanonicalName()}</a>
</div>
<div style="margin-top: 20px;">
{include "../components/comments.xml", comments => $comments, count => $count, page => $page, model => "topics", club => $club, readOnly => $topic->isClosed(), parent => $topic}
</div>
{/block}

View file

@ -0,0 +1,6 @@
{extends "@default.xml"}
{var post = $notification->getModel(0)}
{block under}
{_nt_yours_adjective} <a href="/topic{$post->getPrettyId()}">{_nt_topic_instrumental}</a>
{/block}

View file

@ -19,6 +19,7 @@ services:
- openvk\Web\Presenters\AdminPresenter - openvk\Web\Presenters\AdminPresenter
- openvk\Web\Presenters\GiftsPresenter - openvk\Web\Presenters\GiftsPresenter
- openvk\Web\Presenters\MessengerPresenter - openvk\Web\Presenters\MessengerPresenter
- openvk\Web\Presenters\TopicsPresenter
- openvk\Web\Presenters\ThemepacksPresenter - openvk\Web\Presenters\ThemepacksPresenter
- openvk\Web\Presenters\VKAPIPresenter - openvk\Web\Presenters\VKAPIPresenter
- openvk\Web\Models\Repositories\Users - openvk\Web\Models\Repositories\Users
@ -36,4 +37,5 @@ services:
- openvk\Web\Models\Repositories\IPs - openvk\Web\Models\Repositories\IPs
- openvk\Web\Models\Repositories\Vouchers - openvk\Web\Models\Repositories\Vouchers
- openvk\Web\Models\Repositories\Gifts - openvk\Web\Models\Repositories\Gifts
- openvk\Web\Models\Repositories\Topics
- openvk\Web\Models\Repositories\ContentSearchRepository - openvk\Web\Models\Repositories\ContentSearchRepository

View file

@ -163,6 +163,16 @@ routes:
handler: "User->pinClub" handler: "User->pinClub"
- url: "/groups_create" - url: "/groups_create"
handler: "Group->create" handler: "Group->create"
- url: "/board{num}"
handler: "Topics->board"
- url: "/board{num}/create"
handler: "Topics->create"
- url: "/topic{num}_{num}"
handler: "Topics->topic"
- url: "/topic{num}_{num}/edit"
handler: "Topics->edit"
- url: "/topic{num}_{num}/delete"
handler: "Topics->delete"
- url: "/audios{num}" - url: "/audios{num}"
handler: "Audios->app" handler: "Audios->app"
- url: "/audios{num}.json" - url: "/audios{num}.json"

View file

@ -1673,3 +1673,12 @@ body.scrolled .toTop:hover {
color: #7b7b7b; color: #7b7b7b;
margin-top: 5px; margin-top: 5px;
} }
.pinned-mark {
display: inline-block;
height: 16px;
width: 16px;
overflow: auto;
background: url("/assets/packages/static/openvk/img/pin.png") no-repeat 0px 0px;
vertical-align: middle;
}

View file

@ -17,5 +17,6 @@
"openvk\\Web\\Models\\Entities\\TicketComment":17, "openvk\\Web\\Models\\Entities\\TicketComment":17,
"openvk\\Web\\Models\\Entities\\User":18, "openvk\\Web\\Models\\Entities\\User":18,
"openvk\\Web\\Models\\Entities\\Video":19, "openvk\\Web\\Models\\Entities\\Video":19,
"openvk\\Web\\Models\\Entities\\Gift":20 "openvk\\Web\\Models\\Entities\\Gift":20,
"openvk\\Web\\Models\\Entities\\Topic":21
} }

View file

@ -0,0 +1,22 @@
ALTER TABLE `groups` ADD COLUMN `everyone_can_create_topics` boolean NOT NULL AFTER `administrators_list_display`;
CREATE TABLE IF NOT EXISTS `topics` (
`id` bigint(20) unsigned NOT NULL,
`group` bigint(20) unsigned NOT NULL,
`owner` bigint(20) unsigned NOT NULL,
`virtual_id` bigint(20) unsigned NOT NULL,
`created` bigint(20) unsigned NOT NULL,
`edited` bigint(20) unsigned DEFAULT NULL,
`title` varchar(128) COLLATE utf8mb4_unicode_520_ci NOT NULL,
`closed` boolean NOT NULL DEFAULT FALSE,
`pinned` boolean NOT NULL DEFAULT FALSE,
`flags` tinyint(3) unsigned DEFAULT NULL,
`deleted` tinyint(1) DEFAULT 0,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
ALTER TABLE `topics`
PRIMARY KEY (`id`),
KEY `group` (`group`);
ALTER TABLE `topics`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;

View file

@ -484,6 +484,7 @@
"nt_post_instrumental" = "post"; "nt_post_instrumental" = "post";
"nt_note_instrumental" = "note"; "nt_note_instrumental" = "note";
"nt_photo_instrumental" = "photo"; "nt_photo_instrumental" = "photo";
"nt_topic_instrumental" = "topic";
/* Time */ /* Time */
@ -590,6 +591,39 @@
"banned_2" = "And the reason for this is simple: <b>$1</b>. Unfortunately, this time we had to block you forever."; "banned_2" = "And the reason for this is simple: <b>$1</b>. Unfortunately, this time we had to block you forever.";
"banned_3" = "You can still <a href=\"/support?act=new\">write to the support</a> if you think there was an error or <a href=\"/logout?hash=$1\">logout</a>."; "banned_3" = "You can still <a href=\"/support?act=new\">write to the support</a> if you think there was an error or <a href=\"/logout?hash=$1\">logout</a>.";
/* Discussions */
"discussions" = "Discussions";
"messages_one" = "One message";
"messages_other" = "$1 messages";
"replied" = "replied";
"create_topic" = "Create a topic";
"new_topic" = "New topic";
"title" = "Title";
"text" = "Text";
"view_topic" = "View topic";
"edit_topic_action" = "Edit topic";
"edit_topic" = "Edit topic";
"topic_settings" = "Topic settings";
"pin_topic" = "Pin topic";
"close_topic" = "Close topic";
"delete_topic" = "Delete topic";
"topics_one" = "One topic";
"topics_other" = "$1 topics";
"everyone_can_create_topics" = "Everyone can create topics";
"topic_changes_saved_comment" = "The updated title and settings will appear on the topic page.";
"failed_to_create_topic" = "Failed to create topic";
"failed_to_change_topic" = "Failed to change topic";
"no_title_specified" = "No title specified.";
/* Errors */ /* Errors */
"error_1" = "Incorrect query"; "error_1" = "Incorrect query";

View file

@ -504,6 +504,7 @@
"nt_post_instrumental" = "постом"; "nt_post_instrumental" = "постом";
"nt_note_instrumental" = "заметкой"; "nt_note_instrumental" = "заметкой";
"nt_photo_instrumental" = "фотографией"; "nt_photo_instrumental" = "фотографией";
"nt_topic_instrumental" = "темой";
/* Time */ /* Time */
@ -615,6 +616,43 @@
"banned_2" = "А причина этому проста: <b>$1</b>. К сожалению, на этот раз нам пришлось заблокировать вас навсегда."; "banned_2" = "А причина этому проста: <b>$1</b>. К сожалению, на этот раз нам пришлось заблокировать вас навсегда.";
"banned_3" = "Вы всё ещё можете <a href=\"/support?act=new\">написать в службу поддержки</a>, если считаете что произошла ошибка или <a href=\"/logout?hash=$1\">выйти</a>."; "banned_3" = "Вы всё ещё можете <a href=\"/support?act=new\">написать в службу поддержки</a>, если считаете что произошла ошибка или <a href=\"/logout?hash=$1\">выйти</a>.";
/* Discussions */
"discussions" = "Обсуждения";
"messages_one" = "Одно сообщение";
"messages_few" = "$1 сообщения";
"messages_many" = "$1 сообщений";
"messages_other" = "$1 сообщений";
"replied" = "ответил";
"create_topic" = "Создать тему";
"new_topic" = "Новая тема";
"title" = "Заголовок";
"text" = "Текст";
"view_topic" = "Просмотр темы";
"edit_topic_action" = "Редактировать тему";
"edit_topic" = "Редактирование темы";
"topic_settings" = "Настройки темы";
"pin_topic" = "Закрепить тему";
"close_topic" = "Закрыть тему";
"delete_topic" = "Удалить тему";
"topics_one" = "Одна тема";
"topics_few" = "$1 темы";
"topics_many" = "$1 тема";
"topics_other" = "$1 тем";
"everyone_can_create_topics" = "Все могут создавать темы";
"topic_changes_saved_comment" = "Обновлённый заголовок и настройки появятся на странице с темой.";
"failed_to_create_topic" = "Не удалось создать тему";
"failed_to_change_topic" = "Не удалось изменить тему";
"no_title_specified" = "Заголовок не указан.";
/* Errors */ /* Errors */
"error_1" = "Некорректный запрос"; "error_1" = "Некорректный запрос";