commit dd492db92e23036621a0fca7a98483cbb124e792 Author: Lucas Gabriel Vuotto Date: Sat Apr 26 09:41:50 2025 +0000 Initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d5b225 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +db/ +s/ diff --git a/bin/mk-schema.pl b/bin/mk-schema.pl new file mode 100644 index 0000000..073e39e --- /dev/null +++ b/bin/mk-schema.pl @@ -0,0 +1,12 @@ +#!/usr/bin/env perl +use v5.40; + +use DBIx::Class::Schema::Loader qw(make_schema_at); + +my $dsn = (require "./pooru-api-v0.conf")->{dsn}; +my %opts = ( + dump_directory => "lib", + exclude => "dbix_migration", +); + +make_schema_at("Pooru::Schema", \%opts, [$dsn]); diff --git a/bin/run.pl b/bin/run.pl new file mode 100644 index 0000000..1adc726 --- /dev/null +++ b/bin/run.pl @@ -0,0 +1,12 @@ +#!/usr/bin/env perl +use Mojolicious::Lite; + +app->secrets(["unused"]); + +app->static->paths(["s"]); +app->static->prefix("/s"); + +plugin Mount => { "/api/v0" => "script/pooru-api-v0" }; +plugin Mount => { "/" => "script/pooru-site" }; + +app->start; diff --git a/cpanfile b/cpanfile new file mode 100644 index 0000000..09967e1 --- /dev/null +++ b/cpanfile @@ -0,0 +1,9 @@ +# Reflects OpenBSD's versions at time of development. + +requires "perl", "5.40.1"; + +requires "Mojolicious", "9.37"; +requires "DBIx::Class", "0.082"; +requires "DBIx::Class::Schema::Loader", "0.070"; +requires "DBIx::Migration", "0.07"; # Quite an old one. +requires "DBD::SQLite", "1.76"; diff --git a/lib/Pooru/API/V0.pm b/lib/Pooru/API/V0.pm new file mode 100644 index 0000000..a805860 --- /dev/null +++ b/lib/Pooru/API/V0.pm @@ -0,0 +1,80 @@ +package Pooru::API::V0; +use Mojo::Base "Mojolicious", -signatures; + +use DBD::SQLite::Constants ":dbd_sqlite_string_mode"; +use DBIx::Migration; +use Pooru::Schema; + +sub _search_opts () +{ + return { + Media => { + order_by => {-desc => "id"}, + rows => 12, + }, + TagCountView => { + order_by => [ + {-desc => [qw(count kind_id)]}, + {-asc => "name"}, + ], + rows => 1000, + }, + TaggedMediaView_media => { + order_by => {-desc => "media_id"}, + rows => 12, + }, + TaggedMediaView_tags => { + order_by => [ + {-desc => "tag_kind_id"}, + {-asc => "tag_name"}, + ], + rows => 1000, + }, + }; +} + +sub startup ($self) +{ + $self->moniker("pooru-api-v0"); + + my $config = $self->plugin("Config"); + $self->secrets($config->{secrets}); + + $self->helper(schema => sub { + state $schema = Pooru::Schema->connect( + $config->{dsn}, + undef, + undef, + { + sqlite_string_mode => + DBD_SQLITE_STRING_MODE_UNICODE_STRICT, + on_connect_call => "use_foreign_keys", + } + ); + }); + $self->helper(pager => sub ($, $rs) { + return map +($_ => ($rs->pager->$_ and int($rs->pager->$_))), + qw(first_page previous_page current_page next_page + last_page); + }); + + DBIx::Migration->new({ + dsn => $config->{dsn}, + dir => $self->app->home->child("migrations")->to_string, + })->migrate; + + my $r = $self->routes; + + $r->get("/meta")->to("meta#index")->name("meta"); + + $r->get("/media")->to("media#list")->name("list_media"); + $r->get("/media/")->to("media#show")->name("show_media"); + + $r->get("/tag")->to("tags#show")->name("show_tag"); + $r->get("/tags")->to("tags#list")->name("list_tags"); + + $r->get("/random/media")->to("random#media")->name("random_media"); + $r->get("/random/tag")->to("random#tag")->name("random_tag"); +} + +1; diff --git a/lib/Pooru/API/V0/Controller/Media.pm b/lib/Pooru/API/V0/Controller/Media.pm new file mode 100644 index 0000000..af54e17 --- /dev/null +++ b/lib/Pooru/API/V0/Controller/Media.pm @@ -0,0 +1,120 @@ +package Pooru::API::V0::Controller::Media; +use Mojo::Base "Mojolicious::Controller", -signatures; + +my %search_opts = Pooru::API::V0::_search_opts->%*; + +sub _list_no_tags ($self, $page) +{ + my $paged_media = $self->schema->resultset("Media") + ->search(undef, $search_opts{Media}) + ->page($page); + my @media = map +{ + id => $_->id, + storage_id => $_->storage_id, + filename => $_->filename, + content_type => $_->content_type, + upload_datetime => $_->upload_datetime, + }, $paged_media->all; + + return $self->render(json => { + media => [@media], + pager => {$self->pager($paged_media)}, + }); +} + +# Executes +# +# SELECT * FROM tagged_media_view +# WHERE (tag_display = t_1 OR ... OR tag_display = t_N) +# GROUP BY media_id HAVING COUNT(media_id) = N +sub _list_with_tags ($self, $page, @tags) +{ + my $attrs = { + $search_opts{TaggedMediaView_media}->%*, + + # Use "0 + ?" as otherwise a bare "?" interprets the value as + # TEXT, breaking the functionality. + having => \["COUNT(media_id) = 0 + ?", scalar @tags], + group_by => "media_id", + }; + + my $paged_media = $self->schema->resultset("TaggedMediaView") + ->search({tag_display => [@tags]}, $attrs)->page($page); + my @media = map +{ + id => $_->media_id, + storage_id => $_->media_storage_id, + filename => $_->media_filename, + content_type => $_->media_content_type, + upload_datetime => $_->media_upload_datetime, + }, $paged_media->all; + + return $self->render(json => { + media => [@media], + pager => {$self->pager($paged_media)}, + }); +} + +sub list ($self) +{ + my $v = $self->validation; + + my $page = $v->optional("page")->num(1, undef)->param // 1; + return $self->render( + json => {error => "Invalid page number."}, + status => 400, + ) if $v->has_error; + + my @tags = split(" ", $v->optional("tags")->param // ""); + return $self->render( + json => {error => "Invalid tags."}, + status => 400, + ) if $v->has_error; + + return @tags == 0 ? + $self->_list_no_tags($page) : + $self->_list_with_tags($page, @tags); + +} + +sub show ($self) +{ + my $v = $self->validation; + + my $page = $v->optional("page")->num(1, undef)->param // 1; + return $self->render( + json => {error => "Invalid page number."}, + status => 400, + ) if $v->has_error; + + my $media_id = $self->stash("media_id"); + my $media = $self->schema->resultset("Media") + ->single({id => $media_id}); + + return $self->render( + json => {error => "Media not found"}, + status => 404, + ) if !defined($media); + + my $paged_tags = $self->schema->resultset("TaggedMediaView") + ->search({media_id => $media_id}, + $search_opts{TaggedMediaView_tags})->page($page); + my @tags = map +{ + id => $_->tag_id, + name => $_->tag_name, + kind_id => $_->tag_kind_id, + display => $_->tag_display, + count => $_->tag_count, + }, $paged_tags->all; + + return $self->render(json => { + id => $media->id, + storage_id => $media->storage_id, + filename => $media->filename, + content_type => $media->content_type, + upload_datetime => $media->upload_datetime, + tags => [@tags], + pager => {$self->pager($paged_tags)}, + }); +} + +1; diff --git a/lib/Pooru/API/V0/Controller/Meta.pm b/lib/Pooru/API/V0/Controller/Meta.pm new file mode 100644 index 0000000..7d7cbfe --- /dev/null +++ b/lib/Pooru/API/V0/Controller/Meta.pm @@ -0,0 +1,22 @@ +package Pooru::API::V0::Controller::Meta; +use Mojo::Base "Mojolicious::Controller", -signatures; + +sub index ($self) +{ + return $self->render(json => { + routes => [ + { path => "/meta", verb => "GET" }, + + { path => "/media", verb => "GET" }, + { path => "/media/", verb => "GET" }, + + { path => "/tag", verb => "GET" }, + { path => "/tags", verb => "GET" }, + + { path => "/random/media", verb => "GET" }, + { path => "/random/tag", verb => "GET" }, + ], + }); +} + +1; diff --git a/lib/Pooru/API/V0/Controller/Random.pm b/lib/Pooru/API/V0/Controller/Random.pm new file mode 100644 index 0000000..c3ad983 --- /dev/null +++ b/lib/Pooru/API/V0/Controller/Random.pm @@ -0,0 +1,35 @@ +package Pooru::API::V0::Controller::Random; +use Mojo::Base "Mojolicious::Controller", -signatures; + +sub _random_entry ($self, $rs) +{ + return $self->schema->resultset($rs) + ->search(undef, {order_by => 'random()', rows => 1})->single; +} + +sub media ($self) +{ + my $media = $self->_random_entry("Media"); + + return $self->render( + json => {error => "Media not found."}, + status => 404, + ) if !defined($media); + + return $self->redirect_to("show_media", media_id => $media->id); +} + +sub tag ($self) +{ + my $tag = $self->_random_entry("Tag"); + + return $self->render( + json => {error => "Tag not found."}, + status => 404, + ) if !defined($tag); + + return $self->redirect_to( + $self->url_for("show_tag")->query(id => $tag->id)); +} + +1; diff --git a/lib/Pooru/API/V0/Controller/Tags.pm b/lib/Pooru/API/V0/Controller/Tags.pm new file mode 100644 index 0000000..f0252a6 --- /dev/null +++ b/lib/Pooru/API/V0/Controller/Tags.pm @@ -0,0 +1,69 @@ +package Pooru::API::V0::Controller::Tags; +use Mojo::Base "Mojolicious::Controller", -signatures; + +my %search_opts = Pooru::API::V0::_search_opts->%*; + +sub list ($self) +{ + my $v = $self->validation; + + my $page = $v->optional("page")->num(1, undef)->param // 1; + return $self->render( + json => {error => "Invalid page number."}, + status => 400, + ) if $v->has_error; + + my $paged_tags = $self->schema->resultset("TagCountView") + ->search(undef, $search_opts{TagCountView})->page($page); + my @tags = map +{ + id => $_->id, + name => $_->name, + kind_id => $_->kind_id, + display => $_->display, + count => $_->count, + }, $paged_tags->all; + + return $self->render(json => { + tags => [@tags], + pager => {$self->pager($paged_tags)}, + }); +} + +sub show ($self) +{ + my $v = $self->validation; + + my $tag_id_or_name = $v->optional("id")->num(1, undef)->param // + $v->optional("display")->param // + $v->required("name")->param; + return $self->render( + json => {error => "Invalid tag ID or name."}, + status => 400, + ) if $v->has_error; + + my %search = ($v->topic => $tag_id_or_name); + + my $kind_id = $v->optional("kind_id")->param; + return $self->render( + json => {error => "Invalid kind ID."}, + status => 400, + ) if $v->has_error; + $search{kind_id} = $kind_id if defined($kind_id); + + my @tags = map +{ + id => $_->id, + name => $_->name, + kind_id => $_->kind_id, + display => $_->display, + count => $_->count, + }, $self->schema->resultset("TagCountView")->search(\%search)->all; + + return $self->render( + json => {error => "Tag not found."}, + status => 404, + ) if @tags == 0; + + return $self->render(json => {tags => [@tags]}); +} + +1; diff --git a/lib/Pooru/Schema.pm b/lib/Pooru/Schema.pm new file mode 100644 index 0000000..58b35d0 --- /dev/null +++ b/lib/Pooru/Schema.pm @@ -0,0 +1,20 @@ +use utf8; +package Pooru::Schema; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +use strict; +use warnings; + +use base 'DBIx::Class::Schema'; + +__PACKAGE__->load_namespaces; + + +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:CKLwXldwvhv5+lWXivRpCA + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/Pooru/Schema/Result/Kind.pm b/lib/Pooru/Schema/Result/Kind.pm new file mode 100644 index 0000000..1ea36a3 --- /dev/null +++ b/lib/Pooru/Schema/Result/Kind.pm @@ -0,0 +1,96 @@ +use utf8; +package Pooru::Schema::Result::Kind; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Pooru::Schema::Result::Kind + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("kind"); + +=head1 ACCESSORS + +=head2 id + + data_type: 'text' + is_nullable: 0 + size: 32 + +=head2 name + + data_type: 'text' + is_nullable: 0 + size: 256 + +=cut + +__PACKAGE__->add_columns( + "id", + { data_type => "text", is_nullable => 0, size => 32 }, + "name", + { data_type => "text", is_nullable => 0, size => 256 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("id"); + +=head1 UNIQUE CONSTRAINTS + +=head2 C + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->add_unique_constraint("name_unique", ["name"]); + +=head1 RELATIONS + +=head2 tags + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "tags", + "Pooru::Schema::Result::Tag", + { "foreign.kind_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ePfTpTOWY1H3Bky/bjA3iA + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/Pooru/Schema/Result/Media.pm b/lib/Pooru/Schema/Result/Media.pm new file mode 100644 index 0000000..e4bcf5a --- /dev/null +++ b/lib/Pooru/Schema/Result/Media.pm @@ -0,0 +1,136 @@ +use utf8; +package Pooru::Schema::Result::Media; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Pooru::Schema::Result::Media + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("media"); + +=head1 ACCESSORS + +=head2 id + + data_type: 'integer' + is_auto_increment: 1 + is_nullable: 0 + +=head2 storage_id + + data_type: 'text' + is_nullable: 0 + size: 64 + +=head2 filename + + data_type: 'text' + is_nullable: 0 + size: 256 + +=head2 content_type + + data_type: 'text' + is_nullable: 1 + size: 256 + +=head2 upload_datetime + + data_type: 'text' + default_value: current_timestamp + is_nullable: 1 + size: 32 + +=cut + +__PACKAGE__->add_columns( + "id", + { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, + "storage_id", + { data_type => "text", is_nullable => 0, size => 64 }, + "filename", + { data_type => "text", is_nullable => 0, size => 256 }, + "content_type", + { data_type => "text", is_nullable => 1, size => 256 }, + "upload_datetime", + { + data_type => "text", + default_value => \"current_timestamp", + is_nullable => 1, + size => 32, + }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("id"); + +=head1 UNIQUE CONSTRAINTS + +=head2 C + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->add_unique_constraint("storage_id_unique", ["storage_id"]); + +=head1 RELATIONS + +=head2 media_tags + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "media_tags", + "Pooru::Schema::Result::MediaTag", + { "foreign.media_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + +=head2 tags + +Type: many_to_many + +Composing rels: L -> tag + +=cut + +__PACKAGE__->many_to_many("tags", "media_tags", "tag"); + + +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ODG4pqImG0JNc1YpUIVNFA + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/Pooru/Schema/Result/MediaTag.pm b/lib/Pooru/Schema/Result/MediaTag.pm new file mode 100644 index 0000000..c81d603 --- /dev/null +++ b/lib/Pooru/Schema/Result/MediaTag.pm @@ -0,0 +1,99 @@ +use utf8; +package Pooru::Schema::Result::MediaTag; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Pooru::Schema::Result::MediaTag + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("media_tag"); + +=head1 ACCESSORS + +=head2 media_id + + data_type: 'integer' + is_foreign_key: 1 + is_nullable: 0 + +=head2 tag_id + + data_type: 'integer' + is_foreign_key: 1 + is_nullable: 0 + +=cut + +__PACKAGE__->add_columns( + "media_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "tag_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("media_id", "tag_id"); + +=head1 RELATIONS + +=head2 media + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "media", + "Pooru::Schema::Result::Media", + { id => "media_id" }, + { is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" }, +); + +=head2 tag + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "tag", + "Pooru::Schema::Result::Tag", + { id => "tag_id" }, + { is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:vtbD/Q3h2FEwALyG3IAaEg + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/Pooru/Schema/Result/Tag.pm b/lib/Pooru/Schema/Result/Tag.pm new file mode 100644 index 0000000..b18d66e --- /dev/null +++ b/lib/Pooru/Schema/Result/Tag.pm @@ -0,0 +1,157 @@ +use utf8; +package Pooru::Schema::Result::Tag; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Pooru::Schema::Result::Tag + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("tag"); + +=head1 ACCESSORS + +=head2 id + + data_type: 'integer' + is_auto_increment: 1 + is_nullable: 0 + +=head2 name + + data_type: 'text' + is_nullable: 0 + size: 256 + +=head2 kind_id + + data_type: 'text' + is_foreign_key: 1 + is_nullable: 1 + size: 32 + +=head2 display + + data_type: 'text ' + is_nullable: 1 + size: 289 + +=cut + +__PACKAGE__->add_columns( + "id", + { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, + "name", + { data_type => "text", is_nullable => 0, size => 256 }, + "kind_id", + { data_type => "text", is_foreign_key => 1, is_nullable => 1, size => 32 }, + "display", + { data_type => "text ", is_nullable => 1, size => 289 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("id"); + +=head1 UNIQUE CONSTRAINTS + +=head2 C + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->add_unique_constraint("display_unique", ["display"]); + +=head2 C + +=over 4 + +=item * L + +=item * L + +=back + +=cut + +__PACKAGE__->add_unique_constraint("name_kind_id_unique", ["name", "kind_id"]); + +=head1 RELATIONS + +=head2 kind + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "kind", + "Pooru::Schema::Result::Kind", + { id => "kind_id" }, + { + is_deferrable => 0, + join_type => "LEFT", + on_delete => "NO ACTION", + on_update => "CASCADE", + }, +); + +=head2 media_tags + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "media_tags", + "Pooru::Schema::Result::MediaTag", + { "foreign.tag_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + +=head2 medias + +Type: many_to_many + +Composing rels: L -> media + +=cut + +__PACKAGE__->many_to_many("medias", "media_tags", "media"); + + +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:XopZiRzgoNJuweuyFj9elA + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/Pooru/Schema/Result/TagCountView.pm b/lib/Pooru/Schema/Result/TagCountView.pm new file mode 100644 index 0000000..fae0c24 --- /dev/null +++ b/lib/Pooru/Schema/Result/TagCountView.pm @@ -0,0 +1,76 @@ +use utf8; +package Pooru::Schema::Result::TagCountView; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Pooru::Schema::Result::TagCountView + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; +__PACKAGE__->table_class("DBIx::Class::ResultSource::View"); + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("tag_count_view"); + +=head1 ACCESSORS + +=head2 id + + data_type: 'integer' + is_nullable: 1 + +=head2 name + + data_type: 'text' + is_nullable: 1 + size: 256 + +=head2 kind_id + + data_type: 'text' + is_nullable: 1 + size: 32 + +=head2 display + + data_type: 'text' + is_nullable: 1 + size: 289 + +=head2 count + + data_type: (empty string) + is_nullable: 1 + +=cut + +__PACKAGE__->add_columns( + "id", + { data_type => "integer", is_nullable => 1 }, + "name", + { data_type => "text", is_nullable => 1, size => 256 }, + "kind_id", + { data_type => "text", is_nullable => 1, size => 32 }, + "display", + { data_type => "text", is_nullable => 1, size => 289 }, + "count", + { data_type => "", is_nullable => 1 }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Z8o5//l7dE0oSJLzDguHFw + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/Pooru/Schema/Result/TaggedMediaView.pm b/lib/Pooru/Schema/Result/TaggedMediaView.pm new file mode 100644 index 0000000..464dcae --- /dev/null +++ b/lib/Pooru/Schema/Result/TaggedMediaView.pm @@ -0,0 +1,115 @@ +use utf8; +package Pooru::Schema::Result::TaggedMediaView; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Pooru::Schema::Result::TaggedMediaView + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; +__PACKAGE__->table_class("DBIx::Class::ResultSource::View"); + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("tagged_media_view"); + +=head1 ACCESSORS + +=head2 media_id + + data_type: 'integer' + is_nullable: 1 + +=head2 media_storage_id + + data_type: 'text' + is_nullable: 1 + size: 64 + +=head2 media_filename + + data_type: 'text' + is_nullable: 1 + size: 256 + +=head2 media_content_type + + data_type: 'text' + is_nullable: 1 + size: 256 + +=head2 media_upload_datetime + + data_type: 'text' + is_nullable: 1 + size: 32 + +=head2 tag_id + + data_type: 'integer' + is_nullable: 1 + +=head2 tag_name + + data_type: 'text' + is_nullable: 1 + size: 256 + +=head2 tag_kind_id + + data_type: 'text' + is_nullable: 1 + size: 32 + +=head2 tag_display + + data_type: 'text' + is_nullable: 1 + size: 289 + +=head2 tag_count + + data_type: (empty string) + is_nullable: 1 + +=cut + +__PACKAGE__->add_columns( + "media_id", + { data_type => "integer", is_nullable => 1 }, + "media_storage_id", + { data_type => "text", is_nullable => 1, size => 64 }, + "media_filename", + { data_type => "text", is_nullable => 1, size => 256 }, + "media_content_type", + { data_type => "text", is_nullable => 1, size => 256 }, + "media_upload_datetime", + { data_type => "text", is_nullable => 1, size => 32 }, + "tag_id", + { data_type => "integer", is_nullable => 1 }, + "tag_name", + { data_type => "text", is_nullable => 1, size => 256 }, + "tag_kind_id", + { data_type => "text", is_nullable => 1, size => 32 }, + "tag_display", + { data_type => "text", is_nullable => 1, size => 289 }, + "tag_count", + { data_type => "", is_nullable => 1 }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2025-04-26 09:36:47 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:yihpYS6ccFg3BurhyxXqIw + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/Pooru/Site.pm b/lib/Pooru/Site.pm new file mode 100644 index 0000000..6639286 --- /dev/null +++ b/lib/Pooru/Site.pm @@ -0,0 +1,90 @@ +package Pooru::Site; +use Mojo::Base "Mojolicious", -signatures; + +use Pooru::Storage::Static; + +sub extended_pager ($self, $pager) +{ + my ($start, $end); + + if ($pager->{current_page} < $pager->{first_page} + 2) { + $start = $pager->{first_page}; + $end = $start + 4; + } elsif ($pager->{current_page} > $pager->{last_page} - 2) { + $end = $pager->{last_page}; + $start = $end - 4; + } else { + $start = $pager->{current_page} - 2; + $end = $pager->{current_page} + 2; + } + + return grep {$_ >= $pager->{first_page} && $_ <= $pager->{last_page}} + ($start .. $end); +} + +sub link_for_tag ($self, $tag) +{ + my $url = $self->url_for("list_media") + ->query(tags => $tag->{display}); + + defined($tag->{kind_id}) and + my %class = (class => "tag-kind-$tag->{kind_id}"); + + return $self->link_to($tag->{display} => $url => %class); +} + +sub tags_by_kind ($self, $tags) +{ + my @kinds = qw(Author Character Release Tags); + my %kind_id = ( + Author => "a", + Character => "c", + Release => "r", + Tags => "", + ); + + my @tags_by_kind; + for my $kind (@kinds) { + my @ts = grep {($_->{kind_id} // "") eq $kind_id{$kind}} + $tags->@*; + push @tags_by_kind, {kind => $kind, tags => [@ts]}; + } + + return @tags_by_kind; +} + +sub startup ($self) +{ + $self->moniker("pooru-site"); + + my $config = $self->plugin("Config"); + $self->secrets($config->{secrets}); + + $self->helper(storage => sub { + state $storage = Pooru::Storage::Static-> + new($config->{store}, "/s/"); + }); + $self->helper(api_v0_url => sub { + return Mojo::URL->new($self->config("pooru_api")->{v0}); + }); + $self->helper(extended_pager => \&extended_pager); + $self->helper(link_for_tag => \&link_for_tag); + $self->helper(tags_by_kind => \&tags_by_kind); + + $self->ua + ->connect_timeout(5) + ->inactivity_timeout(5) + ->max_redirects(1); + + my $r = $self->routes; + + $r->get("/")->to("media#list")->name("list_media"); + $r->get("/media/")->to("media#show")->name("show_media"); + + $r->get("/tags")->to("tags#list")->name("list_tags"); + + $r->get("/random/media")->to("random#media")->name("random_media"); + $r->get("/random/tag")->to("random#tag")->name("random_tag"); +} + +1; diff --git a/lib/Pooru/Site/Controller/Media.pm b/lib/Pooru/Site/Controller/Media.pm new file mode 100644 index 0000000..a57d9ef --- /dev/null +++ b/lib/Pooru/Site/Controller/Media.pm @@ -0,0 +1,90 @@ +package Pooru::Site::Controller::Media; +use Mojo::Base "Mojolicious::Controller", -signatures; + +sub list ($self) +{ + my $v = $self->validation; + + my $page = $v->optional("page")->num(1, undef)->param // 1; + return $self->render( + status => 400 + ) if $v->has_error; + + my $tags = $v->optional("tags")->param; + return $self->render( + status => 400 + ) if $v->has_error; + + $self->title($tags) if defined($tags) && $tags ne ""; + + $self->render_later; + + my $endpoint = $self->api_v0_url->path("media") + ->query(page => $page, tags => $tags); + $self->ua->get($endpoint, sub ($ua, $tx) { + return $self->render( + text => "Backend error.", + status => 500, + ) if $tx->error; + + my $json = $tx->res->json; + my @media = map +{ + show_media_url => $self->url_for("show_media", + media_id => $_->{id}), + media_src => $self->url_for_file( + $self->storage->get($_->{id})), + }, $json->{media}->@*; + $self->stash(pager => $json->{pager}); + return $self->render( + template => "gallery", + media => [@media], + ); + }); +} + +sub show ($self) +{ + my $v = $self->validation; + + my $page = $v->optional("page")->num(1, undef)->param // 1; + return $self->render( + status => 400 + ) if $v->has_error; + + my $media_id = $self->stash("media_id"); + + $self->render_later; + + my $endpoint = $self->api_v0_url->path("media/$media_id") + ->query(page => $page); + $self->ua->get($endpoint, sub ($ua, $tx) { + return $self->render( + text => "Backend error.", + status => 500, + ) if $tx->error; + + my $json = $tx->res->json; + my @tags = map +{ + name => $_->{name}, + kind_id => $_->{kind_id}, + display => $_->{display}, + count => $_->{count}, + }, $json->{tags}->@*; + + $self->stash(pager => $json->{pager}); + return $self->render( + template => "media", + media => { + id => $json->{id}, + filename => $json->{filename}, + content_type => $json->{content_type}, + upload_datetime => $json->{upload_datetime}, + src => $self->url_for_file( + $self->storage->get($json->{id})), + }, + tags => [@tags], + ); + }); +} + +1; diff --git a/lib/Pooru/Site/Controller/Random.pm b/lib/Pooru/Site/Controller/Random.pm new file mode 100644 index 0000000..acf2b75 --- /dev/null +++ b/lib/Pooru/Site/Controller/Random.pm @@ -0,0 +1,38 @@ +package Pooru::Site::Controller::Random; +use Mojo::Base "Mojolicious::Controller", -signatures; + +sub media ($self) +{ + $self->render_later; + + my $endpoint = $self->api_v0_url->path("random/media"); + $self->ua->get($endpoint, sub ($ua, $tx) { + return $self->render( + text => "Backend error.", + status => 500, + ) if $tx->error; + + return $self->redirect_to("show_media", + media_id => $tx->res->json->{id}); + }); +} + +sub tag ($self) +{ + $self->render_later; + + my $endpoint = $self->api_v0_url->path("random/tag"); + $self->ua->get($endpoint, sub ($ua, $tx) { + return $self->render( + text => "Backend error.", + status => 500, + ) if $tx->error; + + my @tags = $tx->res->json->{tags}->@*; + my $url = $self->url_for("list_media") + ->query(tags => $tags[int(rand(@tags))]->{display}); + return $self->redirect_to($url); + }); +} + +1; diff --git a/lib/Pooru/Site/Controller/Tags.pm b/lib/Pooru/Site/Controller/Tags.pm new file mode 100644 index 0000000..b8e7c1d --- /dev/null +++ b/lib/Pooru/Site/Controller/Tags.pm @@ -0,0 +1,38 @@ +package Pooru::Site::Controller::Tags; +use Mojo::Base "Mojolicious::Controller", -signatures; + +sub list ($self) +{ + my $v = $self->validation; + + my $page = $v->optional("page")->num(1, undef)->param // 1; + return $self->render( + status => 400 + ) if $v->has_error; + + $self->render_later; + + my $endpoint = $self->api_v0_url->path("tags")->query(page => $page); + $self->ua->get($endpoint, sub ($ua, $tx) { + return $self->render( + text => "Backend error.", + status => 500, + ) if $tx->error; + + my $json = $tx->res->json; + my @tags = map +{ + name => $_->{name}, + kind_id => $_->{kind_id}, + display => $_->{display}, + count => $_->{count}, + }, $json->{tags}->@*; + + $self->stash(pager => $json->{pager}); + return $self->render( + template => "tags", + tags => [@tags], + ); + }); +} + +1; diff --git a/lib/Pooru/Storage.pm b/lib/Pooru/Storage.pm new file mode 100644 index 0000000..d8c4162 --- /dev/null +++ b/lib/Pooru/Storage.pm @@ -0,0 +1,17 @@ +package Pooru::Storage; +use v5.40; +use Carp; + +sub new ($class) { + return bless {}, $class; +} + +sub get ($self, $id) +{ + croak "not implemented"; +} + +sub put ($self, $name) +{ + croak "not implemented"; +} diff --git a/lib/Pooru/Storage/Static.pm b/lib/Pooru/Storage/Static.pm new file mode 100644 index 0000000..d07381b --- /dev/null +++ b/lib/Pooru/Storage/Static.pm @@ -0,0 +1,40 @@ +package Pooru::Storage::Static; +use v5.40; +use parent "Pooru::Storage"; +use Storable qw(lock_store lock_retrieve); + +sub new ($class, $file, $prefix = "") +{ + my $self = $class->SUPER::new; + + $self->{_file} = $file; + $self->{_prefix} = $prefix; + if (-f $file) { + $self->{_store} = lock_retrieve($file); + } else { + $self->{_store} = { + _last_id => 0, + _entries => {}, + }; + } + + return $self; +} + +sub get ($self, $id) +{ + my $entry = $self->{_store}->{_entries}->{$id}; + + return (defined($entry) and "$self->{_prefix}$entry->{name}"); +} + +sub put ($self, $name) +{ + my $id = ++$self->{_store}->{_last_id}; + + $self->{_store}->{_entries}->{$id} = { + name => $name, + }; + + return (lock_store($self->{_store}, $self->{_file}) and $id); +} diff --git a/migrations/kind_2_down.sql b/migrations/kind_2_down.sql new file mode 100644 index 0000000..6b19cea --- /dev/null +++ b/migrations/kind_2_down.sql @@ -0,0 +1,3 @@ +DELETE FROM kind WHERE id = 'r'; +DELETE FROM kind WHERE id = 'c'; +DELETE FROM kind WHERE id = 'a'; diff --git a/migrations/kind_2_up.sql b/migrations/kind_2_up.sql new file mode 100644 index 0000000..e54eee7 --- /dev/null +++ b/migrations/kind_2_up.sql @@ -0,0 +1,3 @@ +INSERT INTO kind(id, name) VALUES ('a', 'author'); +INSERT INTO kind(id, name) VALUES ('c', 'character'); +INSERT INTO kind(id, name) VALUES ('r', 'release'); diff --git a/migrations/pooru_1_down.sql b/migrations/pooru_1_down.sql new file mode 100644 index 0000000..a24794e --- /dev/null +++ b/migrations/pooru_1_down.sql @@ -0,0 +1,7 @@ +DROP VIEW IF EXISTS tagged_media_view; +DROP VIEW IF EXISTS tag_count_view; +DROP TABLE IF EXISTS media_tag; +DROP INDEX IF EXISTS tag_display_idx; +DROP TABLE IF EXISTS tag; +DROP TABLE IF EXISTS kind; +DROP TABLE IF EXISTS media; diff --git a/migrations/pooru_1_up.sql b/migrations/pooru_1_up.sql new file mode 100644 index 0000000..029ea8b --- /dev/null +++ b/migrations/pooru_1_up.sql @@ -0,0 +1,57 @@ +PRAGMA journal_mode = WAL; + +CREATE TABLE media( + id INTEGER PRIMARY KEY, + storage_id TEXT (64) NOT NULL UNIQUE, + filename TEXT (256) NOT NULL, + content_type TEXT (256), + upload_datetime TEXT (32) DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE kind( + id TEXT (32) PRIMARY KEY, + name TEXT (256) NOT NULL UNIQUE +) WITHOUT ROWID; + +CREATE TABLE tag( + id INTEGER PRIMARY KEY, + name TEXT (256) NOT NULL, + kind_id TEXT (32) REFERENCES kind(id) ON UPDATE CASCADE, + display TEXT (289) AS (concat_ws(':', kind_id, name)) STORED, + UNIQUE (name, kind_id) +); +CREATE UNIQUE INDEX tag_display_idx ON tag(display); + +CREATE TABLE media_tag( + media_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE, + PRIMARY KEY (media_id, tag_id) +); + +CREATE VIEW tag_count_view AS SELECT + tag.id AS id, + tag.name AS name, + tag.kind_id AS kind_id, + tag.display AS display, + COUNT(media_tag.tag_id) AS count +FROM tag + INNER JOIN media_tag ON media_tag.tag_id = tag.id + LEFT JOIN kind ON kind.id = tag.kind_id +GROUP BY tag.id; + +CREATE VIEW tagged_media_view AS SELECT + media.id AS media_id, + media.storage_id AS media_storage_id, + media.filename AS media_filename, + media.content_type AS media_content_type, + media.upload_datetime AS media_upload_datetime, + tag_count_view.id AS tag_id, + tag_count_view.name AS tag_name, + tag_count_view.kind_id AS tag_kind_id, + tag_count_view.display AS tag_display, + tag_count_view.count AS tag_count +FROM media_tag + INNER JOIN media ON media.id = media_id + INNER JOIN tag_count_view ON tag_count_view.id = tag_id; diff --git a/pooru-api-v0.conf b/pooru-api-v0.conf new file mode 100644 index 0000000..d6710e8 --- /dev/null +++ b/pooru-api-v0.conf @@ -0,0 +1,8 @@ +{ + dsn => "dbi:SQLite:db/pooru.db", + + secrets => [ + # Generate with "openssl rand -hex 32". + pack("H*", "ce360aef6dbbcc5cf5e7d78eb830fe21d00281ef43f8434c9281b823423a2ac8"), + ], +} diff --git a/pooru-site.conf b/pooru-site.conf new file mode 100644 index 0000000..611ce91 --- /dev/null +++ b/pooru-site.conf @@ -0,0 +1,12 @@ +{ + pooru_api => { + v0 => "http://127.0.0.1:3000/api/v0/", + }, + + store => "db/pooru.storable", + + secrets => [ + # Generate with "openssl rand -hex 32". + pack("H*", "a4ccdf6539b6308b9d911bc9d7ab0a209cfc2c192f83606198f1ab0f41d0cdef"), + ], +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..b863c72 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2025 Lucas Gabriel Vuotto + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +:root { + --dark-theme-bg: black; + --dark-theme-text: white; + --dark-theme-accent: royalblue; + --dark-theme-accent-hover: orange; + --dark-theme-tag-kind-a: crimson; + --dark-theme-tag-kind-c: seagreen; + --dark-theme-tag-kind-r: mediumorchid; + + --light-theme-bg: white; + --light-theme-text: black; + --light-theme-accent: royalblue; + --light-theme-accent-hover: orange; + --light-theme-tag-kind-a: crimson; + --light-theme-tag-kind-c: seagreen; + --light-theme-tag-kind-r: mediumorchid; + + --bg: var(--light-theme-bg); + --text: var(--light-theme-text); + --accent: var(--light-theme-accent); + --accent-hover: var(--light-theme-accent-hover); + --tag-kind-a: var(--light-theme-tag-kind-a); + --tag-kind-c: var(--light-theme-tag-kind-c); + --tag-kind-r: var(--light-theme-tag-kind-r); + + --gap: 1.5rem; + --viewport-width: 60rem; + + --border-thin: 0.0625rem solid var(--accent); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +dl, +h1, +h2, +h3, +ol, +p, +ul { + margin: 0; +} + +body { + margin: 0 auto; + min-height: 100vh; + background-color: var(--bg); + color: var(--text); + font-family: monospace; + font-size: 1rem; + line-height: 1.5; +} + +dl, +h1, +h2, +h3, +ol, +p, +ul { + margin-bottom: var(--gap); +} + +h1 { + text-align: center; + font-size: 3rem; + line-height: 2; +} + +h2 { + font-size: 2.5rem; + line-height: 1.2; +} + +h3 { + font-size: 2rem; + line-height: 1.5; +} + +dd { + margin-left: var(--gap); +} + +dt { + font-weight: bold; +} + +ol, +ul { + padding-left: calc(var(--gap) * 2); +} + +a, +a:link, +a:visited { + color: var(--accent); +} + +a:hover, +a:focus, +a:active { + color: var(--accent-hover); +} + +img { + max-width: 100%; + height: auto; +} + +body > header { + border-bottom: var(--border-thin); + font-size: 1.25rem; + line-height: 2.4; +} + +body > header > nav { + gap: 0 var(--gap); + + /* Make the navbar horizontally scrollable. Used for mobile. */ + white-space: nowrap; + overflow-x: auto; +} + +body > header > nav > a { + font-weight: bold; + text-decoration: none; +} + +body > footer { + border-top: var(--border-thin); +} + +body > footer > p { + /* + * Together they add for a single --gap, which is also the vertical rhythm + * unit. + */ + padding-top: calc(var(--gap) / 2); + margin-bottom: calc(var(--gap) / 2); +} + +main { + border-top: var(--border-thin); + border-bottom: var(--border-thin); + margin-top: 0.1875rem; + margin-bottom: 0.1875rem; + padding-top: var(--gap); + padding-bottom: var(--gap); +} + +.text-center { + text-align: center; +} + +.layout-viewport { + margin-left: auto; + margin-right: auto; + width: var(--viewport-width); +} + +.layout-flex-row { + display: flex; + flex-direction: row; +} + +.layout-flex-column { + display: flex; + flex-direction: column; +} + +.layout-flex-item-fullsize { + flex: auto; +} + +.pager > a, +.pager > span { + display: inline-block; + padding-left: 0.5rem; + padding-right: 0.5rem; + border: var(--border-thin); +} + +.pager > a { + text-decoration: none; +} + +.pager > a:hover, +.pager > a:focus { + color: var(--bg); + background-color: var(--accent); +} + +.pager > a:active { + color: var(--bg); + background-color: var(--accent-hover); + border-color: var(--accent-hover); +} + +.pager > span { + border-color: var(--text); +} + +.gallery { + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 1rem; + margin-bottom: var(--gap); +} + +.gallery > a { + flex: 0 1 14rem; + padding: 0.1875rem; + line-height: 0; +} + +.gallery > a:link, +.gallery > a:visited { + border: var(--border-thin); +} + +.gallery > a:hover, +.gallery > a:focus, +.gallery > a:active { + border-color: var(--accent-hover); +} + +.media { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + gap: var(--gap); +} + +.media-metadata { + flex: 1; +} + +.media-item { + flex: 3; + line-height: 0; +} + +a.tag-kind-a:link, +a.tag-kind-a:visited { + color: var(--tag-kind-a); +} + +a.tag-kind-c:link, +a.tag-kind-c:visited { + color: var(--tag-kind-c); +} + +a.tag-kind-r:link, +a.tag-kind-r:visited { + color: var(--tag-kind-r); +} + +a.tag-kind-a:hover, +a.tag-kind-a:focus, +a.tag-kind-a:active, +a.tag-kind-c:hover, +a.tag-kind-c:focus, +a.tag-kind-c:active, +a.tag-kind-r:hover, +a.tag-kind-r:focus, +a.tag-kind-r:active { + color: var(--accent-hover); +} + +@media screen and (max-width: 60rem) { + :root { + --viewport-width: 100%; + } + + body > header > nav, + body > footer, + main { + padding-left: var(--gap); + padding-right: var(--gap); + } + + .media { flex-direction: column-reverse; } + .media-metadata { flex: auto; } + .media-item { flex: auto; } +} + +@media screen and (min-width: 60rem) { + :root { + --viewport-width: 60rem; + } + + body > header > nav, + body > footer, + main { + padding-left: 0; + padding-right: 0; + } + + .media { flex-direction: row; } + .media-metadata { flex: 1; } + .media-item { flex: 3; } +} + +@media screen and (min-width: 90rem) { + :root { + --viewport-width: 90rem; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: var(--dark-theme-bg); + --text: var(--dark-theme-text); + --accent: var(--dark-theme-accent); + --accent-hover: var(--dark-theme-accent-hover); + --tag-kind-author: var(--dark-theme-tag-kind-author); + --tag-kind-character: var(--dark-theme-tag-kind-character); + --tag-kind-release: var(--dark-theme-tag-kind-release); + } +} diff --git a/script/pooru-api-v0 b/script/pooru-api-v0 new file mode 100755 index 0000000..88c0e5f --- /dev/null +++ b/script/pooru-api-v0 @@ -0,0 +1,11 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Mojo::File qw(curfile); +use lib curfile->dirname->sibling("lib")->to_string; +use Mojolicious::Commands; + +# Start command line interface for application +Mojolicious::Commands->start_app("Pooru::API::V0"); diff --git a/script/pooru-site b/script/pooru-site new file mode 100755 index 0000000..06ef3da --- /dev/null +++ b/script/pooru-site @@ -0,0 +1,11 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Mojo::File qw(curfile); +use lib curfile->dirname->sibling("lib")->to_string; +use Mojolicious::Commands; + +# Start command line interface for application +Mojolicious::Commands->start_app("Pooru::Site"); diff --git a/t/basic.t b/t/basic.t new file mode 100644 index 0000000..e24ce5b --- /dev/null +++ b/t/basic.t @@ -0,0 +1,9 @@ +use Mojo::Base -strict; + +use Test::More; +use Test::Mojo; + +my $t = Test::Mojo->new('Pooru::API::V0'); +$t->get_ok('/')->status_is(200)->content_like(qr/Mojolicious/i); + +done_testing(); diff --git a/templates/_pager.html.ep b/templates/_pager.html.ep new file mode 100644 index 0000000..543a540 --- /dev/null +++ b/templates/_pager.html.ep @@ -0,0 +1,24 @@ +% if (my $pager = stash("pager")) { +% my @pages = extended_pager($pager); +
+ +
+% } diff --git a/templates/gallery.html.ep b/templates/gallery.html.ep new file mode 100644 index 0000000..4fb7945 --- /dev/null +++ b/templates/gallery.html.ep @@ -0,0 +1,13 @@ +% layout "main"; + +<%= tag("h1", title) if title %> + + + +%= include "_pager"; diff --git a/templates/layouts/main.html.ep b/templates/layouts/main.html.ep new file mode 100644 index 0000000..55be277 --- /dev/null +++ b/templates/layouts/main.html.ep @@ -0,0 +1,33 @@ + + + + + + <%= join(" - ", "Pooru", title() // ()) %> + <%= stylesheet "/css/style.css" %> + + +
+ +
+
+
+<%= content %> +
+
+ + + diff --git a/templates/media.html.ep b/templates/media.html.ep new file mode 100644 index 0000000..cb5cc76 --- /dev/null +++ b/templates/media.html.ep @@ -0,0 +1,37 @@ +% layout "main"; + +
+ + + +<%= link_to $media->{src} => + class => "layout-flex-item-fullsize media-item" => + begin %> + <%= image $media->{src} %> +<% end %> + +
diff --git a/templates/tags.html.ep b/templates/tags.html.ep new file mode 100644 index 0000000..dd5274c --- /dev/null +++ b/templates/tags.html.ep @@ -0,0 +1,9 @@ +% layout "main"; + +

+% for my $tag ($tags->@*) { + <%= link_for_tag $tag %>(<%= $tag->{count} %>) +% } +

+ +%= include "_pager";