Initial import
This commit is contained in:
commit
dd492db92e
37 changed files with 1953 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
db/
|
||||
s/
|
12
bin/mk-schema.pl
Normal file
12
bin/mk-schema.pl
Normal file
|
@ -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]);
|
12
bin/run.pl
Normal file
12
bin/run.pl
Normal file
|
@ -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;
|
9
cpanfile
Normal file
9
cpanfile
Normal file
|
@ -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";
|
80
lib/Pooru/API/V0.pm
Normal file
80
lib/Pooru/API/V0.pm
Normal file
|
@ -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/<media_id:num>")->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;
|
120
lib/Pooru/API/V0/Controller/Media.pm
Normal file
120
lib/Pooru/API/V0/Controller/Media.pm
Normal file
|
@ -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;
|
22
lib/Pooru/API/V0/Controller/Meta.pm
Normal file
22
lib/Pooru/API/V0/Controller/Meta.pm
Normal file
|
@ -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/<media_id:num>", verb => "GET" },
|
||||
|
||||
{ path => "/tag", verb => "GET" },
|
||||
{ path => "/tags", verb => "GET" },
|
||||
|
||||
{ path => "/random/media", verb => "GET" },
|
||||
{ path => "/random/tag", verb => "GET" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
1;
|
35
lib/Pooru/API/V0/Controller/Random.pm
Normal file
35
lib/Pooru/API/V0/Controller/Random.pm
Normal file
|
@ -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;
|
69
lib/Pooru/API/V0/Controller/Tags.pm
Normal file
69
lib/Pooru/API/V0/Controller/Tags.pm
Normal file
|
@ -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;
|
20
lib/Pooru/Schema.pm
Normal file
20
lib/Pooru/Schema.pm
Normal file
|
@ -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;
|
96
lib/Pooru/Schema/Result/Kind.pm
Normal file
96
lib/Pooru/Schema/Result/Kind.pm
Normal file
|
@ -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<kind>
|
||||
|
||||
=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</id>
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->set_primary_key("id");
|
||||
|
||||
=head1 UNIQUE CONSTRAINTS
|
||||
|
||||
=head2 C<name_unique>
|
||||
|
||||
=over 4
|
||||
|
||||
=item * L</name>
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->add_unique_constraint("name_unique", ["name"]);
|
||||
|
||||
=head1 RELATIONS
|
||||
|
||||
=head2 tags
|
||||
|
||||
Type: has_many
|
||||
|
||||
Related object: L<Pooru::Schema::Result::Tag>
|
||||
|
||||
=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;
|
136
lib/Pooru/Schema/Result/Media.pm
Normal file
136
lib/Pooru/Schema/Result/Media.pm
Normal file
|
@ -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<media>
|
||||
|
||||
=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</id>
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->set_primary_key("id");
|
||||
|
||||
=head1 UNIQUE CONSTRAINTS
|
||||
|
||||
=head2 C<storage_id_unique>
|
||||
|
||||
=over 4
|
||||
|
||||
=item * L</storage_id>
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->add_unique_constraint("storage_id_unique", ["storage_id"]);
|
||||
|
||||
=head1 RELATIONS
|
||||
|
||||
=head2 media_tags
|
||||
|
||||
Type: has_many
|
||||
|
||||
Related object: L<Pooru::Schema::Result::MediaTag>
|
||||
|
||||
=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</media_tags> -> 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;
|
99
lib/Pooru/Schema/Result/MediaTag.pm
Normal file
99
lib/Pooru/Schema/Result/MediaTag.pm
Normal file
|
@ -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<media_tag>
|
||||
|
||||
=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</media_id>
|
||||
|
||||
=item * L</tag_id>
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->set_primary_key("media_id", "tag_id");
|
||||
|
||||
=head1 RELATIONS
|
||||
|
||||
=head2 media
|
||||
|
||||
Type: belongs_to
|
||||
|
||||
Related object: L<Pooru::Schema::Result::Media>
|
||||
|
||||
=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<Pooru::Schema::Result::Tag>
|
||||
|
||||
=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;
|
157
lib/Pooru/Schema/Result/Tag.pm
Normal file
157
lib/Pooru/Schema/Result/Tag.pm
Normal file
|
@ -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<tag>
|
||||
|
||||
=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</id>
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->set_primary_key("id");
|
||||
|
||||
=head1 UNIQUE CONSTRAINTS
|
||||
|
||||
=head2 C<display_unique>
|
||||
|
||||
=over 4
|
||||
|
||||
=item * L</display>
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->add_unique_constraint("display_unique", ["display"]);
|
||||
|
||||
=head2 C<name_kind_id_unique>
|
||||
|
||||
=over 4
|
||||
|
||||
=item * L</name>
|
||||
|
||||
=item * L</kind_id>
|
||||
|
||||
=back
|
||||
|
||||
=cut
|
||||
|
||||
__PACKAGE__->add_unique_constraint("name_kind_id_unique", ["name", "kind_id"]);
|
||||
|
||||
=head1 RELATIONS
|
||||
|
||||
=head2 kind
|
||||
|
||||
Type: belongs_to
|
||||
|
||||
Related object: L<Pooru::Schema::Result::Kind>
|
||||
|
||||
=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<Pooru::Schema::Result::MediaTag>
|
||||
|
||||
=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_tags> -> 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;
|
76
lib/Pooru/Schema/Result/TagCountView.pm
Normal file
76
lib/Pooru/Schema/Result/TagCountView.pm
Normal file
|
@ -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<tag_count_view>
|
||||
|
||||
=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;
|
115
lib/Pooru/Schema/Result/TaggedMediaView.pm
Normal file
115
lib/Pooru/Schema/Result/TaggedMediaView.pm
Normal file
|
@ -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<tagged_media_view>
|
||||
|
||||
=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;
|
90
lib/Pooru/Site.pm
Normal file
90
lib/Pooru/Site.pm
Normal file
|
@ -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/<media_id:num>")->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;
|
90
lib/Pooru/Site/Controller/Media.pm
Normal file
90
lib/Pooru/Site/Controller/Media.pm
Normal file
|
@ -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;
|
38
lib/Pooru/Site/Controller/Random.pm
Normal file
38
lib/Pooru/Site/Controller/Random.pm
Normal file
|
@ -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;
|
38
lib/Pooru/Site/Controller/Tags.pm
Normal file
38
lib/Pooru/Site/Controller/Tags.pm
Normal file
|
@ -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;
|
17
lib/Pooru/Storage.pm
Normal file
17
lib/Pooru/Storage.pm
Normal file
|
@ -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";
|
||||
}
|
40
lib/Pooru/Storage/Static.pm
Normal file
40
lib/Pooru/Storage/Static.pm
Normal file
|
@ -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);
|
||||
}
|
3
migrations/kind_2_down.sql
Normal file
3
migrations/kind_2_down.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
DELETE FROM kind WHERE id = 'r';
|
||||
DELETE FROM kind WHERE id = 'c';
|
||||
DELETE FROM kind WHERE id = 'a';
|
3
migrations/kind_2_up.sql
Normal file
3
migrations/kind_2_up.sql
Normal file
|
@ -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');
|
7
migrations/pooru_1_down.sql
Normal file
7
migrations/pooru_1_down.sql
Normal file
|
@ -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;
|
57
migrations/pooru_1_up.sql
Normal file
57
migrations/pooru_1_up.sql
Normal file
|
@ -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;
|
8
pooru-api-v0.conf
Normal file
8
pooru-api-v0.conf
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
dsn => "dbi:SQLite:db/pooru.db",
|
||||
|
||||
secrets => [
|
||||
# Generate with "openssl rand -hex 32".
|
||||
pack("H*", "ce360aef6dbbcc5cf5e7d78eb830fe21d00281ef43f8434c9281b823423a2ac8"),
|
||||
],
|
||||
}
|
12
pooru-site.conf
Normal file
12
pooru-site.conf
Normal file
|
@ -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"),
|
||||
],
|
||||
}
|
343
public/css/style.css
Normal file
343
public/css/style.css
Normal file
|
@ -0,0 +1,343 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Lucas Gabriel Vuotto <lucas@lgv5.net>
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
11
script/pooru-api-v0
Executable file
11
script/pooru-api-v0
Executable file
|
@ -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");
|
11
script/pooru-site
Executable file
11
script/pooru-site
Executable file
|
@ -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");
|
9
t/basic.t
Normal file
9
t/basic.t
Normal file
|
@ -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();
|
24
templates/_pager.html.ep
Normal file
24
templates/_pager.html.ep
Normal file
|
@ -0,0 +1,24 @@
|
|||
% if (my $pager = stash("pager")) {
|
||||
% my @pages = extended_pager($pager);
|
||||
<footer>
|
||||
<nav class="text-center pager">
|
||||
<%= link_to "<<" => url_with->query({page => $pager->{first_page}})
|
||||
if $pager->{first_page} < $pages[0] %>
|
||||
<%= link_to "<" => url_with->query({page => $pager->{previous_page}})
|
||||
if defined($pager->{previous_page}) &&
|
||||
$pager->{previous_page} > 3 %>
|
||||
% for my $page (@pages) {
|
||||
% if ($page == $pager->{current_page}) {
|
||||
<span><%= $pager->{current_page} %></span>
|
||||
% } else {
|
||||
<%= link_to $page => url_with->query({page => $page}) %>
|
||||
% }
|
||||
% }
|
||||
<%= link_to ">" => url_with->query({page => $pager->{next_page}})
|
||||
if defined($pager->{next_page}) &&
|
||||
$pager->{next_page} < $pager->{last_page} - 2 %>
|
||||
<%= link_to ">>" => url_with->query({page => $pager->{last_page}})
|
||||
if $pager->{last_page} > $pages[-1] %>
|
||||
</nav>
|
||||
</footer>
|
||||
% }
|
13
templates/gallery.html.ep
Normal file
13
templates/gallery.html.ep
Normal file
|
@ -0,0 +1,13 @@
|
|||
% layout "main";
|
||||
|
||||
<%= tag("h1", title) if title %>
|
||||
|
||||
<div class="layout-flex-row gallery">
|
||||
% for my $m ($media->@*) {
|
||||
<%= link_to $m->{show_media_url} => begin %>
|
||||
<%= image $m->{media_src} %>
|
||||
<%= end %>
|
||||
% }
|
||||
</div>
|
||||
|
||||
%= include "_pager";
|
33
templates/layouts/main.html.ep
Normal file
33
templates/layouts/main.html.ep
Normal file
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
|
||||
<title><%= join(" - ", "Pooru", title() // ()) %></title>
|
||||
<%= stylesheet "/css/style.css" %>
|
||||
</head>
|
||||
<body class="layout-flex-column">
|
||||
<header>
|
||||
<nav class="layout-viewport layout-flex-row">
|
||||
<%= link_to "Pooru~" => "/" %>
|
||||
<%= link_to "tags" => url_for("list_tags") %>
|
||||
<%= link_to "random tag" => url_for("random_tag") %>
|
||||
<%= link_to "random media" => url_for("random_media") %>
|
||||
<span class="layout-flex-item-fullsize"><!-- spacer --></span>
|
||||
<%= link_to "login" => "/login" %>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="layout-flex-item-fullsize layout-flex-column">
|
||||
<div class="layout-viewport layout-flex-item-fullsize layout-flex-column">
|
||||
<%= content %>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<p class="layout-viewport text-center">
|
||||
Powered by <a href="//www.openbsd.org/">OpenBSD</a> /
|
||||
<a href="//www.mojolicious.org/">Mojolicious</a> /
|
||||
<a href="//www.haproxy.org/">HAProxy</a>.
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
37
templates/media.html.ep
Normal file
37
templates/media.html.ep
Normal file
|
@ -0,0 +1,37 @@
|
|||
% layout "main";
|
||||
|
||||
<div class="layout-flex-box media">
|
||||
|
||||
<div class="media-metadata">
|
||||
% if ($tags->@* == 0) {
|
||||
<p>Non tags yet. >_<</p>
|
||||
% } else {
|
||||
<dl>
|
||||
% for my $entry (tags_by_kind $tags) {
|
||||
% next if $entry->{tags}->@* == 0;
|
||||
<dt><%= $entry->{kind} %></dt>
|
||||
% for my $tag ($entry->{tags}->@*) {
|
||||
<dd><%= link_for_tag $tag %> <%= $tag->{count} %></dd>
|
||||
% }
|
||||
% }
|
||||
</dl>
|
||||
% }
|
||||
<dl>
|
||||
<dt>ID</dt>
|
||||
<dd><%= $media->{id} %></dd>
|
||||
<dt>Original filename</dt>
|
||||
<dd><%= $media->{filename} %></dd>
|
||||
<dt>Content-type</dt>
|
||||
<dd><%= $media->{content_type} %></dd>
|
||||
<dt>Upload date</dt>
|
||||
<dd><%= $media->{upload_datetime} %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<%= link_to $media->{src} =>
|
||||
class => "layout-flex-item-fullsize media-item" =>
|
||||
begin %>
|
||||
<%= image $media->{src} %>
|
||||
<% end %>
|
||||
|
||||
</div>
|
9
templates/tags.html.ep
Normal file
9
templates/tags.html.ep
Normal file
|
@ -0,0 +1,9 @@
|
|||
% layout "main";
|
||||
|
||||
<p class="text-center">
|
||||
% for my $tag ($tags->@*) {
|
||||
<%= link_for_tag $tag %>(<%= $tag->{count} %>)
|
||||
% }
|
||||
</p>
|
||||
|
||||
%= include "_pager";
|
Loading…
Add table
Add a link
Reference in a new issue