From 1ff831f80df628adbe819c4564e2e4c906155a7c Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 18 Feb 2023 09:49:05 +0000 Subject: [PATCH] Initial import --- .dancer | 0 bin/app.psgi | 15 ++ bin/create-dbix-class-schemas | 62 ++++++++ config.yml | 68 +++++++++ cpanfile | 34 +++++ environments/development.yml | 22 +++ environments/production.yml | 13 ++ lib/PoorBooru.pm | 37 +++++ lib/PoorBooru/API/V0.pm | 113 ++++++++++++++ lib/PoorBooru/Schema.pm | 20 +++ lib/PoorBooru/Schema/Result/Media.pm | 108 +++++++++++++ lib/PoorBooru/Schema/Result/MediaTag.pm | 99 ++++++++++++ lib/PoorBooru/Schema/Result/Tag.pm | 106 +++++++++++++ lib/PoorBooru/Schema/Result/TagsCountView.pm | 60 ++++++++ public/css/style.css | 150 +++++++++++++++++++ t/001_base.t | 5 + t/002_index_route.t | 16 ++ views/index.tt | 1 + views/layouts/main.tt | 27 ++++ 19 files changed, 956 insertions(+) create mode 100644 .dancer create mode 100755 bin/app.psgi create mode 100755 bin/create-dbix-class-schemas create mode 100644 config.yml create mode 100644 cpanfile create mode 100644 environments/development.yml create mode 100644 environments/production.yml create mode 100644 lib/PoorBooru.pm create mode 100644 lib/PoorBooru/API/V0.pm create mode 100644 lib/PoorBooru/Schema.pm create mode 100644 lib/PoorBooru/Schema/Result/Media.pm create mode 100644 lib/PoorBooru/Schema/Result/MediaTag.pm create mode 100644 lib/PoorBooru/Schema/Result/Tag.pm create mode 100644 lib/PoorBooru/Schema/Result/TagsCountView.pm create mode 100644 public/css/style.css create mode 100644 t/001_base.t create mode 100644 t/002_index_route.t create mode 100644 views/index.tt create mode 100644 views/layouts/main.tt diff --git a/.dancer b/.dancer new file mode 100644 index 0000000..e69de29 diff --git a/bin/app.psgi b/bin/app.psgi new file mode 100755 index 0000000..bbfc804 --- /dev/null +++ b/bin/app.psgi @@ -0,0 +1,15 @@ +#!/usr/bin/env perl + +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../lib"; + +use PoorBooru; +use PoorBooru::API::V0; +use Plack::Builder; + +builder { + mount "/" => PoorBooru->to_app; + mount "/api/v0" => PoorBooru::API::V0->to_app; +}; diff --git a/bin/create-dbix-class-schemas b/bin/create-dbix-class-schemas new file mode 100755 index 0000000..d04d9b3 --- /dev/null +++ b/bin/create-dbix-class-schemas @@ -0,0 +1,62 @@ +#!/bin/sh +err() +{ + printf "%s: %s\n" "${0##*/}" "$*" >&2 + exit 1 +} + +warn() +{ + printf "%s: %s\n" "${0##*/}" "$*" >&2 +} + +check_required_programs() +{ + _rc=0 + for _prog; do + if ! command -v "$_prog" >/dev/null; then + _rc=1 + warn "$_prog: not found" + fi + done + return $_rc; +} + +set -eu + +check_required_programs sqlite3 dbicdump || exit 1 + +sqlite3 "db/PoorBooru.db" <<'_SQL' +PRAGMA foreig_keys = ON; + +CREATE TABLE IF NOT EXISTS media( + media_id INTEGER PRIMARY KEY, + content BLOB (10485760) NOT NULL, + filename TEXT (255) NOT NULL, + content_type TEXT (255) +); + +CREATE TABLE IF NOT EXISTS tags( + tag_id INTEGER PRIMARY KEY, + name TEXT (255) NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS media_tags( + media_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY (media_id) REFERENCES media (media_id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags (tag_id) ON DELETE CASCADE, + PRIMARY KEY(media_id, tag_id) +); + +CREATE VIEW IF NOT EXISTS tags_count_view AS SELECT + tags.tag_id AS tag_id, + tags.name AS name, + COUNT(media_tags.media_id) AS count +FROM tags + INNER JOIN media_tags USING (tag_id) + GROUP BY tag_id; + +_SQL + +dbicdump -o dump_directory=./lib PoorBooru::Schema dbi:SQLite:db/PoorBooru.db diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..f8cc123 --- /dev/null +++ b/config.yml @@ -0,0 +1,68 @@ +# This is the main configuration file of your Dancer2 app +# env-related settings should go to environments/$env.yml +# all the settings in this file will be loaded at Dancer's startup. + +# === Basic configuration === + +# Your application's name +appname: "PoorBooru" + +# The default layout to use for your application (located in +# views/layouts/main.tt) +layout: "main" + +# when the charset is set to UTF-8 Dancer2 will handle for you +# all the magic of encoding and decoding. You should not care +# about unicode within your app when this setting is set (recommended). +charset: "UTF-8" + +# === Engines === +# +# NOTE: All the engine configurations need to be under a single "engines:" +# key. If you uncomment engine configurations below, make sure to delete +# all "engines:" lines except the first. Otherwise, only the last +# "engines:" block will take effect. + +# template engine +# simple: default and very basic template engine +# template_toolkit: TT + +template: "tiny" + +# template: "template_toolkit" +# engines: +# template: +# template_toolkit: +# # Note: start_tag and end_tag are regexes +# start_tag: '<%' +# end_tag: '%>' + +# session engine +# +# Simple: in-memory session store - Dancer2::Session::Simple +# YAML: session stored in YAML files - Dancer2::Session::YAML +# +# Check out metacpan for other session storage options: +# https://metacpan.org/search?q=Dancer2%3A%3ASession&search_type=modules +# +# Default value for 'cookie_name' is 'dancer.session'. If you run multiple +# Dancer apps on the same host then you will need to make sure 'cookie_name' +# is different for each app. +# +#engines: +# session: +# Simple: +# cookie_name: testapp.session +# +#engines: +# session: +# YAML: +# cookie_name: eshop.session +# is_secure: 1 +# is_http_only: 1 + +plugins: + DBIC: + default: + dsn: "dbi:SQLite:dbname=db/PoorBooru.db" + schema_class: PoorBooru::Schema diff --git a/cpanfile b/cpanfile new file mode 100644 index 0000000..52876da --- /dev/null +++ b/cpanfile @@ -0,0 +1,34 @@ +requires "Dancer2" => "0.400000"; + +recommends "YAML" => "0"; +recommends "URL::Encode::XS" => "0"; +recommends "CGI::Deurl::XS" => "0"; +recommends "CBOR::XS" => "0"; +recommends "YAML::XS" => "0"; +recommends "Class::XSAccessor" => "0"; +recommends "Crypt::URandom" => "0"; +recommends "HTTP::XSCookies" => "0"; +recommends "HTTP::XSHeaders" => "0"; +recommends "Math::Random::ISAAC::XS" => "0"; +recommends "MooX::TypeTiny" => "0"; +recommends "Type::Tiny::XS" => "0"; + +feature 'accelerate', 'Accelerate Dancer2 app performance with XS modules' => sub { + requires "URL::Encode::XS" => "0"; + requires "CGI::Deurl::XS" => "0"; + requires "YAML::XS" => "0"; + requires "Class::XSAccessor" => "0"; + requires "Cpanel::JSON::XS" => "0"; + requires "Crypt::URandom" => "0"; + requires "HTTP::XSCookies" => "0"; + requires "HTTP::XSHeaders" => "0"; + requires "Math::Random::ISAAC::XS" => "0"; + requires "MooX::TypeTiny" => "0"; + requires "Type::Tiny::XS" => "0"; +}; + +on "test" => sub { + requires "Test::More" => "0"; + requires "HTTP::Request::Common" => "0"; +}; + diff --git a/environments/development.yml b/environments/development.yml new file mode 100644 index 0000000..04d65e7 --- /dev/null +++ b/environments/development.yml @@ -0,0 +1,22 @@ +# configuration file for development environment + +# the logger engine to use +# console: log messages to STDOUT (your console where you started the +# application server) +# file: log message to a file in log/ +logger: "console" + +# the log level for this environment +# core is the lowest, it shows Dancer2's core log messages as well as yours +# (debug, info, warning and error) +log: "core" + +# should Dancer2 show a stacktrace when an 5xx error is caught? +# if set to yes, public/500.html will be ignored and either +# views/500.tt, 'error_template' template, or a default error template will be used. +show_errors: 1 + +# print the banner +startup_info: 1 + +poorbooru_api: "http://localhost:8080/api/v0" diff --git a/environments/production.yml b/environments/production.yml new file mode 100644 index 0000000..d86c30c --- /dev/null +++ b/environments/production.yml @@ -0,0 +1,13 @@ +# configuration file for production environment + +# only log warning and error messsages +log: "warning" + +# log message to a file in logs/ +logger: "file" + +# hide errors +show_errors: 0 + +# disable server tokens in production environments +no_server_tokens: 1 diff --git a/lib/PoorBooru.pm b/lib/PoorBooru.pm new file mode 100644 index 0000000..4df6219 --- /dev/null +++ b/lib/PoorBooru.pm @@ -0,0 +1,37 @@ +package PoorBooru; +use Dancer2; +use HTTP::Tiny; + +our $VERSION = v0.1; + +my $POORBOORU_API = setting("poorbooru_api"); + +hook before_template_render => sub { + my $tokens = shift; + + $tokens->{uris}->{root} = uri_for("/"); + $tokens->{uris}->{login} = uri_for("/login"); + $tokens->{uris}->{logout} = uri_for("/logout"); + $tokens->{uris}->{random} = uri_for("/random"); + $tokens->{uris}->{tags} = uri_for("/tags"); +}; + +get "/" => sub { + template "index" => { + "title" => "main", + }; +}; + +get "/tags" => sub { +}; + +get "/tag/:tag_id" => sub { +}; + +get "/random" => sub { +}; + +get "/image/:image_id" => sub { +}; + +true; diff --git a/lib/PoorBooru/API/V0.pm b/lib/PoorBooru/API/V0.pm new file mode 100644 index 0000000..c18e403 --- /dev/null +++ b/lib/PoorBooru/API/V0.pm @@ -0,0 +1,113 @@ +package PoorBooru::API::V0; +use Dancer2; +use Dancer2::Plugin::DBIC; + +our $VERSION = v0; + +set serializer => "JSON"; +set database => "db/booru.db"; + +my $DEFAULT_CONTENT_TYPE = "application/json"; +my @ROUTES = ( + { path => "/meta", verb => "GET" }, + { path => "/tags", verb => "GET" }, + { path => "/tags/new", verb => "POST" }, + { path => "/tag/:tag_id_or_name", verb => "GET" }, + { path => "/random", verb => "GET" }, + { path => "/media/:media_id", verb => "GET" }, + { path => "/download/:media_id", verb => "GET" }, +); + +get "/meta" => sub { + return \@ROUTES; +}; + +get "/tags" => sub { + my @tags = schema("default")->resultset("TagsCountView")->all; + + return [ + map +( { + id => $_->tag_id, + name => $_->name, + count => $_->count, + } ), @tags, + ]; +}; + +post "/tags/new" => sub { + my $tag; + + eval { + $tag = schema("default")->resultset("Tag") + ->create({ name => body_parameters->get("name") }); + } or send_error("Tag exists", 409); + + return { + id => $tag->tag_id, + name => $tag->name, + }; +}; + +get "/tag/:tag_id_or_name" => sub { + my ($tag, $tag_rset, @media); + my $tag_id_or_name = route_parameters->get("tag_id_or_name"); + + $tag_rset = schema("default")->resultset("Tag"); + $tag = $tag_rset->single({ tag_id => $tag_id_or_name }) // + $tag_rset->single({ name => $tag_id_or_name }); + + send_error("Tag not found", 404) if !defined($tag); + + @media = map { $_->media_id } schema("default")->resultset("MediaTag") + ->search({ tag_id => $tag->tag_id })->all; + + return { + id => $tag->tag_id, + name => $tag->name, + media => [ + map +( { + id => $_, + uri => uri_for("/media/$_"), + donwload_uri => uri_for("/download/$_"), + } ), @media, + ], + }; +}; + +get "/random" => sub { + my $media = schema("default")->resultset("Media") + ->search({}, { order_by => \"random()", limit => 1 })->single; + + send_error("Media not found", 404) if !defined($media); + + forward "/media/" . $media->media_id; +}; + +get "/media/:media_id" => sub { + my $media = schema("default")->resultset("Media") + ->single({ media_id => route_parameters->get("media_id") }); + + send_error("Media not found", 404) if !defined($media); + + return { + id => $media->media_id, + filename => $media->filename, + size => length($media->content), + download_uri => uri_for("/download/" . $media->media_id), + }; +}; + +get "/download/:media_id" => sub { + my $media = schema("default")->resultset("Media") + ->single({ media_id => route_parameters->get("media_id") }); + + send_error("Media not found", 404) if !defined($media); + + send_file( + \$media->content, + content_type => $media->content_type // $DEFAULT_CONTENT_TYPE, + filename => $media->filename, + ); +}; + +true; diff --git a/lib/PoorBooru/Schema.pm b/lib/PoorBooru/Schema.pm new file mode 100644 index 0000000..b324f03 --- /dev/null +++ b/lib/PoorBooru/Schema.pm @@ -0,0 +1,20 @@ +use utf8; +package PoorBooru::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.07049 @ 2023-02-18 09:09:31 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:0PabNwBpp04P3y4a8O5MtA + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/PoorBooru/Schema/Result/Media.pm b/lib/PoorBooru/Schema/Result/Media.pm new file mode 100644 index 0000000..94c7a02 --- /dev/null +++ b/lib/PoorBooru/Schema/Result/Media.pm @@ -0,0 +1,108 @@ +use utf8; +package PoorBooru::Schema::Result::Media; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +PoorBooru::Schema::Result::Media + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("media"); + +=head1 ACCESSORS + +=head2 media_id + + data_type: 'integer' + is_auto_increment: 1 + is_nullable: 0 + +=head2 content + + data_type: 'blob' + is_nullable: 0 + size: 10485760 + +=head2 filename + + data_type: 'text' + is_nullable: 0 + size: 255 + +=head2 content_type + + data_type: 'text' + is_nullable: 1 + size: 255 + +=cut + +__PACKAGE__->add_columns( + "media_id", + { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, + "content", + { data_type => "blob", is_nullable => 0, size => 10485760 }, + "filename", + { data_type => "text", is_nullable => 0, size => 255 }, + "content_type", + { data_type => "text", is_nullable => 1, size => 255 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("media_id"); + +=head1 RELATIONS + +=head2 media_tags + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "media_tags", + "PoorBooru::Schema::Result::MediaTag", + { "foreign.media_id" => "self.media_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.07049 @ 2023-02-18 09:09:31 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:X0A6CSw6yWOYvxhMNRoS0g + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/PoorBooru/Schema/Result/MediaTag.pm b/lib/PoorBooru/Schema/Result/MediaTag.pm new file mode 100644 index 0000000..f42b9af --- /dev/null +++ b/lib/PoorBooru/Schema/Result/MediaTag.pm @@ -0,0 +1,99 @@ +use utf8; +package PoorBooru::Schema::Result::MediaTag; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +PoorBooru::Schema::Result::MediaTag + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("media_tags"); + +=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", + "PoorBooru::Schema::Result::Media", + { 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", + "PoorBooru::Schema::Result::Tag", + { tag_id => "tag_id" }, + { is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07049 @ 2023-02-18 09:09:31 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:CaXiI8+eaCoDbot368Hjyw + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/PoorBooru/Schema/Result/Tag.pm b/lib/PoorBooru/Schema/Result/Tag.pm new file mode 100644 index 0000000..e199a19 --- /dev/null +++ b/lib/PoorBooru/Schema/Result/Tag.pm @@ -0,0 +1,106 @@ +use utf8; +package PoorBooru::Schema::Result::Tag; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +PoorBooru::Schema::Result::Tag + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("tags"); + +=head1 ACCESSORS + +=head2 tag_id + + data_type: 'integer' + is_auto_increment: 1 + is_nullable: 0 + +=head2 name + + data_type: 'text' + is_nullable: 0 + size: 255 + +=cut + +__PACKAGE__->add_columns( + "tag_id", + { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, + "name", + { data_type => "text", is_nullable => 0, size => 255 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("tag_id"); + +=head1 UNIQUE CONSTRAINTS + +=head2 C + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->add_unique_constraint("name_unique", ["name"]); + +=head1 RELATIONS + +=head2 media_tags + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "media_tags", + "PoorBooru::Schema::Result::MediaTag", + { "foreign.tag_id" => "self.tag_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.07049 @ 2023-02-18 09:09:31 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:tlbIFVg6S6LAWWiR0ioHFw + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/lib/PoorBooru/Schema/Result/TagsCountView.pm b/lib/PoorBooru/Schema/Result/TagsCountView.pm new file mode 100644 index 0000000..376465b --- /dev/null +++ b/lib/PoorBooru/Schema/Result/TagsCountView.pm @@ -0,0 +1,60 @@ +use utf8; +package PoorBooru::Schema::Result::TagsCountView; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +PoorBooru::Schema::Result::TagsCountView + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; +__PACKAGE__->table_class("DBIx::Class::ResultSource::View"); + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("tags_count_view"); + +=head1 ACCESSORS + +=head2 tag_id + + data_type: 'integer' + is_nullable: 1 + +=head2 name + + data_type: 'text' + is_nullable: 1 + size: 255 + +=head2 count + + data_type: (empty string) + is_nullable: 1 + +=cut + +__PACKAGE__->add_columns( + "tag_id", + { data_type => "integer", is_nullable => 1 }, + "name", + { data_type => "text", is_nullable => 1, size => 255 }, + "count", + { data_type => "", is_nullable => 1 }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07049 @ 2023-02-18 09:09:31 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:9NUzqweyXVu/G84fxb3eew + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..c5e4d42 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,150 @@ +/* + * PoorBooru - Poorman's booru + * + * Written in 2023 by Lucas + * + * To the extent possible under law, the author(s) have dedicated all + * copyright and related and neighboring rights to this software to the + * public domain worldwide. This software is distributed without any + * warranty. + * + * You should have received a copy of the CC0 Public Domain Dedication + * along with this software. If not, see + * . + */ +/* + * Some parts copied from https://piccalil.li/blog/a-modern-css-reset/ + */ + +:root { + --bg-color: #0c0700; + --fg-color: #fff8f0; + --accent-color-0: #ec57bc; + --accent-color-1: #f7be00; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body, +h1, +p { + margin: 0; +} + +body { + margin: 0 auto; + min-height: 100vh; + background-color: var(--bg-color); + color: var(--fg-color); + font-family: monospace; + line-height: 1.5; +} + +h1, +p { + margin-bottom: 1.5rem; +} + +footer > p { + margin-bottom: 0; +} + +h1 { + text-align: center; + font-size: 4rem; + margin-bottom: 1rem; +} + +a { + color: var(--accent-color-0); +} + +a:link, +a:visited { + color: var(--accent-color-0); +} + +a:hover, +a:active { + color: var(--accent-color-1); +} + +nav { + font-size: 1.25rem; + line-height: 2.4; +} + +.viewport { + margin: 0 auto; + max-width: 60rem; +} + +.border-bottom { + border-bottom: 1px solid; +} + +.border-top { + border-top: 1px solid; +} + +.border-accent { + border-color: var(--accent-color-0); +} + +.text-center { + text-align: center; +} + +.bg-color { + background-color: var(--bg-color); +} + +.fg-color { + color: var(--fg-color); +} + +.fg-accent { + color: var(--accent-color-0); +} + +.nav-link { + font-weight: bold; + text-decoration: none; +} + +.nav-link:link, +.nav-link:visited { + color: var(--accent-color-0); +} + +.nav-link:hover, +.nav-link:active { + color: var(--accent-color-1); +} + +.nav-link-gap { + gap: 0 1rem; +} + +.flex-c-horizontal { + display: flex; +} + +.flex-c-vertical { + display: flex; + flex-direction: column; +} + +.flex-c-center { + display: flex; + justify-content: center; + align-items: center; +} + +.flex-i-fullsize { + flex: auto; +} diff --git a/t/001_base.t b/t/001_base.t new file mode 100644 index 0000000..ca62cd0 --- /dev/null +++ b/t/001_base.t @@ -0,0 +1,5 @@ +use strict; +use warnings; + +use Test::More tests => 1; +use_ok 'PoorBooru'; diff --git a/t/002_index_route.t b/t/002_index_route.t new file mode 100644 index 0000000..247200f --- /dev/null +++ b/t/002_index_route.t @@ -0,0 +1,16 @@ +use strict; +use warnings; + +use PoorBooru; +use Test::More tests => 2; +use Plack::Test; +use HTTP::Request::Common; +use Ref::Util qw; + +my $app = PoorBooru->to_app; +ok( is_coderef($app), 'Got app' ); + +my $test = Plack::Test->create($app); +my $res = $test->request( GET '/' ); + +ok( $res->is_success, '[GET /] successful' ); diff --git a/views/index.tt b/views/index.tt new file mode 100644 index 0000000..cd1d1e6 --- /dev/null +++ b/views/index.tt @@ -0,0 +1 @@ +

No content

diff --git a/views/layouts/main.tt b/views/layouts/main.tt new file mode 100644 index 0000000..7148d58 --- /dev/null +++ b/views/layouts/main.tt @@ -0,0 +1,27 @@ + + + + + + [% settings.appname %] - [% title %] + + + +
+ +
+ +
+[% content -%] +
+ + +