mirror of
https://github.com/openvk/openvk
synced 2024-12-23 00:51:03 +03:00
Add polls
This commit is contained in:
parent
8c314adf6c
commit
4ebeaa3f2f
23 changed files with 977 additions and 59 deletions
70
ServiceAPI/Polls.php
Normal file
70
ServiceAPI/Polls.php
Normal file
|
@ -0,0 +1,70 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\ServiceAPI;
|
||||
use Chandler\MVC\Routing\Router;
|
||||
use openvk\Web\Models\Entities\User;
|
||||
use openvk\Web\Models\Exceptions\{AlreadyVotedException, InvalidOptionException, PollLockedException};
|
||||
use openvk\Web\Models\Repositories\Polls as PollRepo;
|
||||
use UnexpectedValueException;
|
||||
|
||||
class Polls implements Handler
|
||||
{
|
||||
protected $user;
|
||||
protected $polls;
|
||||
|
||||
function __construct(?User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->polls = new PollRepo;
|
||||
}
|
||||
|
||||
private function getPollHtml(int $poll): string
|
||||
{
|
||||
return Router::i()->execute("/poll$poll", "SAPI");
|
||||
}
|
||||
|
||||
function vote(int $pollId, string $options, callable $resolve, callable $reject): void
|
||||
{
|
||||
$poll = $this->polls->get($pollId);
|
||||
if(!$poll) {
|
||||
$reject("Poll not found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$options = explode(",", $options);
|
||||
$poll->vote($this->user, $options);
|
||||
} catch(AlreadyVotedException $ex) {
|
||||
$reject("Poll state changed: user has already voted.");
|
||||
return;
|
||||
} catch(PollLockedException $ex) {
|
||||
$reject("Poll state changed: poll has ended.");
|
||||
return;
|
||||
} catch(InvalidOptionException $ex) {
|
||||
$reject("Foreign options passed.");
|
||||
return;
|
||||
} catch(UnexpectedValueException $ex) {
|
||||
$reject("Too much options passed.");
|
||||
return;
|
||||
}
|
||||
|
||||
$resolve(["html" => $this->getPollHtml($pollId)]);
|
||||
}
|
||||
|
||||
function unvote(int $pollId, callable $resolve, callable $reject): void
|
||||
{
|
||||
$poll = $this->polls->get($pollId);
|
||||
if(!$poll) {
|
||||
$reject("Poll not found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$poll->revokeVote($this->user);
|
||||
} catch(PollLockedException $ex) {
|
||||
$reject("Votes can't be revoked from this poll.");
|
||||
return;
|
||||
}
|
||||
|
||||
$resolve(["html" => $this->getPollHtml($pollId)]);
|
||||
}
|
||||
}
|
295
Web/Models/Entities/Poll.php
Normal file
295
Web/Models/Entities/Poll.php
Normal file
|
@ -0,0 +1,295 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Models\Entities;
|
||||
use openvk\Web\Models\Exceptions\TooMuchOptionsException;
|
||||
use openvk\Web\Util\DateTime;
|
||||
use \UnexpectedValueException;
|
||||
use Nette\InvalidStateException;
|
||||
use openvk\Web\Models\Repositories\Users;
|
||||
use Chandler\Database\DatabaseConnection;
|
||||
use openvk\Web\Models\Exceptions\PollLockedException;
|
||||
use openvk\Web\Models\Exceptions\AlreadyVotedException;
|
||||
use openvk\Web\Models\Exceptions\InvalidOptionException;
|
||||
|
||||
class Poll extends Attachable
|
||||
{
|
||||
protected $tableName = "polls";
|
||||
|
||||
private $choicesToPersist = [];
|
||||
|
||||
function getTitle(): string
|
||||
{
|
||||
return $this->getRecord()->title;
|
||||
}
|
||||
|
||||
function getMetaDescription(): string
|
||||
{
|
||||
$props = [];
|
||||
$props[] = tr($this->isAnonymous() ? "poll_anon" : "poll_public");
|
||||
if($this->isMultipleChoice()) $props[] = tr("poll_multi");
|
||||
if(!$this->isRevotable()) $props[] = tr("poll_lock");
|
||||
if(!is_null($this->endsAt())) $props[] = tr("poll_until", $this->endsAt());
|
||||
|
||||
return implode(" • ", $props);
|
||||
}
|
||||
|
||||
function getOwner(): User
|
||||
{
|
||||
return (new Users)->get($this->getRecord()->owner);
|
||||
}
|
||||
|
||||
function getOptions(): array
|
||||
{
|
||||
$options = $this->getRecord()->related("poll_options.poll");
|
||||
$res = [];
|
||||
foreach($options as $opt)
|
||||
$res[$opt->id] = $opt->name;
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
function getUserVote(User $user): ?array
|
||||
{
|
||||
$ctx = DatabaseConnection::i()->getContext();
|
||||
$votedOpts = $ctx->table("poll_votes")
|
||||
->where(["user" => $user->getId(), "poll" => $this->getId()]);
|
||||
|
||||
if($votedOpts->count() == 0)
|
||||
return NULL;
|
||||
|
||||
$res = [];
|
||||
foreach($votedOpts as $votedOpt) {
|
||||
$option = $ctx->table("poll_options")->get($votedOpt->option);
|
||||
$res[] = [$option->id, $option->name];
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
function getVoters(int $optionId, int $page = 1, ?int $perPage = NULL): array
|
||||
{
|
||||
$res = [];
|
||||
$ctx = DatabaseConnection::i()->getContext();
|
||||
$perPage = $perPage ?? OPENVK_DEFAULT_PER_PAGE;
|
||||
$voters = $ctx->table("poll_votes")->where(["poll" => $this->getId(), "option" => $optionId]);
|
||||
foreach($voters->page($page, $perPage) as $vote)
|
||||
$res[] = (new Users)->get($vote->user);
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
function getVoterCount(?int $optionId = NULL): int
|
||||
{
|
||||
$votes = DatabaseConnection::i()->getContext()->table("poll_votes");
|
||||
if(!$optionId)
|
||||
return $votes->select("COUNT(DISTINCT user) AS c")->where("poll", $this->getId())->fetch()->c;
|
||||
|
||||
return $votes->where(["poll" => $this->getId(), "option" => $optionId])->count();
|
||||
}
|
||||
|
||||
function getResults(?User $user = NULL): object
|
||||
{
|
||||
$ctx = DatabaseConnection::i()->getContext();
|
||||
$voted = NULL;
|
||||
if(!is_null($user))
|
||||
$voted = $this->getUserVote($user);
|
||||
|
||||
$result = (object) [];
|
||||
$result->totalVotes = $this->getVoterCount();
|
||||
|
||||
$unsOptions = [];
|
||||
foreach($this->getOptions() as $id => $title) {
|
||||
$option = (object) [];
|
||||
$option->id = $id;
|
||||
$option->name = $title;
|
||||
|
||||
$option->votes = $this->getVoterCount($id);
|
||||
$option->pct = $result->totalVotes == 0 ? 0 : (($option->votes / $result->totalVotes) * 100);
|
||||
$option->voters = $this->getVoters($id, 1, 10);
|
||||
if(!$user || !$voted)
|
||||
$option->voted = NULL;
|
||||
else
|
||||
$option->voted = in_array([$id, $title], $voted);
|
||||
|
||||
$unsOptions[$id] = $option;
|
||||
}
|
||||
|
||||
$optionsC = sizeof($unsOptions);
|
||||
$sOptions = $unsOptions;
|
||||
usort($sOptions, function($a, $b) { return $a->votes <=> $b->votes; });
|
||||
for($i = 0; $i < $optionsC; $i++)
|
||||
$unsOptions[$id]->rate = $optionsC - $i - 1;
|
||||
|
||||
$result->options = array_values($unsOptions);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function isAnonymous(): bool
|
||||
{
|
||||
return (bool) $this->getRecord()->is_anonymous;
|
||||
}
|
||||
|
||||
function isMultipleChoice(): bool
|
||||
{
|
||||
return (bool) $this->getRecord()->allows_multiple;
|
||||
}
|
||||
|
||||
function isRevotable(): bool
|
||||
{
|
||||
return (bool) $this->getRecord()->can_revote;
|
||||
}
|
||||
|
||||
function endsAt(): ?DateTime
|
||||
{
|
||||
if(!$this->getRecord()->until)
|
||||
return NULL;
|
||||
|
||||
return new DateTime($this->getRecord()->until);
|
||||
}
|
||||
|
||||
function hasEnded(): bool
|
||||
{
|
||||
if($this->getRecord()->ended)
|
||||
return true;
|
||||
|
||||
if(!is_null($this->getRecord()->until))
|
||||
return time() >= $this->getRecord()->until;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasVoted(User $user): bool
|
||||
{
|
||||
return !is_null($this->getUserVote($user));
|
||||
}
|
||||
|
||||
function canVote(User $user): bool
|
||||
{
|
||||
return !$this->hasEnded() && !$this->hasVoted($user);
|
||||
}
|
||||
|
||||
function vote(User $user, array $optionIds): void
|
||||
{
|
||||
if($this->hasEnded())
|
||||
throw new PollLockedException;
|
||||
|
||||
if($this->hasVoted($user))
|
||||
throw new AlreadyVotedException;
|
||||
|
||||
$optionIds = array_map(function($x) { return (int) $x; }, array_unique($optionIds));
|
||||
$validOpts = array_keys($this->getOptions());
|
||||
if(empty($optionIds) || (sizeof($optionIds) > 1 && !$this->isMultipleChoice()))
|
||||
throw new UnexpectedValueException;
|
||||
|
||||
if(sizeof(array_diff($optionIds, $validOpts)) > 0)
|
||||
throw new InvalidOptionException;
|
||||
|
||||
foreach($optionIds as $opt) {
|
||||
DatabaseConnection::i()->getContext()->table("poll_votes")->insert([
|
||||
"user" => $user->getId(),
|
||||
"poll" => $this->getId(),
|
||||
"option" => $opt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function revokeVote(User $user): void
|
||||
{
|
||||
if(!$this->isRevotable())
|
||||
throw new PollLockedException;
|
||||
|
||||
$this->getRecord()->related("poll_votes.poll")
|
||||
->where("user", $user->getId())->delete();
|
||||
}
|
||||
|
||||
function setOwner(User $owner): void
|
||||
{
|
||||
$this->stateChanges("owner", $owner->getId());
|
||||
}
|
||||
|
||||
function setEndDate(int $timestamp): void
|
||||
{
|
||||
if(!is_null($this->getRecord()))
|
||||
throw new PollLockedException;
|
||||
|
||||
$this->stateChanges("until", $timestamp);
|
||||
}
|
||||
|
||||
function setEnded(): void
|
||||
{
|
||||
$this->stateChanges("ended", 1);
|
||||
}
|
||||
|
||||
function setOptions(array $options): void
|
||||
{
|
||||
if(!is_null($this->getRecord()))
|
||||
throw new PollLockedException;
|
||||
|
||||
if(sizeof($options) > ovkGetQuirk("polls.max-opts"))
|
||||
throw new TooMuchOptionsException;
|
||||
|
||||
$this->choicesToPersist = $options;
|
||||
}
|
||||
|
||||
function setRevotability(bool $canReVote): void
|
||||
{
|
||||
if(!is_null($this->getRecord()))
|
||||
throw new PollLockedException;
|
||||
|
||||
$this->stateChanges("can_revote", true);
|
||||
}
|
||||
|
||||
function setAnonymity(bool $anonymous): void
|
||||
{
|
||||
$this->stateChanges("is_anonymous", $anonymous);
|
||||
}
|
||||
|
||||
function setMultipleChoice(bool $mc): void
|
||||
{
|
||||
$this->stateChanges("allows_multiple", $mc);
|
||||
}
|
||||
|
||||
function importXML(User $owner, string $xml): void
|
||||
{
|
||||
$xml = simplexml_load_string($xml);
|
||||
$this->setOwner($owner);
|
||||
$this->setTitle($xml["title"] ?? "Untitled");
|
||||
$this->setMultipleChoice(($xml["multiple"] ?? "no") == "yes");
|
||||
$this->setAnonymity(($xml["anonymous"] ?? "no") == "yes");
|
||||
$this->setRevotability(($xml["locked"] ?? "no") == "yes");
|
||||
if(ctype_digit($xml["duration"] ?? ""))
|
||||
$this->setEndDate(time() + ((86400 * (int) $xml["duration"])));
|
||||
|
||||
$options = [];
|
||||
foreach($xml->options->option as $opt)
|
||||
$options[] = (string) $opt;
|
||||
|
||||
if(empty($options))
|
||||
throw new UnexpectedValueException;
|
||||
|
||||
$this->setOptions($options);
|
||||
}
|
||||
|
||||
static function import(User $owner, string $xml): Poll
|
||||
{
|
||||
$poll = new Poll;
|
||||
$poll->importXML($owner, $xml);
|
||||
$poll->save();
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
function save(): void
|
||||
{
|
||||
if(empty($this->choicesToPersist))
|
||||
throw new InvalidStateException;
|
||||
|
||||
parent::save();
|
||||
foreach($this->choicesToPersist as $option) {
|
||||
DatabaseConnection::i()->getContext()->table("poll_options")->insert([
|
||||
"poll" => $this->getId(),
|
||||
"name" => $option,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
7
Web/Models/Exceptions/AlreadyVotedException.php
Normal file
7
Web/Models/Exceptions/AlreadyVotedException.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Models\Exceptions;
|
||||
|
||||
final class AlreadyVotedException extends \RuntimeException
|
||||
{
|
||||
|
||||
}
|
7
Web/Models/Exceptions/InvalidOptionException.php
Normal file
7
Web/Models/Exceptions/InvalidOptionException.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Models\Exceptions;
|
||||
|
||||
final class InvalidOptionException extends \UnexpectedValueException
|
||||
{
|
||||
|
||||
}
|
8
Web/Models/Exceptions/PollLockedException.php
Normal file
8
Web/Models/Exceptions/PollLockedException.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Models\Exceptions;
|
||||
use Nette\InvalidStateException;
|
||||
|
||||
final class PollLockedException extends InvalidStateException
|
||||
{
|
||||
|
||||
}
|
7
Web/Models/Exceptions/TooMuchOptionsException.php
Normal file
7
Web/Models/Exceptions/TooMuchOptionsException.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Models\Exceptions;
|
||||
|
||||
final class TooMuchOptionsException extends \UnexpectedValueException
|
||||
{
|
||||
|
||||
}
|
23
Web/Models/Repositories/Polls.php
Normal file
23
Web/Models/Repositories/Polls.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Models\Repositories;
|
||||
use Chandler\Database\DatabaseConnection;
|
||||
use openvk\Web\Models\Entities\Poll;
|
||||
|
||||
class Polls
|
||||
{
|
||||
private $polls;
|
||||
|
||||
function __construct()
|
||||
{
|
||||
$this->polls = DatabaseConnection::i()->getContext()->table("polls");
|
||||
}
|
||||
|
||||
function get(int $id): ?Poll
|
||||
{
|
||||
$poll = $this->polls->get($id);
|
||||
if(!$poll)
|
||||
return NULL;
|
||||
|
||||
return new Poll($poll);
|
||||
}
|
||||
}
|
70
Web/Presenters/PollPresenter.php
Normal file
70
Web/Presenters/PollPresenter.php
Normal file
|
@ -0,0 +1,70 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Presenters;
|
||||
use openvk\Web\Models\Entities\Poll;
|
||||
use openvk\Web\Models\Repositories\Polls;
|
||||
|
||||
final class PollPresenter extends OpenVKPresenter
|
||||
{
|
||||
private $polls;
|
||||
|
||||
function __construct(Polls $polls)
|
||||
{
|
||||
$this->polls = $polls;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
function renderView(int $id): void
|
||||
{
|
||||
$poll = $this->polls->get($id);
|
||||
if(!$poll)
|
||||
$this->notFound();
|
||||
|
||||
$this->template->id = $poll->getId();
|
||||
$this->template->title = $poll->getTitle();
|
||||
$this->template->isAnon = $poll->isAnonymous();
|
||||
$this->template->multiple = $poll->isMultipleChoice();
|
||||
$this->template->unlocked = $poll->isRevotable();
|
||||
$this->template->until = $poll->endsAt();
|
||||
$this->template->votes = $poll->getVoterCount();
|
||||
$this->template->meta = $poll->getMetaDescription();
|
||||
if(is_null($this->user) || $poll->canVote($this->user->identity)) {
|
||||
$this->template->options = $poll->getOptions();
|
||||
|
||||
$this->template->_template = "Poll/Poll.xml";
|
||||
return;
|
||||
}
|
||||
|
||||
$this->template->voted = $poll->hasVoted($this->user->identity);
|
||||
$this->template->ended = $poll->hasEnded();
|
||||
$this->template->results = $poll->getResults($this->user->identity);
|
||||
|
||||
$this->template->_template = "Poll/PollResults.xml";
|
||||
}
|
||||
|
||||
function renderVoters(int $pollId): void
|
||||
{
|
||||
$poll = $this->polls->get($pollId);
|
||||
if(!$poll)
|
||||
$this->notFound();
|
||||
|
||||
if($poll->isAnonymous())
|
||||
$this->flashFail("err", tr("forbidden"), tr("poll_err_anonymous"));
|
||||
|
||||
$options = $poll->getOptions();
|
||||
$option = (int) base_convert($this->queryParam("option"), 32, 10);
|
||||
if(!in_array($option, array_keys($options)))
|
||||
$this->notFound();
|
||||
|
||||
$page = (int) ($this->queryParam("p") ?? 1);
|
||||
$voters = $poll->getVoters($option, $page);
|
||||
|
||||
$this->template->pollId = $pollId;
|
||||
$this->template->options = $options;
|
||||
$this->template->option = [$option, $options[$option]];
|
||||
$this->template->tabs = $options;
|
||||
$this->template->iterator = $voters;
|
||||
$this->template->count = $poll->getVoterCount($option);
|
||||
$this->template->page = $page;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Presenters;
|
||||
use openvk\Web\Models\Entities\{Post, Photo, Video, Club, User};
|
||||
use openvk\Web\Models\Exceptions\TooMuchOptionsException;
|
||||
use openvk\Web\Models\Entities\{Poll, Post, Photo, Video, Club, User};
|
||||
use openvk\Web\Models\Entities\Notifications\{RepostNotification, WallPostNotification};
|
||||
use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums};
|
||||
use Chandler\Database\DatabaseConnection;
|
||||
|
@ -259,16 +260,26 @@ final class WallPresenter extends OpenVKPresenter
|
|||
$photo = Photo::fastMake($this->user->id, $this->postParam("text"), $_FILES["_pic_attachment"], $album, $anon);
|
||||
}
|
||||
|
||||
if($_FILES["_vid_attachment"]["error"] === UPLOAD_ERR_OK) {
|
||||
if($_FILES["_vid_attachment"]["error"] === UPLOAD_ERR_OK)
|
||||
$video = Video::fastMake($this->user->id, $this->postParam("text"), $_FILES["_vid_attachment"], $anon);
|
||||
}
|
||||
} catch(\DomainException $ex) {
|
||||
$this->flashFail("err", tr("failed_to_publish_post"), tr("media_file_corrupted"));
|
||||
} catch(ISE $ex) {
|
||||
$this->flashFail("err", tr("failed_to_publish_post"), tr("media_file_corrupted_or_too_large"));
|
||||
}
|
||||
|
||||
if(empty($this->postParam("text")) && !$photo && !$video)
|
||||
try {
|
||||
$poll = NULL;
|
||||
$xml = $this->postParam("poll");
|
||||
if (!is_null($xml) && $xml != "none")
|
||||
$poll = Poll::import($this->user->identity, $xml);
|
||||
} catch(TooMuchOptionsException $e) {
|
||||
$this->flashFail("err", tr("failed_to_publish_post"), tr("poll_err_to_much_options"));
|
||||
} catch(\UnexpectedValueException $e) {
|
||||
$this->flashFail("err", tr("failed_to_publish_post"), "Poll format invalid");
|
||||
}
|
||||
|
||||
if(empty($this->postParam("text")) && !$photo && !$video && !$poll)
|
||||
$this->flashFail("err", tr("failed_to_publish_post"), tr("post_is_empty_or_too_big"));
|
||||
|
||||
try {
|
||||
|
@ -291,6 +302,9 @@ final class WallPresenter extends OpenVKPresenter
|
|||
if(!is_null($video))
|
||||
$post->attach($video);
|
||||
|
||||
if(!is_null($poll))
|
||||
$post->attach($poll);
|
||||
|
||||
if($wall > 0 && $wall !== $this->user->identity->getId())
|
||||
(new WallPostNotification($wallOwner, $post, $this->user->identity))->emit();
|
||||
|
||||
|
|
|
@ -29,58 +29,7 @@
|
|||
{script "js/timezone.js"}
|
||||
{/if}
|
||||
|
||||
{ifset $thisUser}
|
||||
{if $thisUser->getNsfwTolerance() < 2}
|
||||
{css "css/nsfw-posts.css"}
|
||||
{/if}
|
||||
|
||||
{if $theme !== NULL}
|
||||
{if $theme->inheritDefault()}
|
||||
{css "css/style.css"}
|
||||
{css "css/dialog.css"}
|
||||
{css "css/notifications.css"}
|
||||
|
||||
{if $isXmas}
|
||||
{css "css/xmas.css"}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<link rel="stylesheet" href="/themepack/{$theme->getId()}/{$theme->getVersion()}/stylesheet/styles.css" />
|
||||
|
||||
{if $isXmas}
|
||||
<link rel="stylesheet" href="/themepack/{$theme->getId()}/{$theme->getVersion()}/resource/xmas.css" />
|
||||
{/if}
|
||||
{else}
|
||||
{css "css/style.css"}
|
||||
{css "css/dialog.css"}
|
||||
{css "css/notifications.css"}
|
||||
|
||||
{if $isXmas}
|
||||
{css "css/xmas.css"}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{if $thisUser->getStyleAvatar() == 1}
|
||||
{css "css/avatar.1.css"}
|
||||
{/if}
|
||||
|
||||
{if $thisUser->getStyleAvatar() == 2}
|
||||
{css "css/avatar.2.css"}
|
||||
{/if}
|
||||
|
||||
{if $thisUser->hasMicroblogEnabled() == 1}
|
||||
{css "css/microblog.css"}
|
||||
{/if}
|
||||
{else}
|
||||
{css "css/style.css"}
|
||||
{css "css/dialog.css"}
|
||||
{css "css/nsfw-posts.css"}
|
||||
{css "css/notifications.css"}
|
||||
|
||||
{if $isXmas}
|
||||
{css "css/xmas.css"}
|
||||
{/if}
|
||||
{/ifset}
|
||||
{include "_includeCSS.xml"}
|
||||
|
||||
{ifset headIncludes}
|
||||
{include headIncludes}
|
||||
|
@ -334,6 +283,7 @@
|
|||
{script "js/al_wall.js"}
|
||||
{script "js/al_api.js"}
|
||||
{script "js/al_mentions.js"}
|
||||
{script "js/al_polls.js"}
|
||||
|
||||
{ifset $thisUser}
|
||||
{script "js/al_notifs.js"}
|
||||
|
|
44
Web/Presenters/templates/Poll/Poll.xml
Normal file
44
Web/Presenters/templates/Poll/Poll.xml
Normal file
|
@ -0,0 +1,44 @@
|
|||
{if !isset($parentModule) || substr($parentModule, 0, 21) === 'libchandler:absolute.'}
|
||||
<link rel="shortcut icon" href="/assets/packages/static/openvk/img/icon.ico" />
|
||||
<meta n:ifset="$csrfToken" name="csrf" value="{$csrfToken}" />
|
||||
<script src="/language/{getLanguage()}.js" crossorigin="anonymous"></script>
|
||||
{script "js/node_modules/jquery/dist/jquery.min.js"}
|
||||
{script "js/node_modules/umbrellajs/umbrella.min.js"}
|
||||
{script "js/node_modules/msgpack-lite/dist/msgpack.min.js"}
|
||||
{script "js/messagebox.js"}
|
||||
{script "js/l10n.js"}
|
||||
{script "js/al_api.js"}
|
||||
{script "js/al_polls.js"}
|
||||
{include "../_includeCSS.xml"}
|
||||
|
||||
<style>body { margin: 8px; }</style>
|
||||
{/if}
|
||||
|
||||
<div class="poll">
|
||||
<h4>{$title}</h4>
|
||||
<form onsubmit="pollFormSubmit(event, this)" data-multi="{$multiple ? '1' : '0'}" data-pid="{$id}">
|
||||
<div class="poll-options">
|
||||
<div n:foreach="$options as $oid => $option" class="poll-option">
|
||||
<label>
|
||||
{if $multiple}
|
||||
<input n:attr="disabled: is_null($thisUser)" type="checkbox" name="option{$oid}" onclick="pollCheckBoxPressed(this)" />
|
||||
{else}
|
||||
<input n:attr="disabled: is_null($thisUser)" type="radio" value="{$oid}" name="vote" onclick="pollRadioPressed(this)" />
|
||||
{/if}
|
||||
|
||||
{$option}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if $multiple}
|
||||
<br/>
|
||||
<input type="submit" class="button" value="{_cast_vote}" disabled="disabled" />
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<div class="poll-meta">
|
||||
{tr("poll_voter_count", $votes)|noescape}<br/>
|
||||
<span class="nobold">{$meta}</span>
|
||||
</div>
|
||||
</div>
|
44
Web/Presenters/templates/Poll/PollResults.xml
Normal file
44
Web/Presenters/templates/Poll/PollResults.xml
Normal file
|
@ -0,0 +1,44 @@
|
|||
{if !isset($parentModule) || substr($parentModule, 0, 21) === 'libchandler:absolute.'}
|
||||
<link rel="shortcut icon" href="/assets/packages/static/openvk/img/icon.ico" />
|
||||
<meta n:ifset="$csrfToken" name="csrf" value="{$csrfToken}" />
|
||||
<script src="/language/{getLanguage()}.js" crossorigin="anonymous"></script>
|
||||
{script "js/node_modules/jquery/dist/jquery.min.js"}
|
||||
{script "js/node_modules/umbrellajs/umbrella.min.js"}
|
||||
{script "js/node_modules/msgpack-lite/dist/msgpack.min.js"}
|
||||
{script "js/messagebox.js"}
|
||||
{script "js/l10n.js"}
|
||||
{script "js/al_api.js"}
|
||||
{script "js/al_polls.js"}
|
||||
{include "../_includeCSS.xml"}
|
||||
|
||||
<style>body { margin: 8px; } .poll { border: 1px solid #e3e3e3; }</style>
|
||||
{/if}
|
||||
|
||||
<div class="poll" data-id="{$id}">
|
||||
<a n:if="$unlocked && $voted" href="javascript:pollRetractVote({$id})" class="poll-retract-vote">{_retract_vote}</a>
|
||||
<h4>{$title}</h4>
|
||||
<div class="poll-results">
|
||||
<div n:foreach="$results->options as $option" class="poll-result">
|
||||
{if $isAnon}
|
||||
<a href="javascript:false">{$option->name}</a>
|
||||
{else}
|
||||
<a href="/poll{$id}/voters?option={base_convert($option->id, 10, 32)}">{$option->name}</a>
|
||||
{/if}
|
||||
|
||||
<div class="poll-result-barspace">
|
||||
<div class="poll-result-bar">
|
||||
<span class="poll-result-count">{$option->votes}</span>
|
||||
<div class="poll-result-bar-sub" style="width: {$option->pct}%"> </div>
|
||||
</div>
|
||||
<div class="poll-result-pct">
|
||||
<strong>{$option->pct}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="poll-meta">
|
||||
{tr("poll_voter_count", $votes)|noescape}<br/>
|
||||
<span class="nobold">{$meta}</span>
|
||||
</div>
|
||||
</div>
|
40
Web/Presenters/templates/Poll/Voters.xml
Normal file
40
Web/Presenters/templates/Poll/Voters.xml
Normal file
|
@ -0,0 +1,40 @@
|
|||
{extends "../@listView.xml"}
|
||||
|
||||
{block title}
|
||||
{_poll_voters_list}
|
||||
{/block}
|
||||
|
||||
{block header}
|
||||
{_poll_voters_list} »
|
||||
{$option[1]}
|
||||
{/block}
|
||||
|
||||
{block tabs}
|
||||
<div n:foreach="$options as $optionId => $optionName" class="tab" id="{$optionId == $option[0] ? 'activetabs' : 'ki'}">
|
||||
<a id="{$optionId == $option[0] ? 'act_tab_a' : ''}" href="/poll{$pollId}/voters?option={$optionId}">{$optionName}</a>
|
||||
</div>
|
||||
{/block}
|
||||
|
||||
{* BEGIN ELEMENTS DESCRIPTION *}
|
||||
|
||||
{block link|strip|stripHtml}
|
||||
{$x->getURL()}
|
||||
{/block}
|
||||
|
||||
{block preview}
|
||||
<img src="{$x->getAvatarUrl('miniscule')}" width="75" alt="Фотография пользователя" />
|
||||
{/block}
|
||||
|
||||
{block name}
|
||||
{$x->getCanonicalName()}
|
||||
<img n:if="$x->isVerified()"
|
||||
class="name-checkmark"
|
||||
src="/assets/packages/static/openvk/img/checkmark.png"
|
||||
/>
|
||||
{/block}
|
||||
|
||||
{block description}
|
||||
{/block}
|
||||
|
||||
{block actions}
|
||||
{/block}
|
52
Web/Presenters/templates/_includeCSS.xml
Normal file
52
Web/Presenters/templates/_includeCSS.xml
Normal file
|
@ -0,0 +1,52 @@
|
|||
{ifset $thisUser}
|
||||
{if $thisUser->getNsfwTolerance() < 2}
|
||||
{css "css/nsfw-posts.css"}
|
||||
{/if}
|
||||
|
||||
{if $theme !== NULL}
|
||||
{if $theme->inheritDefault()}
|
||||
{css "css/style.css"}
|
||||
{css "css/dialog.css"}
|
||||
{css "css/notifications.css"}
|
||||
|
||||
{if $isXmas}
|
||||
{css "css/xmas.css"}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<link rel="stylesheet" href="/themepack/{$theme->getId()}/{$theme->getVersion()}/stylesheet/styles.css" />
|
||||
|
||||
{if $isXmas}
|
||||
<link rel="stylesheet" href="/themepack/{$theme->getId()}/{$theme->getVersion()}/resource/xmas.css" />
|
||||
{/if}
|
||||
{else}
|
||||
{css "css/style.css"}
|
||||
{css "css/dialog.css"}
|
||||
{css "css/notifications.css"}
|
||||
|
||||
{if $isXmas}
|
||||
{css "css/xmas.css"}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{if $thisUser->getStyleAvatar() == 1}
|
||||
{css "css/avatar.1.css"}
|
||||
{/if}
|
||||
|
||||
{if $thisUser->getStyleAvatar() == 2}
|
||||
{css "css/avatar.2.css"}
|
||||
{/if}
|
||||
|
||||
{if $thisUser->hasMicroblogEnabled() == 1}
|
||||
{css "css/microblog.css"}
|
||||
{/if}
|
||||
{else}
|
||||
{css "css/style.css"}
|
||||
{css "css/dialog.css"}
|
||||
{css "css/nsfw-posts.css"}
|
||||
{css "css/notifications.css"}
|
||||
|
||||
{if $isXmas}
|
||||
{css "css/xmas.css"}
|
||||
{/if}
|
||||
{/ifset}
|
|
@ -11,6 +11,8 @@
|
|||
{/if}
|
||||
{elseif $attachment instanceof \openvk\Web\Models\Entities\Video}
|
||||
<video class="media" src="{$attachment->getURL()}" controls="controls"></video>
|
||||
{elseif $attachment instanceof \openvk\Web\Models\Entities\Poll}
|
||||
{presenter "openvk!Poll->view", $attachment->getId()}
|
||||
{elseif $attachment instanceof \openvk\Web\Models\Entities\Post}
|
||||
{php $GLOBALS["_nesAttGloCou"] = (isset($GLOBALS["_nesAttGloCou"]) ? $GLOBALS["_nesAttGloCou"] : 0) + 1}
|
||||
{if $GLOBALS["_nesAttGloCou"] > 2}
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
<div class="post-upload">
|
||||
{_attachment}: <span>(unknown)</span>
|
||||
</div>
|
||||
<div class="post-has-poll">
|
||||
{_poll}
|
||||
</div>
|
||||
<div n:if="$postOpts ?? true" class="post-opts">
|
||||
{var $anonEnabled = OPENVK_ROOT_CONF['openvk']['preferences']['wall']['anonymousPosting']['enable']}
|
||||
|
||||
|
@ -50,6 +53,7 @@
|
|||
</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;" />
|
||||
<input type="hidden" name="poll" value="none" />
|
||||
<input type="hidden" name="type" value="1" />
|
||||
<input type="hidden" name="hash" value="{$csrfToken}" />
|
||||
<br/>
|
||||
|
@ -75,6 +79,10 @@
|
|||
<img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/actions/draw-brush.png" />
|
||||
{_graffiti}
|
||||
</a>
|
||||
<a n:if="$polls ?? false" href="javascript:initPoll({$textAreaId})">
|
||||
<img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/actions/office-chart-bar-stacked.png" />
|
||||
{_poll}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</div>
|
||||
<div>
|
||||
<div n:if="$canPost" class="content_subtitle">
|
||||
{include "../components/textArea.xml", route => "/wall$owner/makePost", graffiti => true}
|
||||
{include "../components/textArea.xml", route => "/wall$owner/makePost", graffiti => true, polls => true}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
|
|
|
@ -23,9 +23,11 @@ services:
|
|||
- openvk\Web\Presenters\AppsPresenter
|
||||
- openvk\Web\Presenters\ThemepacksPresenter
|
||||
- openvk\Web\Presenters\VKAPIPresenter
|
||||
- openvk\Web\Presenters\PollPresenter
|
||||
- openvk\Web\Presenters\BannedLinkPresenter
|
||||
- openvk\Web\Models\Repositories\Users
|
||||
- openvk\Web\Models\Repositories\Posts
|
||||
- openvk\Web\Models\Repositories\Polls
|
||||
- openvk\Web\Models\Repositories\Photos
|
||||
- openvk\Web\Models\Repositories\Albums
|
||||
- openvk\Web\Models\Repositories\Clubs
|
||||
|
|
|
@ -273,6 +273,10 @@ routes:
|
|||
handler: "Apps->edit"
|
||||
- url: "/apps/uninstall"
|
||||
handler: "Apps->unInstall"
|
||||
- url: "/poll{num}"
|
||||
handler: "Poll->view"
|
||||
- url: "/poll{num}/voters"
|
||||
handler: "Poll->voters"
|
||||
- url: "/admin"
|
||||
handler: "Admin->index"
|
||||
- url: "/admin/users"
|
||||
|
|
|
@ -448,6 +448,15 @@ table {
|
|||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.button[disabled] {
|
||||
filter: opacity(0.5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button[disabled]:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-loading {
|
||||
display: inline-block;
|
||||
background-image: url('/assets/packages/static/openvk/img/loading_mini.gif');
|
||||
|
@ -801,6 +810,8 @@ table.User {
|
|||
padding: 0 10px;
|
||||
margin-left: -10px;
|
||||
width: 607px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs.stupid-fix {
|
||||
|
@ -1338,14 +1349,14 @@ body.scrolled .toTop:hover {
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.post-upload {
|
||||
.post-upload, .post-has-poll {
|
||||
margin-top: 11px;
|
||||
margin-left: 3px;
|
||||
color: #3c3c3c;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.post-upload::before {
|
||||
.post-upload::before, .post-has-poll::before {
|
||||
content: " ";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
@ -2051,6 +2062,81 @@ table td[width="120"] {
|
|||
max-height: 250px;
|
||||
}
|
||||
|
||||
.poll {
|
||||
padding: 8px;
|
||||
transition: .1s filter ease-in;
|
||||
border: 1px solid #e3e3e3;
|
||||
}
|
||||
|
||||
.poll.loading {
|
||||
filter: opacity(0.5);
|
||||
cursor: progress;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.poll.loading * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.poll-embed {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.poll h4 {
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.poll-meta .nobold {
|
||||
font-style: oblique;
|
||||
}
|
||||
|
||||
.poll-result-barspace {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.poll-result {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.poll-result a {
|
||||
color: unset;
|
||||
}
|
||||
|
||||
.poll-result-bar {
|
||||
position: relative;
|
||||
margin: 5px 0;
|
||||
background-color: #f7f7f7;
|
||||
height: 13pt;
|
||||
flex: 14;
|
||||
}
|
||||
|
||||
span.poll-result-count {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-right: -50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #7d96af;
|
||||
}
|
||||
|
||||
.poll-result-bar-sub {
|
||||
height: 100%;
|
||||
background-color: #d9e1ea;
|
||||
}
|
||||
|
||||
.poll-result-pct {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
a.poll-retract-vote {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="vk"] {
|
||||
user-select: none;
|
||||
background-color: #fff;
|
||||
|
|
151
Web/static/js/al_polls.js
Normal file
151
Web/static/js/al_polls.js
Normal file
|
@ -0,0 +1,151 @@
|
|||
async function pollRetractVote(id) {
|
||||
let poll = $(`.poll[data-id=${id}]`);
|
||||
|
||||
poll.addClass("loading");
|
||||
try {
|
||||
let html = (await API.Polls.unvote(poll.data("id"))).html;
|
||||
poll.prop("outerHTML", html);
|
||||
} catch(e) {
|
||||
MessageBox(tr("error"), "Sorry: " + e.message, ["OK"], [Function.noop]);
|
||||
} finally {
|
||||
poll.removeClass("loading");
|
||||
}
|
||||
}
|
||||
|
||||
async function pollFormSubmit(e, form) {
|
||||
e.preventDefault();
|
||||
form = $(form);
|
||||
|
||||
let options;
|
||||
let isMultiple = form.data("multi");
|
||||
let pollId = form.data("pid");
|
||||
|
||||
let formData = form.serializeArray();
|
||||
if(!isMultiple) {
|
||||
options = [Number(formData[0].value)];
|
||||
} else {
|
||||
options = [];
|
||||
formData.forEach(function(record) {
|
||||
if(!record.name.startsWith("option") || record.value !== "on")
|
||||
return;
|
||||
|
||||
options.push(Number(record.name.substr(6)));
|
||||
});
|
||||
}
|
||||
|
||||
let poll = form.parent();
|
||||
poll.addClass("loading");
|
||||
try {
|
||||
let html = (await API.Polls.vote(pollId, options.join(","))).html;
|
||||
poll.prop("outerHTML", html);
|
||||
} catch(e) {
|
||||
MessageBox(tr("error"), "Sorry: " + e.message, ["OK"], [Function.noop]);
|
||||
} finally {
|
||||
poll.removeClass("loading");
|
||||
}
|
||||
}
|
||||
|
||||
function pollCheckBoxPressed(cb) {
|
||||
cb = $(cb);
|
||||
let form = cb.parent().parent().parent().parent();
|
||||
let checked = $("input:checked", form);
|
||||
if(checked.length >= 1)
|
||||
$("input[type=submit]", form).removeAttr("disabled");
|
||||
else
|
||||
$("input[type=submit]", form).attr("disabled", "disabled");
|
||||
}
|
||||
|
||||
function pollRadioPressed(radio) {
|
||||
let form = $(radio).parent().parent().parent().parent();
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function initPoll(id) {
|
||||
let form = $(`#wall-post-input${id}`).parent();
|
||||
|
||||
let mBody = `
|
||||
<div id="poll_editor${id}">
|
||||
<input type="text" name="title" placeholder="${tr("poll_title")}" />
|
||||
<div class="poll-options" style="margin-top: 10px;"></div>
|
||||
<input type="text" name="newOption" placeholder="${tr("poll_add_option")}" style="margin: 5px 0;" />
|
||||
<hr/>
|
||||
<label><input type="checkbox" name="anon" /> ${tr("poll_anonymous")}</label><br/>
|
||||
<label><input type="checkbox" name="multi" /> ${tr("poll_multiple")}</label><br/>
|
||||
<label><input type="checkbox" name="locked" /> ${tr("poll_locked")}</label><br/>
|
||||
<label style="display: none;">
|
||||
<input type="checkbox" name="expires" />
|
||||
${tr("poll_edit_expires")}
|
||||
<select name="expires_in" style="width: unset;">
|
||||
${[...Array(32).keys()].reduce((p, c) => (!p ? '' : p) + ("<option value='" + c + "'>" + c + " " + tr("poll_edit_expires_days") + "</option>\n"))}
|
||||
</select>
|
||||
</label>
|
||||
<div class="nobold" style="margin: 10px 5px 0">${tr("poll_editor_tips")}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
MessageBox(tr("create_poll"), mBody, [tr("attach"), tr("cancel")], [
|
||||
function() {
|
||||
let dialog = $(this.$dialog().nodes[0]);
|
||||
$("input", dialog).unbind();
|
||||
|
||||
let title = $("input[name=title]", dialog).val();
|
||||
let anon = $("input[name=anon]", dialog).prop("checked") ? "yes" : "no";
|
||||
let multi = $("input[name=multi]", dialog).prop("checked") ? "yes" : "no";
|
||||
let lock = $("input[name=locked]", dialog).prop("checked") ? "yes" : "no";
|
||||
let expires = "infinite";
|
||||
if($("input[name=expires]", dialog).prop("checked"))
|
||||
expires = $("select[name=expires_in]", dialog).val();
|
||||
|
||||
let options = "";
|
||||
$(".poll-option", dialog).each(function() {
|
||||
if($(this).val().length === 0)
|
||||
return;
|
||||
|
||||
options += `<option>${$(this).val()}</option>`;
|
||||
});
|
||||
|
||||
let xml = `
|
||||
<Poll title="${title}" anonymous="${anon}" multiple="${multi}" locked="${lock}" duration="${expires}">
|
||||
<options>${options}</options>
|
||||
</Poll>
|
||||
`;
|
||||
$("input[name=poll]", form).val(xml);
|
||||
$(".post-has-poll", form).show();
|
||||
console.log(xml);
|
||||
},
|
||||
function() {
|
||||
$("input", $(this.$dialog().nodes[0])).unbind();
|
||||
}
|
||||
]);
|
||||
|
||||
let editor = $(`#poll_editor${id}`);
|
||||
$("input[name=newOption]", editor).bind("focus", function() {
|
||||
let newOption = $('<input type="text" class="poll-option" style="margin: 5px 0;" />');
|
||||
newOption.appendTo($(".poll-options", editor));
|
||||
newOption.focus();
|
||||
newOption.bind("keydown", function(e) {
|
||||
if(e.key === "Enter" && $(this).next().length === 0) {
|
||||
$("input[name=newOption]", editor).focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if($(this).val().length > 0)
|
||||
return;
|
||||
|
||||
if(e.key !== "Backspace")
|
||||
return;
|
||||
|
||||
if($(this).siblings().length === 0)
|
||||
return;
|
||||
|
||||
if($(this).prev().length === 0)
|
||||
$(this).next().focus();
|
||||
else
|
||||
$(this).prev().focus();
|
||||
|
||||
e.preventDefault();
|
||||
$(this).unbind("keydown");
|
||||
$(this).remove();
|
||||
});
|
||||
});
|
||||
}
|
|
@ -920,6 +920,38 @@
|
|||
"messages_error_1" = "Сообщение не доставлено";
|
||||
"messages_error_1_description" = "При отправке этого сообщения произошла ошибка общего характера...";
|
||||
|
||||
/* Polls */
|
||||
"poll" = "Опрос";
|
||||
"create_poll" = "Новый опрос";
|
||||
"poll_title" = "Тема опроса";
|
||||
"poll_add_option" = "Добавить вариант ответа";
|
||||
"poll_anonymous" = "Анонимный опрос";
|
||||
"poll_multiple" = "Множественный выбор";
|
||||
"poll_locked" = "Запретить отменять свой голос";
|
||||
"poll_edit_expires" = "Голосование истекает через: ";
|
||||
"poll_edit_expires_days" = "дней";
|
||||
"poll_editor_tips" = "Нажатие Backspace в пустом варианте приводит к его удалению. Tab/Enter в последнем добавляет новый.";
|
||||
"poll_embed" = "Получить код";
|
||||
|
||||
"poll_voter_count_zero" = "Будьте <b>первым</b>, кто проголосует!";
|
||||
"poll_voter_count_one" = "В опросе проголосовал <b>один</b> человек.";
|
||||
"poll_voter_count_few" = "В опросе проголосовало <b>$1</b> человека.";
|
||||
"poll_voter_count_many" = "В опросе проголосовало <b>$1</b> человек.";
|
||||
"poll_voter_count_other" = "В опросе проголосовало <b>$1</b> человек.";
|
||||
|
||||
"poll_voters_list" = "Список проголосовавших";
|
||||
|
||||
"poll_anon" = "Анонимное голосование";
|
||||
"poll_public" = "Публичное голосование";
|
||||
"poll_multi" = "много вариантов";
|
||||
"poll_lock" = "нельзя переголосовать";
|
||||
"poll_until" = "до $1";
|
||||
|
||||
"poll_err_to_much_options" = "Слишком много вариантов в опросе.";
|
||||
"poll_err_anonymous" = "Невозможно просмотреть список проголосовавших в анонимном голосовании.";
|
||||
"cast_vote" = "Проголосовать";
|
||||
"retract_vote" = "Отменить голос";
|
||||
|
||||
/* Discussions */
|
||||
|
||||
"discussions" = "Обсуждения";
|
||||
|
|
|
@ -44,3 +44,5 @@ comments.allow-graffiti: 0
|
|||
# + Set this option to -1 if you want to disable the limit
|
||||
# + Set this option to any non-negative number to be this limit
|
||||
wall.repost-liking-recursion-limit: 10
|
||||
|
||||
polls.max-opts: 10
|
Loading…
Reference in a new issue