@import "newslash/messages.less";
@import "newslash/progress_bar.less";
@import "newslash/wiki_content.less";
+@import "newslash/timeline.less";
@import "newslash/ads.less";
@import "newslash/system_error.less";
header {
margin-bottom: 10px;
h1 {
+ &.color-red { border-left: 2px solid red; }
+ &.color-orange { border-left: 2px solid orange; }
+ &.color-yellow { border-left: 2px solid yellow; }
+ &.color-green { border-left: 2px solid green; }
+ &.color-blue { border-left: 2px solid blue; }
+ &.color-indigo { border-left: 2px solid indigo; }
+ &.color-violet { border-left: 2px solid violet; }
+ &.color-black { border-left: 2px solid black; }
&:extend(.rectangle-header);
&:extend(.large-text);
img {
--- /dev/null
+/* timeline related styles */
+
+.timeline-filter-ui {
+ &:extend(.bordered-box);
+ .filter-colors {
+ display: inline-block;
+ .color-indicator {
+ display: inline-block;
+ width: 20px;
+ border: 1px solid @component-border-color;
+ cursor: pointer;
+ float: left;
+ &.red { background-color: red; color: red; }
+ &.orange { background-color: orange; color: orange; }
+ &.yellow { background-color: yellow; color: yellow; }
+ &.green { background-color: green; color: green; }
+ &.blue { background-color: blue; color: blue; }
+ &.indigo { background-color: indigo; color: indigo; }
+ &.violet { background-color: violet; color: violet; }
+ &.black { background-color: black; color: black; }
+ &.active { border-width: 3px; border-color: @primary-color; }
+ }
+ }
+}
my $keys = { uid => "journals.uid",
user_id => "journals.uid",
karma => "users_info.karma",
+ popularity => "firehose.popularity",
};
my $datetime_keys = { create_time => 'journals.date',
update_time => 'journals.last_update',
my $params = {@_};
my $uid = $params->{uid};
- my $type = "global";
+ my $type = $params->{type} || "global";
if ($uid) {
$type = $params->{type} || "user";
}
if ($type eq "user") {
return $self->_select_user($uid, $params);
}
+ elsif ($type eq "tags") {
+ return $self->_select_tags($uid, $params);
+ }
+ elsif ($type eq "user") {
+ return $self->_select_user($uid, $params);
+ }
elsif ($type eq "friends") {
return $self->_select_friends($uid, $params);
}
}
+sub select_nocache {
+ my ($self, @params) = @_;
+ return $self->model->select(@params);
+}
+
sub select {
my ($self, @params) = @_;
Timeline => { popular_period => { hours => 6 },
item_per_page => 20,
+ item_per_page_limit => 100,
+ heatmap => { black => -999,
+ violet => -20,
+ indigo => 25,
+ blue => 93,
+ green => 138,
+ yellow => 175,
+ orange => 200,
+ red => 240,
+ },
},
Database => { host => "db",
$api->get('/story')->to('API::Story#get');
$api->post('/story')->to('API::Story#post');
+ $api->get('/timeline')->to('API::Timeline#get');
+
$api->get('/poll')->to('API::Poll#get');
$api->post('/poll')->to('API::Poll#post');
$api->post('/vote')->to('API::Poll#vote', csrf_check_id => 'vote');
--- /dev/null
+package Newslash::Web::Controller::API::Timeline;
+use Mojo::Base 'Mojolicious::Controller';
+use Data::Dumper;
+
+
+sub _add_url {
+ my ($c, $item) = @_;
+ if ($item->{content_type} eq "journal") {
+ return "/~$item->{author}/journal/$item->{id}/";
+ }
+ elsif ($item->{content_type} eq "story") {
+ return "/story/$item->{sid}/";
+ }
+ else {
+ return "/$item->{content_type}/$item->{id}/";
+ }
+ return;
+}
+
+sub _get_heatmap {
+ my $c = shift;
+ my $cfg = $c->config("Timeline");
+ my $heatmap = $cfg->{heatmap};
+
+ if (!$heatmap) {
+ $c->app->log->error("Timeline: no heatmap defined.");
+ return;
+ }
+
+ my @keys = keys %$heatmap;
+ @keys = sort { $heatmap->{$a} <=> $heatmap->{$b} } @keys;
+ my $rs = [];
+ for my $k (@keys) {
+ push @$rs, { $k => $heatmap->{$k } };
+ }
+ return $rs;
+}
+
+sub _get_primary_topic_icon_url {
+ my ($c, $item) = @_;
+ my $cfg = $c->config("Site") || {};
+ my $base_url = $cfg->{topic_icon_base_url};
+
+ if (!$base_url) {
+ $c->app->log->error("Timeline: Site.topic_icon_base_url is not defined");
+ return;
+ }
+ my $t = $item->{primary_topic} || {};
+ my $image = $t->{image};
+
+ if ($image) {
+ return "$base_url/$image";
+ }
+ return;
+}
+
+sub _score_to_heatmap_color {
+ my ($c, $item) = @_;
+ my $heatmap = _get_heatmap($c);
+ if (!$heatmap) {
+ return;
+ }
+
+ my $last_color;
+ for my $i (reverse @$heatmap) {
+ my @k = keys %$i;
+ my $color = $last_color = $k[0];
+ my $threshold = $i->{$color};
+
+ if ($item->{popularity} > $threshold) {
+ return $color;
+ }
+ }
+ return $last_color;
+}
+
+sub _threshold_to_popularity {
+ my ($c, $threshold) = @_;
+ my $heatmap = _get_heatmap($c);
+ if (!$heatmap) {
+ $c->app->log->error("Timeline::_threshold_to_popularity: no heatmap defined.");
+ return;
+ }
+
+ my $color = $heatmap->[$threshold];
+ if (!$color) {
+ $c->app->log->error("Timeline::_threshold_to_popularity: no color for threshold $threshold.");
+ return;
+ }
+
+ my @k = keys %$color;
+ return $color->{$k[0]};
+}
+
+
+sub get {
+ my $c = shift;
+ my $user = $c->stash('user');
+ my $params = $c->req->query_params->to_hash;
+ my $target = $params->{target} || "all";
+ my $cfg = $c->config("Timeline");
+
+ my $hide_future = !$user->{is_admin} && !$user->{editor};
+ my $public_only = !$user->{is_admin} && !$user->{editor};
+
+ my $limit = $params->{limit} || $cfg->{item_per_page} || 10;
+ my $max_limit = $cfg->{item_per_page_limit} || 1000;
+ $limit = $max_limit if $limit > $max_limit;
+
+ my $skip = $params->{skip} || 0;
+ my $min_popularity;
+ if ($params->{threshold}) {
+ $min_popularity = _threshold_to_popularity($c, $params->{threshold});
+ $c->app->log->debug("Timeline::_threshold_to_popularity: use min_pop $min_popularity.");
+ }
+ my $result;
+ my $model;
+
+ if ($target eq "story") {
+ $model = $c->ccache->model('stories');
+ }
+ elsif ($target eq "journal") {
+ $model = $c->ccache->model('journals');
+ }
+ elsif ($target eq "comment") {
+ $model = $c->ccache->model('comments');
+ }
+ elsif ($target eq "poll") {
+ $model = $c->ccache->model('polls');
+ }
+ elsif ($target eq "submission") {
+ $model = $c->ccache->model('submissions');
+ }
+ elsif ($target eq "all") {
+ $model = $c->ccache->model('timeline');
+ }
+ else {
+ $c->render(json => { error => { code => -1, message => "invalid_request" }});
+ $c->rendered(400);
+ return;
+ }
+
+ if ($user->{is_login}) {
+ $result = $model->select_nocache(hide_future => $hide_future,
+ public_only => $public_only,
+ limit => $limit,
+ skip => $skip,
+ order_by => {create_time => 'desc'},
+ popularity => $min_popularity ? { ge => $min_popularity } : undef,
+ );
+ }
+ else {
+ $result = $model->select(hide_future => $hide_future,
+ public_only => $public_only,
+ limit => $limit,
+ skip => $skip,
+ order_by => {create_time => 'desc'},
+ popularity => $min_popularity ? { ge => $min_popularity } : undef,
+ );
+ }
+
+ if (!$result) {
+ $c->render(json => { error => { code => -1, message => "internal_server_error" }});
+ $c->rendered(500);
+ return;
+ }
+
+ if (!@$result) {
+ $c->render(json => { error => { code => -1, message => "not_found" }});
+ $c->rendered(404);
+ return;
+ }
+
+ # add headmap info and topic icon url
+ for my $item (@$result) {
+ $item->{color} = _score_to_heatmap_color($c, $item);
+ $item->{icon_url} = _get_primary_topic_icon_url($c, $item);
+ $item->{url} = _add_url($c, $item);
+ }
+
+
+ $c->render(json => { result => $result });
+}
+
+
+1;
content_type => $params->{content_type},
};
+ if ($params->{content_type} eq "journal") {
+ $self->render("timeline/timeline2",
+ items => $items,
+ prev => $prev,
+ page => $page,
+ );
+ $self->stats->add_event_counter("timeline_view");
+ return;
+ }
+
$self->render("timeline/base",
items => $items,
prev => $prev,
return this.post("/journal", data);
};
+ Newslash.prototype.getTimeline = function getTimeline (target, options) {
+ if (!target) { target = "all"; }
+ options = options || {};
+
+ var url = "/timeline?target=" + target;
+ if (options.threshold !== undefined) {
+ url = url + "&threshold=" + options.threshold;
+ }
+
+ return this.get(url);
+ };
}
_initNewslash();
--- /dev/null
+/* timeline.js */
+var timeline = {};
+
+timeline.run = function (params) {
+ /* define exotic parameters */
+ params = params || {};
+ var userConfig = params.userConfig || {};
+ var siteConfig = params.siteConfig || {};
+ var pageInfo = params.pageInfo || {};
+ var user = params.user || {};
+
+ if (!params.el) {
+ console.log('error in commentTree.run(): no element given');
+ return;
+ }
+
+ /*
+ * register <timeline-item>
+ */
+ Vue.component('timeline-item', {
+ template: '#timeline-item-template',
+ props: {item: Object},
+ data: function () { return {}; },
+ created: function () { return; },
+ });
+
+ /*
+ * register <timeline-filter-ui>
+ */
+ Vue.component('timeline-filter-ui', {
+ template: '#timeline-filter-ui-template',
+ props: {},
+ data: function () { return { threshold: 1}; },
+ created: function () { return; },
+ methods: {
+ setThreshold: function setThreshold (threshold) {
+ if (this.threshold != threshold) {
+ this.threshold = threshold;
+ vm.$emit('updateTimeline', {threshold: threshold});
+ }
+ },
+ },
+ });
+
+ function updateTimeline(vm, target, threshold) {
+ newslash.getTimeline(target, {threshold: threshold}).then(
+ (resp) => { // success
+ vm.items = resp.result;
+ },
+ (resp) => { // fail
+ statusIndicator.error("comment_loading_error");
+ }
+ );
+ }
+
+ var vm = this.vm = new Vue({
+ el: params.el,
+ data: { items: [] },
+ created: function created() {
+ updateTimeline(this, params.target, 1);
+ },
+ });
+
+ vm.$on("updateTimeline", function (args) {
+ updateTimeline(this, params.target, args.threshold);
+ });
+};
+
--- /dev/null
+# -*-Perl-*-
+# timeline api tests
+use Mojo::Base -strict;
+use Mojo::Date;
+
+use Test::More;
+use Test::Mojo;
+use Mojo::Util qw(dumper);
+
+my $t = Test::Mojo->new('Newslash::Web');
+
+subtest 'get timeline' => sub {
+
+ # get all items
+ $t->get_ok("/api/v1/timeline?target=all")
+ ->status_is(200)
+ ->content_type_like(qr/application\/json/)
+ ->json_has('/result')
+ ->or(sub {diag "message: " . dumper($t->tx->res->json);});
+
+ # get story items
+ $t->get_ok("/api/v1/timeline?target=story")
+ ->status_is(200)
+ ->content_type_like(qr/application\/json/)
+ ->json_has('/result')
+ ->json_has('/result/0/stoid')
+ ->or(sub {diag "message: " . dumper($t->tx->res->json);});
+
+ # get comment items
+ $t->get_ok("/api/v1/timeline?target=comment")
+ ->status_is(200)
+ ->content_type_like(qr/application\/json/)
+ ->json_has('/result')
+ ->json_has('/result/0/cid')
+ ->or(sub {diag "message: " . dumper($t->tx->res->json);});
+
+ # get journal items
+ $t->get_ok("/api/v1/timeline?target=journal")
+ ->status_is(200)
+ ->content_type_like(qr/application\/json/)
+ ->json_has('/result')
+ ->json_has('/result/0/journal_id')
+ ->or(sub {diag "message: " . dumper($t->tx->res->json);});
+
+ # get submission items
+ $t->get_ok("/api/v1/timeline?target=submission")
+ ->status_is(200)
+ ->content_type_like(qr/application\/json/)
+ ->json_has('/result')
+ ->json_has('/result/0/submission_id')
+ ->or(sub {diag "message: " . dumper($t->tx->res->json);});
+
+ # get poll items
+ #$t->get_ok("/api/v1/timeline?target=poll")
+ # ->status_is(200)
+ # ->content_type_like(qr/application\/json/)
+ # ->json_has('/result')
+ # ->json_has('/result/0/qid')
+ # ->or(sub {diag "message: " . dumper($t->tx->res->json);});
+};
+
+
+done_testing();
-%]
-<article id="[% item.id %]" type="[% item.content_type %]" item-id="[% content_id %]"
+<article id="[% item.id %]" type="[% item.content_type %]" item-id="[% item.content_id %]"
[% IF !x_template %]v-if="0"[% ELSE %]v-if="mode != 'editing' || enableAutoPreview"[% END %]>
<header>
<h1>
--- /dev/null
+<script type="text/x-template" id="timeline-filter-ui-template">
+ <div class="timeline-filter-ui">
+ <span>表示するアイテムのしきい値:</span>
+ <div class="filter-colors">
+ <span :class="{active: threshold == 0}" class="color-indicator black" title="0" @click="setThreshold(0)">0</span>
+ <span :class="{active: threshold == 1}" class="color-indicator violet" title="1" @click="setThreshold(1)">1</span>
+ <span :class="{active: threshold == 2}" class="color-indicator indigo" title="2" @click="setThreshold(2)">2</span>
+ <span :class="{active: threshold == 3}" class="color-indicator blue" title="3" @click="setThreshold(3)">3</span>
+ <span :class="{active: threshold == 4}" class="color-indicator green" title="4" @click="setThreshold(4)">4</span>
+ <span :class="{active: threshold == 5}" class="color-indicator yellow" title="5" @click="setThreshold(5)">5</span>
+ <span :class="{active: threshold == 6}" class="color-indicator orange" title="6" @click="setThreshold(6)">6</span>
+ <span :class="{active: threshold == 7}" class="color-indicator red" title="7" @click="setThreshold(7)">7</span>
+ </div>
+ </div>
+</script>
+
+<script type="text/x-template" id="timeline-item-template">
+ <article>
+ <header>
+ <h1 :class="item.color ? 'color-' + item.color : ''">
+ <img :src="item.icon_url" v-if="item.icon_url" />
+ <a :href="item.url" v-html="item.title" v-if="item.url">
+ <span v-html="item.title"></span>
+ </a>
+ <span v-html="item.title" v-else></span>
+ </h1>
+
+ <div class="property">
+ <span class="content-type" v-html="item.content_type"></span>
+ <span class="author">
+ by <a :href="'/~' + item.author + '/'" v-text="item.author"></a>
+ </span>
+ <span class="create-time" v-text="item.create_time"></span>
+
+ [%- IF user.is_admin %]
+ <span class="score">
+ pop: <span v-text="item.popularity"></span>
+ epop: <span v-text="item.editorpop"></span>
+ need: <span v-text="item.neediness"></span>
+ act: <span v-text="item.activity"></span>
+ </span>
+ [%- END %]
+
+ <span class="dept" v-if="item.content_type == 'story' && item.dept">
+ <span class="dept-name" v-text="item.dept"></span> 部門より
+ </span>
+ </div><!-- .property -->
+
+ [% IF user.author || user.is_admin %]
+ <div class="alert alert-info" v-if="item.public != 'yes'">この記事は非公開に設定されています</div>
+ [% END %]
+ </header>
+
+ <div class="body contents-text" v-html="item.intro_text" v-if="item.intro_text"></div>
+ <div class="body contents-text" v-html="item.body_text" v-if="item.body_text"></div>
+ <div class="body contents-text" v-html="item.full_text" v-if="item.full_text"></div>
+ <div class="body contents-text" v-html="item.media" v-if="item.media"></div>
+ <div class="body contents-text" v-if="item.url"><p><a :href="item.url">情報元へのリンク</a></p></div>
+
+ <footer>
+ <div class="link-to-story">
+ <a :href="item.url">
+ <span v-if='item.comment_count > 0'>
+ <span v-text="item.comment_count"></span>件のコメントを見る
+ </span>
+ <span v-else>
+ 続きを読む
+ </span>
+ </a>
+ </div>
+
+ <div class="tag-bar">
+ <ul class="tags">
+ <li v-for="tag in item.tags" v-if="tag.private == 'no' && tag.tagname != 'mainpage' && tag.uid == item.uid">
+ <a :href="'/tag/' + tag.tagname" v-text="tag.tagname"></a>
+ </li>
+ </ul>
+ </div>
+
+ </footer>
+ </article>
+</script>
+
+
+[% helpers.load_js("timeline.js") %]
+
--- /dev/null
+[% WRAPPER common/layout enable_sidebar=1 %]
+
+<div class="sidebar-wrapper">
+ [%- helpers.ad_code("timeline-top") %]
+ <div class="index main-contents" id="timeline">
+ <timeline-filter-ui></timeline-filter-ui>
+ <div class="timeline-items" v-if="0">
+ [%- FOREACH item IN items -%]
+ [%- INCLUDE common/article/article hide_bodytext=1 %]
+ [%- END -%]
+ </div>
+
+ <div class="timeline-items" v-else v-for="item in items">
+ <timeline-item :item="item"></timeline-item>
+ </div>
+
+ [%- IF prev -%]
+ <div class="pager">
+ <span class="prev">
+ <a href="/[% prev.type %]/[% prev.date %]/[% IF prev.id %]#[% prev.id %][% END %]">前の記事</a>
+ </span>
+ </div>
+ [%- END -%]
+
+ </div><!-- .index -->
+
+ [%- INCLUDE common/sidebar -%]
+
+</div><!-- .timeline-wrapper -->
+[% INCLUDE common/components/timeline %]
+<script>
+ timeline.run({el: "#timeline", target: "journal"});
+</script>
+[% END %]