OSDN Git Service

implement filtering feature for timeline (currently works journal only) v0.1.11
authorhylom <hylom@users.sourceforge.jp>
Wed, 20 Mar 2019 13:35:05 +0000 (22:35 +0900)
committerhylom <hylom@users.sourceforge.jp>
Wed, 20 Mar 2019 13:35:05 +0000 (22:35 +0900)
16 files changed:
src/newslash_web/css/newslash.less
src/newslash_web/css/newslash/article.less
src/newslash_web/css/newslash/timeline.less [new file with mode: 0644]
src/newslash_web/lib/Newslash/Model/Journals.pm
src/newslash_web/lib/Newslash/Model/Timeline.pm
src/newslash_web/lib/Newslash/Plugin/CompositeCache.pm
src/newslash_web/lib/Newslash/Plugin/DefaultConfig.pm
src/newslash_web/lib/Newslash/Web.pm
src/newslash_web/lib/Newslash/Web/Controller/API/Timeline.pm [new file with mode: 0644]
src/newslash_web/lib/Newslash/Web/Controller/Timeline.pm
src/newslash_web/public/js/newslash.js
src/newslash_web/public/js/timeline.js [new file with mode: 0644]
src/newslash_web/t/api/timeline.t [new file with mode: 0644]
src/newslash_web/templates/common/article/article.html.tt2
src/newslash_web/templates/common/components/timeline.html.tt2 [new file with mode: 0644]
src/newslash_web/templates/timeline/timeline2.html.tt2 [new file with mode: 0644]

index b9d6ba3..ca8af4e 100644 (file)
@@ -28,6 +28,7 @@
 @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";
index f92c89c..03e1ac0 100644 (file)
@@ -6,6 +6,14 @@ article {
     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 {
diff --git a/src/newslash_web/css/newslash/timeline.less b/src/newslash_web/css/newslash/timeline.less
new file mode 100644 (file)
index 0000000..0b86d60
--- /dev/null
@@ -0,0 +1,24 @@
+/* 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; }
+        }
+    }
+}
index 21d6759..7ecf36c 100644 (file)
@@ -122,6 +122,7 @@ sub select {
     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',
index a54997a..a313e7e 100644 (file)
@@ -9,7 +9,7 @@ sub select {
     my $params = {@_};
 
     my $uid = $params->{uid};
-    my $type = "global";
+    my $type = $params->{type} || "global";
     if ($uid) {
         $type = $params->{type} || "user";
     }
@@ -17,6 +17,12 @@ sub select {
     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);
     }
index a87b634..efd798d 100644 (file)
@@ -287,6 +287,11 @@ sub _param_to_hash {
 }
 
 
+sub select_nocache {
+    my ($self, @params) = @_;
+    return $self->model->select(@params);
+}
+
 sub select {
     my ($self, @params) = @_;
 
index 399e8a7..1b1e65d 100644 (file)
@@ -69,6 +69,16 @@ my $defaults = {
 
                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",
index e83ea43..33b705f 100644 (file)
@@ -414,6 +414,8 @@ sub startup {
     $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');
diff --git a/src/newslash_web/lib/Newslash/Web/Controller/API/Timeline.pm b/src/newslash_web/lib/Newslash/Web/Controller/API/Timeline.pm
new file mode 100644 (file)
index 0000000..61efccd
--- /dev/null
@@ -0,0 +1,186 @@
+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;
index ac4f2c8..06bc7a5 100644 (file)
@@ -60,6 +60,16 @@ sub _render_timeline {
                  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,
index 54a4098..6886998 100644 (file)
@@ -201,6 +201,17 @@ function _initNewslash() {
     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();
diff --git a/src/newslash_web/public/js/timeline.js b/src/newslash_web/public/js/timeline.js
new file mode 100644 (file)
index 0000000..7e6a5ad
--- /dev/null
@@ -0,0 +1,68 @@
+/* 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);
+  });
+};
+
diff --git a/src/newslash_web/t/api/timeline.t b/src/newslash_web/t/api/timeline.t
new file mode 100644 (file)
index 0000000..7611bc7
--- /dev/null
@@ -0,0 +1,63 @@
+# -*-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();
index 87d2f7f..224effd 100644 (file)
@@ -41,7 +41,7 @@ END;
 
 -%]
 
-<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>
diff --git a/src/newslash_web/templates/common/components/timeline.html.tt2 b/src/newslash_web/templates/common/components/timeline.html.tt2
new file mode 100644 (file)
index 0000000..408f652
--- /dev/null
@@ -0,0 +1,86 @@
+<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") %]
+
diff --git a/src/newslash_web/templates/timeline/timeline2.html.tt2 b/src/newslash_web/templates/timeline/timeline2.html.tt2
new file mode 100644 (file)
index 0000000..127fbb3
--- /dev/null
@@ -0,0 +1,34 @@
+[% 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 %]