Version 5.1 - All the GraphQL #32

Closed
timw4mail wants to merge 1160 commits from develop into master
547 changed files with 67164 additions and 5355 deletions

20
.editorconfig Normal file
View File

@ -0,0 +1,20 @@
# EditorConfig is awesome: http://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = false
charset = utf-8
indent_style = tab
trim_trailing_whitespace = true
[*.{cpp,c,h,hpp,cxx}]
insert_final_newline = true
# Yaml files
[*.{yml,yaml}]
indent_style = space
indent_size = 4

157
.gitignore vendored
View File

@ -1,10 +1,157 @@
# Created by https://www.gitignore.io/api/macos,jetbrains+all
### JetBrains+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### JetBrains+all Patch ###
# Ignores the whole .idea folder and all .iml files
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
.idea/
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
*.iml
modules.xml
.idea/misc.xml
*.ipr
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# End of https://www.gitignore.io/api/macos,jetbrains+all
.codelite
.phing_targets
.sonar/
*.phprj
*.workspace
vendor
app/cache/*
public/images/*
public/js/cache/*
**/cache/**
**/logs/**
**/coverage/**
**/docs/**
**/node_modules/**
composer.lock
*.sqlite
*.db
*.sqlite3
docs/*
coverage/*
apidocs/**
tests/test_data/sessions/*
cache.properties
build/**
!build/*.txt
!build/*.xml
!build/*.php
app/config/*.toml
!app/config/*.toml.example
phinx.yml
Caddyfile
build/humbuglog.txt
public/images/anime/**
public/images/avatars/**
public/images/manga/**
public/images/characters/**
public/images/people/**
public/mal_mappings.json
.phpunit.result.cache
.is-dev
tmp
tools/vendor/
tools/phinx/vendor/
/.php-cs-fixer.php
/.php-cs-fixer.cache

6
.htaccess Normal file
View File

@ -0,0 +1,6 @@
#Rewrite index.php out of the app urls
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [L]

534
.php-cs-fixer.dist.php Normal file
View File

@ -0,0 +1,534 @@
<?php declare(strict_types=1);
use Nexus\CsConfig\Factory;
use PhpCsFixer\{Config, Finder};
$finder = Finder::create()
->in([
__DIR__ . '/src',
__DIR__ . '/tests',
__DIR__ . '/tools',
])
->exclude([
'vendor',
]);
return (new Config())
->setRiskyAllowed(TRUE)
->setFinder($finder)
->setIndent(' ')
->setRules([
'align_multiline_comment' => false,
'array_indentation' => true,
'array_push' => true,
'array_syntax' => ['syntax' => 'short'],
'assign_null_coalescing_to_coalesce_equal' => true,
'backtick_to_shell_exec' => true,
'binary_operator_spaces' => [
'default' => 'single_space',
'operators' => [
'=' => NULL,
'&' => NULL,
]
],
'blank_line_after_namespace' => true,
'blank_line_after_opening_tag' => false,
'blank_line_before_statement' => [
'statements' => [
// 'case',
'continue',
'declare',
'default',
'do',
'exit',
'for',
'foreach',
'goto',
'return',
'switch',
'throw',
'try',
'while',
'yield',
'yield_from',
],
],
// 'braces' => [
// 'allow_single_line_anonymous_class_with_empty_body' => true,
// 'allow_single_line_closure' => true,
// 'position_after_anonymous_constructs' => 'same',
// 'position_after_control_structures' => 'next',
// 'position_after_functions_and_oop_constructs' => 'next',
// ],
'cast_spaces' => ['space' => 'single'],
'class_attributes_separation' => [
'elements' => [
'const' => 'none',
'property' => 'none',
'method' => 'one',
'trait_import' => 'none',
],
],
'class_definition' => [
'multi_line_extends_each_single_line' => true,
'single_item_single_line' => true,
'single_line' => true,
'space_before_parenthesis' => true,
],
'class_reference_name_casing' => true,
'clean_namespace' => true,
'combine_consecutive_issets' => true,
'combine_consecutive_unsets' => true,
'combine_nested_dirname' => true,
'comment_to_phpdoc' => [
'ignored_tags' => [
'todo',
'codeCoverageIgnore',
'codeCoverageIgnoreStart',
'codeCoverageIgnoreEnd',
'phpstan-ignore-line',
'phpstan-ignore-next-line',
],
],
'compact_nullable_typehint' => true,
'concat_space' => ['spacing' => 'one'],
'constant_case' => ['case' => 'upper'],
'control_structure_braces' => true,
'control_structure_continuation_position' => ['position' => 'next_line'],
'curly_braces_position' => [
'allow_single_line_anonymous_functions' => true,
'allow_single_line_empty_anonymous_classes' => true,
'anonymous_functions_opening_brace' => 'same_line',
'classes_opening_brace' => 'next_line_unless_newline_at_signature_end',
'control_structures_opening_brace' => 'next_line_unless_newline_at_signature_end',
'functions_opening_brace' => 'next_line_unless_newline_at_signature_end',
],
'date_time_immutable' => false,
'declare_equal_normalize' => ['space' => 'none'],
'declare_parentheses' => true,
'declare_strict_types' => true,
'dir_constant' => true,
'doctrine_annotation_array_assignment' => false,
'doctrine_annotation_braces' => false,
'doctrine_annotation_indentation' => false,
'doctrine_annotation_spaces' => false,
'echo_tag_syntax' => [
'format' => 'short',
'long_function' => 'echo',
'shorten_simple_statements_only' => false,
],
'elseif' => false,
'empty_loop_body' => ['style' => 'braces'],
'empty_loop_condition' => ['style' => 'while'],
'encoding' => true,
'error_suppression' => [
'mute_deprecation_error' => true,
'noise_remaining_usages' => false,
'noise_remaining_usages_exclude' => [],
],
'escape_implicit_backslashes' => [
'double_quoted' => false,
'heredoc_syntax' => false,
'single_quoted' => false,
],
'explicit_indirect_variable' => false,
'explicit_string_variable' => false,
'final_class' => false,
'final_internal_class' => [
'annotation_exclude' => ['@no-final'],
'annotation_include' => ['@internal'],
'consider_absent_docblock_as_internal_class' => false,
],
'final_public_method_for_abstract_class' => false,
'fopen_flag_order' => true,
'fopen_flags' => ['b_mode' => true],
'full_opening_tag' => true,
'fully_qualified_strict_types' => true,
'function_declaration' => ['closure_function_spacing' => 'one'],
'function_to_constant' => [
'functions' => [
'get_called_class',
'get_class',
'get_class_this',
'php_sapi_name',
'phpversion',
'pi',
],
],
'function_typehint_space' => true,
'general_phpdoc_annotation_remove' => false,
'general_phpdoc_tag_rename' => false,
'get_class_to_class_keyword' => false,
'global_namespace_import' => [
'import_constants' => true,
'import_functions' => true,
'import_classes' => true,
],
'group_import' => true,
'header_comment' => false, // false by default
// 'heredoc_indentation' => ['indentation' => 'start_plus_one'],
'heredoc_to_nowdoc' => true,
'implode_call' => true,
'include' => true,
'increment_style' => ['style' => 'post'],
'indentation_type' => true,
'integer_literal_case' => true,
'is_null' => true,
'lambda_not_used_import' => true,
'line_ending' => true,
'linebreak_after_opening_tag' => false,
'list_syntax' => ['syntax' => 'short'],
'logical_operators' => true,
'lowercase_cast' => true,
'lowercase_keywords' => true,
'lowercase_static_reference' => true,
'magic_constant_casing' => true,
'magic_method_casing' => true,
'mb_str_functions' => false,
'method_argument_space' => [
'after_heredoc' => false,
'keep_multiple_spaces_after_comma' => false,
'on_multiline' => 'ensure_fully_multiline',
],
'method_chaining_indentation' => true,
'modernize_strpos' => false, // requires 8.0+
'modernize_types_casting' => true,
'multiline_comment_opening_closing' => true,
'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'],
'native_constant_invocation' => false,
'native_function_casing' => true,
'native_function_invocation' => false,
'native_function_type_declaration_casing' => true,
'new_with_braces' => true,
'no_alias_functions' => ['sets' => ['@all']],
'no_alias_language_construct_call' => true,
'no_alternative_syntax' => ['fix_non_monolithic_code' => false],
'no_binary_string' => true,
'no_blank_lines_after_class_opening' => true,
'no_blank_lines_after_phpdoc' => true,
'no_blank_lines_before_namespace' => false, // conflicts with `single_blank_line_before_namespace`
'no_break_comment' => ['comment_text' => 'no break'],
'no_closing_tag' => true,
'no_empty_comment' => true,
'no_empty_phpdoc' => true,
'no_empty_statement' => true,
'no_extra_blank_lines' => ['tokens' => ['extra']],
'no_homoglyph_names' => true,
'no_leading_import_slash' => true,
'no_leading_namespace_whitespace' => true,
'no_mixed_echo_print' => ['use' => 'echo'],
'no_multiline_whitespace_around_double_arrow' => true,
'no_null_property_initialization' => true,
'no_short_bool_cast' => true,
'no_singleline_whitespace_before_semicolons' => true,
'no_space_around_double_colon' => true,
'no_spaces_after_function_name' => true,
'no_spaces_around_offset' => ['positions' => ['inside', 'outside']],
'no_spaces_inside_parenthesis' => true,
'no_superfluous_elseif' => true,
'no_superfluous_phpdoc_tags' => [
'allow_mixed' => true,
'allow_unused_params' => true,
'remove_inheritdoc' => false,
],
'no_trailing_comma_in_singleline' => true,
'no_trailing_whitespace' => true,
'no_trailing_whitespace_in_comment' => true,
'no_trailing_whitespace_in_string' => true,
'no_unneeded_control_parentheses' => [
'statements' => [
'break',
'clone',
'continue',
'echo_print',
'return',
'switch_case',
'yield',
],
],
'no_unneeded_curly_braces' => ['namespaces' => true],
'no_unneeded_final_method' => ['private_methods' => true],
'no_unneeded_import_alias' => true,
'no_unreachable_default_argument_value' => true,
'no_unset_cast' => true,
'no_unset_on_property' => false,
'no_unused_imports' => true,
'no_useless_else' => true,
'no_useless_return' => true,
'no_useless_sprintf' => true,
'no_whitespace_before_comma_in_array' => ['after_heredoc' => true],
'no_whitespace_in_blank_line' => true,
'non_printable_character' => ['use_escape_sequences_in_strings' => true],
'normalize_index_brace' => true,
'not_operator_with_space' => true,
'not_operator_with_successor_space' => true,
'nullable_type_declaration_for_default_null_value' => ['use_nullable_type_declaration' => true],
'object_operator_without_whitespace' => true,
'operator_linebreak' => ['only_booleans' => true, 'position' => 'beginning'],
'ordered_class_elements' => [
'order' => [
'use_trait',
'case',
'constant_public',
'constant_protected',
'constant_private',
'property_public',
'property_protected',
'property_private',
'construct',
'destruct',
'magic',
],
'sort_algorithm' => 'none',
],
'ordered_imports' => [
'sort_algorithm' => 'alpha',
'imports_order' => ['class', 'function', 'const'],
],
'ordered_interfaces' => false,
'ordered_traits' => false,
'php_unit_construct' => [
'assertions' => [
'assertSame',
'assertEquals',
'assertNotEquals',
'assertNotSame',
],
],
'php_unit_dedicate_assert' => ['target' => 'newest'],
'php_unit_dedicate_assert_internal_type' => ['target' => 'newest'],
'php_unit_expectation' => ['target' => 'newest'],
'php_unit_fqcn_annotation' => true,
'php_unit_internal_class' => ['types' => ['final']],
'php_unit_method_casing' => ['case' => 'camel_case'],
'php_unit_mock' => ['target' => 'newest'],
'php_unit_mock_short_will_return' => true,
'php_unit_namespaced' => ['target' => 'newest'],
'php_unit_no_expectation_annotation' => [
'target' => 'newest',
'use_class_const' => true,
],
'php_unit_set_up_tear_down_visibility' => true,
'php_unit_size_class' => false,
// 'php_unit_strict' => [
// 'assertions' => [
// 'assertAttributeEquals',
// 'assertAttributeNotEquals',
// 'assertEquals',
// 'assertNotEquals',
// ],
// ],
'php_unit_test_annotation' => ['style' => 'prefix'],
'php_unit_test_case_static_method_calls' => [
'call_type' => 'this',
'methods' => [],
],
'php_unit_test_class_requires_covers' => false,
'phpdoc_add_missing_param_annotation' => ['only_untyped' => true],
'phpdoc_align' => [
'align' => 'left'
],
'phpdoc_annotation_without_dot' => false,
'phpdoc_indent' => true,
'phpdoc_inline_tag_normalizer' => [
'tags' => [
'example',
'id',
'internal',
'inheritdoc',
'inheritdocs',
'link',
'source',
'toc',
'tutorial',
],
],
'phpdoc_line_span' => [
'const' => 'multi',
'method' => 'multi',
'property' => 'multi',
],
'phpdoc_no_access' => true,
'phpdoc_no_empty_return' => false,
'phpdoc_no_package' => false,
'phpdoc_no_useless_inheritdoc' => true,
'phpdoc_order' => true,
'phpdoc_order_by_value' => [
'annotations' => [
'author',
'covers',
'coversNothing',
'dataProvider',
'depends',
'group',
'internal',
'method',
'property',
'property-read',
'property-write',
'requires',
'throws',
'uses',
],
],
'phpdoc_return_self_reference' => [
'replacements' => [
'this' => '$this',
'@this' => '$this',
'$self' => 'self',
'@self' => 'self',
'$static' => 'static',
'@static' => 'static',
],
],
'phpdoc_scalar' => [
'types' => [
'boolean',
'callback',
'double',
'integer',
'real',
'str',
],
],
'phpdoc_separation' => false,
'phpdoc_single_line_var_spacing' => true,
'phpdoc_summary' => false,
'phpdoc_tag_casing' => ['tags' => ['inheritDoc']],
'phpdoc_tag_type' => ['tags' => ['inheritDoc' => 'inline']],
'phpdoc_to_comment' => false,
'phpdoc_to_param_type' => false,
'phpdoc_to_property_type' => false,
'phpdoc_to_return_type' => false,
'phpdoc_trim' => true,
'phpdoc_trim_consecutive_blank_line_separation' => true,
'phpdoc_types' => ['groups' => ['simple', 'alias', 'meta']],
'phpdoc_types_order' => [
'null_adjustment' => 'always_last',
'sort_algorithm' => 'alpha',
],
'phpdoc_var_annotation_correct_order' => true,
'phpdoc_var_without_name' => true,
'pow_to_exponentiation' => true,
'protected_to_private' => true,
'psr_autoloading' => ['dir' => null],
'random_api_migration' => [
'replacements' => [
'getrandmax' => 'mt_getrandmax',
'rand' => 'mt_rand',
'srand' => 'mt_srand',
],
],
'regular_callable_call' => true,
'return_assignment' => true,
'return_type_declaration' => ['space_before' => 'none'],
'self_accessor' => false,
'self_static_accessor' => true,
'semicolon_after_instruction' => false,
'set_type_to_cast' => true,
'short_scalar_cast' => true,
'simple_to_complex_string_variable' => true,
'simplified_if_return' => true,
'simplified_null_return' => false,
'single_blank_line_at_eof' => true,
'single_blank_line_before_namespace' => true,
'single_class_element_per_statement' => ['elements' => ['const', 'property']],
'single_import_per_statement' => false,
'single_line_after_imports' => true,
'single_line_comment_style' => ['comment_types' => ['asterisk', 'hash']],
'single_line_throw' => false,
'single_quote' => ['strings_containing_single_quote_chars' => false],
'single_space_around_construct' => [
'constructs_followed_by_a_single_space' => [
'abstract',
'as',
'attribute',
'break',
'case',
'catch',
'class',
'clone',
'comment',
'const',
'const_import',
'continue',
'do',
'echo',
'else',
'elseif',
'extends',
'final',
'finally',
'for',
'foreach',
'function',
'function_import',
'global',
'goto',
'if',
'implements',
'include',
'include_once',
'instanceof',
'insteadof',
'interface',
'match',
'named_argument',
'new',
'open_tag_with_echo',
'php_doc',
'php_open',
'print',
'private',
'protected',
'public',
'require',
'require_once',
'return',
'static',
'throw',
'trait',
'try',
'use',
'use_lambda',
'use_trait',
'var',
'while',
'yield',
'yield_from',
],
],
'single_trait_insert_per_statement' => true,
'space_after_semicolon' => ['remove_in_empty_for_expressions' => true],
'standardize_increment' => true,
'standardize_not_equals' => true,
'statement_indentation' => true,
'static_lambda' => true,
'strict_comparison' => true,
'strict_param' => true,
'string_length_to_empty' => true,
'string_line_ending' => true,
'switch_case_semicolon_to_colon' => true,
'switch_case_space' => true,
'switch_continue_to_break' => true,
'ternary_operator_spaces' => true,
'ternary_to_elvis_operator' => true,
'ternary_to_null_coalescing' => true,
'trailing_comma_in_multiline' => [
'after_heredoc' => true,
'elements' => ['arrays'],
],
'trim_array_spaces' => true,
'types_spaces' => ['space' => 'none'],
'unary_operator_spaces' => false,
'use_arrow_functions' => true,
'visibility_required' => ['elements' => ['const', 'method', 'property']],
'void_return' => false, // changes method signature
'whitespace_after_comma_in_array' => true,
'yoda_style' => [
'equal' => false,
'identical' => null,
'less_and_greater' => false,
'always_move_variable' => false,
],
]);

View File

@ -1,20 +1,17 @@
language: php
install:
- composer install
- composer install --ignore-platform-reqs
php:
- 5.4
- 5.5
- 5.6
- 7
- hhvm
- 8.0
- 8.1
- nightly
script:
- mkdir -p build/logs
- phpunit --coverage-clover=coverage.clover
- php vendor/bin/phpunit -c build
after_script:
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover coverage.clover
#matrix:
# allow_failures:
# - php: nightly

51
CHANGELOG.md Normal file
View File

@ -0,0 +1,51 @@
# Changelog
## Version 5.3
* Update PHP requirement to 8.2
## Version 5.2
* Updated PHP requirement to 8.1
* Updated to support PHP 8.2
* Improve Anilist <-> Kitsu mappings to be more reliable
## Version 5.1
* Added session check, so when coming back to a page, if the session is expired, the page will refresh.
* Updated logging config so that much fewer, much smaller files are generated.
* Updated Kitsu integration to use GraphQL API, reducing a lot of internal complexity.
## Version 5
* Updated PHP requirement to 7.4
* Added anime watching history view
* Added manga reading history view
* Updated anime collection to have more media types
## Version 4.2
* Updated dependencies
* Updated PHP requirement to 7.3
* Added option to automatically set dark mode based on the OS setting
## Version 4.1
* Added optional dark theme
* Removed MAL integration, added Anilist Integration
* Now uses WebP cache images when the browser supports it
* Replaces JS minifier with pre-minified scripts (Removes the need for one caching folder, too)
* Updated console command to sync Kitsu and Anilist data (Kitsu can sync MAL, and MAL's API broke, so MAL sync was removed)
* Added page to update settings without having to edit config files
* Defaulted to secure (HTTPS) urls
* Updated Character pages to show voice actors
* Added People pages, showing which works they contributed to, and in what role
## Version 4
* Updated to use Kitsu API after discontinuation of Hummingbird
* Added streaming links to list entries from the Kitsu API
* Added simple integration with MyAnimeList, so an update can cross-post to both Kitsu and MyAnimeList (anime and manga)
* Added console command to sync Kitsu and MyAnimeList data
* Added character pages
## Version 3
* Converted user configuration to toml files
* Added a caching layer for api calls, which resets upon updates from the
app.
* Added a bulk thumbnail generator script
* Removed json file "cache" from the app folder

52
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,52 @@
pipeline {
agent none
stages {
stage('setup') {
agent any
steps {
sh 'curl -sS https://getcomposer.org/installer | php'
sh 'rm -rf ./vendor'
sh 'rm -f composer.lock'
sh 'php composer.phar install --ignore-platform-reqs'
}
}
stage('PHP 8') {
agent {
docker {
image 'php:8-cli-alpine'
args '-u root --privileged'
}
}
steps {
sh 'apk add --no-cache git icu-dev'
sh 'docker-php-ext-configure intl && docker-php-ext-install intl'
sh 'php ./vendor/bin/phpunit --colors=never'
}
}
stage('Latest PHP') {
agent {
docker {
image 'php:cli-alpine'
args '-u root --privileged'
}
}
steps {
sh 'apk add --no-cache git icu-dev'
sh 'docker-php-ext-configure intl && docker-php-ext-install intl'
sh 'php ./vendor/bin/phpunit --colors=never'
}
}
stage('Coverage') {
agent any
steps {
sh 'php composer.phar run-script coverage'
step([
$class: 'CloverPublisher',
cloverReportDir: '',
cloverReportFileName: 'build/logs/clover.xml',
])
junit 'build/logs/junit.xml'
}
}
}
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017 Timothy J Warren
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,10 +1,11 @@
# Hummingbird Anime Client
A self-hosted client that allows custom formatting of data from the hummingbird api
Update your anime/manga list on Kitsu.io and Anilist
[![Build Status](https://travis-ci.org/timw4mail/HummingBirdAnimeClient.svg)](https://travis-ci.org/timw4mail/HummingBirdAnimeClient)
[![Build Status](https://travis-ci.com/timw4mail/HummingBirdAnimeClient.svg?branch=master)](https://travis-ci.com/github/timw4mail/HummingBirdAnimeClient)
[![Build Status](https://jenkins.timshome.page/buildStatus/icon?job=timw4mail/HummingBirdAnimeClient/develop)](https://jenkins.timshome.page/job/timw4mail/job/HummingBirdAnimeClient/job/develop/)
[[Hosted Example](https://anime.timshomepage.net)]
[[Hosted Example](https://list.timshomepage.net)]
## Features
@ -14,7 +15,7 @@ A self-hosted client that allows custom formatting of data from the hummingbird
* On Hold
* Dropped
* Completed
* All of the above
* Combined View
* Manga List views (Each with list and cover views):
* Reading
@ -22,7 +23,7 @@ A self-hosted client that allows custom formatting of data from the hummingbird
* On Hold
* Dropped
* Completed
* All of the above
* Combined View
* Anime collection view (segmented by media type):
* Cover Images
@ -30,26 +31,36 @@ A self-hosted client that allows custom formatting of data from the hummingbird
### Requirements
* PHP 5.4+
* PDO SQLite (For collection tab)
* GD
* PHP 8.2
* ext-dom (For editing the DOM)
* ext-gd (For caching images)
* ext-intl (For time localization)
* ext-json
* ext-mbstring
* ext-pdo
### Highly Recommended
* Redis or Memcached for caching
* PDO SQLite or PDO PostgreSQL (For collection tab)
### Installation
1. Install dependencies via composer: `composer install`
2. Change the `WHOSE` constant declaration in `index.php` to your name
3. Configure settings in `app/config/config.php` to your liking
1. Install via git, then install dependencies via composer: `composer install`
2. Duplicate `app/config/config.toml.example` file as `app/config/config.toml`
3. Configure settings in `app/config/config.toml` to your liking
4. Create the following directories if they don't exist, and make sure they are world writable
* app/cache
* public/images/manga
* app/config
* app/logs
* public/images/avatars
* public/images/anime
* public/js/cache
* public/images/characters
* public/images/manga
5. Make sure the `console` script is executable
6. Additional settings are on the settings page once you log in.
#### Anime Collection Additional Installation
* Run `php /vendor/bin/phinx migrate -e development` to create the database tables
* For importing anime:
1. Find the anime you are looking for on the hummingbird search api page: `https://hummingbird.me/api/v1/search/anime?query=`
2. Create an `import.json` file in the root of the app, with an array of objects from the search page that you want to import
3. Go to the anime collection tab, and the import will be run
### Server Setup
See the [wiki](https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient/wiki)
for more in-depth information

View File

@ -0,0 +1,60 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 8
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2021 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5.2
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
use function Aviat\AnimeClient\loadConfig;
// ----------------------------------------------------------------------------
// Lower level configuration
//
// You shouldn't generally need to change anything below this line
// ----------------------------------------------------------------------------
$APP_DIR = dirname(__DIR__);
$ROOT_DIR = dirname($APP_DIR);
$tomlConfig = loadConfig(__DIR__);
return array_merge($tomlConfig, [
'root' => $ROOT_DIR,
'asset_dir' => "{$ROOT_DIR}/public",
'base_config_dir' => __DIR__,
'config_dir' => "{$APP_DIR}/config",
// No config defaults
'kitsu_username' => 'timw4mail',
'whose_list' => 'Someone',
'cache' => [
'connection' => [],
'driver' => 'null',
],
'secure_urls' => TRUE,
// Routing defaults
'asset_path' => '/public',
'default_list' => 'anime', //anime|manga
'default_anime_list_path' => 'watching', // watching|plan_to_watch|on_hold|dropped|completed|all
'default_manga_list_path' => 'reading', // reading|plan_to_read|on_hold|dropped|completed|all
'default_view_type' => 'cover_view', // cover_view|list_view
// Template file path
'view_path' => "{$APP_DIR}/views",
// Cache paths
'data_cache_path' => "{$APP_DIR}/cache",
'img_cache_path' => "{$ROOT_DIR}/public/images",
// Included config files
'routes' => require 'routes.php',
]);

21
app/appConf/menus.toml Normal file
View File

@ -0,0 +1,21 @@
[anime_list]
route_prefix = ""
[anime_list.items]
watch_history = '/history/anime'
watching = '/anime/watching'
plan_to_watch = '/anime/plan_to_watch'
on_hold = '/anime/on_hold'
dropped = '/anime/dropped'
completed = '/anime/completed'
all = '/anime/all'
[manga_list]
route_prefix = ""
[manga_list.items]
reading_history = '/history/manga'
reading = '/manga/reading'
plan_to_read = '/manga/plan_to_read'
on_hold = '/manga/on_hold'
dropped = '/manga/dropped'
completed = '/manga/completed'
all = '/manga/all'

329
app/appConf/routes.php Normal file
View File

@ -0,0 +1,329 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 8
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2021 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5.2
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
use const Aviat\AnimeClient\{
ALPHA_SLUG_PATTERN,
DEFAULT_CONTROLLER,
DEFAULT_CONTROLLER_METHOD,
NUM_PATTERN,
SLUG_PATTERN,
SLUG_SPACE_PATTERN,
};
// -------------------------------------------------------------------------
// Routing Config
//
// Maps paths to controllers and methods
// -------------------------------------------------------------------------
$routes = [
// ---------------------------------------------------------------------
// AJAX Routes
// ---------------------------------------------------------------------
'cache_purge' => [
'path' => '/cache_purge',
'action' => 'clearCache',
],
'heartbeat' => [
'path' => '/heartbeat',
'action' => 'heartbeat',
],
// ---------------------------------------------------------------------
// Anime List Routes
// ---------------------------------------------------------------------
'anime.add.get' => [
'path' => '/anime/add',
'action' => 'addForm',
],
'anime.add.post' => [
'path' => '/anime/add',
'action' => 'add',
'verb' => 'post',
],
'anime.random' => [
'path' => '/anime/details/random',
'action' => 'random',
],
'anime.details' => [
'path' => '/anime/details/{id}',
'action' => 'details',
'tokens' => [
'id' => SLUG_PATTERN,
],
],
'anime.delete' => [
'path' => '/anime/delete',
'action' => 'delete',
'verb' => 'post',
],
// ---------------------------------------------------------------------
// Manga Routes
// ---------------------------------------------------------------------
'manga.search' => [
'path' => '/manga/search',
'action' => 'search',
],
'manga.add.get' => [
'path' => '/manga/add',
'action' => 'addForm',
],
'manga.add.post' => [
'path' => '/manga/add',
'action' => 'add',
'verb' => 'post',
],
'manga.delete' => [
'path' => '/manga/delete',
'action' => 'delete',
'verb' => 'post',
],
'manga.random' => [
'path' => '/manga/details/random',
'action' => 'random',
],
'manga.details' => [
'path' => '/manga/details/{id}',
'action' => 'details',
'tokens' => [
'id' => SLUG_PATTERN,
],
],
// ---------------------------------------------------------------------
// Anime Collection Routes
// ---------------------------------------------------------------------
'anime.collection.search' => [
'path' => '/anime-collection/search',
'action' => 'search',
],
'anime.collection.add.get' => [
'path' => '/anime-collection/add',
'action' => 'form',
],
'anime.collection.edit.get' => [
'path' => '/anime-collection/edit/{id}',
'action' => 'form',
'tokens' => [
'id' => NUM_PATTERN,
],
],
'anime.collection.add.post' => [
'path' => '/anime-collection/add',
'action' => 'add',
'verb' => 'post',
],
'anime.collection.edit.post' => [
'path' => '/anime-collection/edit',
'action' => 'edit',
'verb' => 'post',
],
'anime.collection.view' => [
'path' => '/anime-collection/view{/view}',
'action' => 'view',
'tokens' => [
'view' => ALPHA_SLUG_PATTERN,
],
],
'anime.collection.delete' => [
'path' => '/anime-collection/delete',
'action' => 'delete',
'verb' => 'post',
],
'anime.collection.redirect' => [
'path' => '/anime-collection',
],
'anime.collection.redirect2' => [
'path' => '/anime-collection/',
],
// ---------------------------------------------------------------------
// Manga Collection Routes
// ---------------------------------------------------------------------
'manga.collection.search' => [
'path' => '/manga-collection/search',
'action' => 'search',
],
'manga.collection.add.get' => [
'path' => '/manga-collection/add',
'action' => 'form',
],
'manga.collection.edit.get' => [
'path' => '/manga-collection/edit/{id}',
'action' => 'form',
'tokens' => [
'id' => NUM_PATTERN,
],
],
'manga.collection.add.post' => [
'path' => '/manga-collection/add',
'action' => 'add',
'verb' => 'post',
],
'manga.collection.edit.post' => [
'path' => '/manga-collection/edit',
'action' => 'edit',
'verb' => 'post',
],
'manga.collection.view' => [
'path' => '/manga-collection/view{/view}',
'tokens' => [
'view' => ALPHA_SLUG_PATTERN,
],
],
'manga.collection.delete' => [
'path' => '/manga-collection/delete',
'action' => 'delete',
'verb' => 'post',
],
// ---------------------------------------------------------------------
// Other Routes
// ---------------------------------------------------------------------
'character' => [
'path' => '/character/{slug}',
'tokens' => [
'slug' => SLUG_PATTERN,
],
],
'person' => [
'path' => '/people/{slug}',
'tokens' => [
'slug' => SLUG_PATTERN,
],
],
'default_user_info' => [
'path' => '/me',
'action' => 'me',
'controller' => 'user',
],
'user_info' => [
'path' => '/user/{username}',
'controller' => 'user',
'action' => 'about',
'tokens' => [
'username' => '.*?',
],
],
// ---------------------------------------------------------------------
// Default / Shared routes
// ---------------------------------------------------------------------
'anilist-redirect' => [
'path' => '/anilist-redirect',
'action' => 'anilistRedirect',
'controller' => 'settings',
],
'anilist-callback' => [
'path' => '/anilist-oauth',
'action' => 'anilistCallback',
'controller' => 'settings',
],
'image_proxy' => [
'path' => '/public/images/{type}/{file}',
'action' => 'cache',
'controller' => 'images',
'tokens' => [
'type' => SLUG_PATTERN,
'file' => '[a-z0-9\-]+\.[a-z]{3,4}',
],
],
'settings' => [
'path' => '/settings',
],
'settings-post' => [
'path' => '/settings/update',
'action' => 'update',
'verb' => 'post',
],
'login' => [
'path' => '/login',
'action' => 'login',
],
'login.post' => [
'path' => '/login',
'action' => 'loginAction',
'verb' => 'post',
],
'logout' => [
'path' => '/logout',
'action' => 'logout',
],
'history' => [
'controller' => 'history',
'path' => '/history/{type}',
'tokens' => [
'type' => SLUG_PATTERN,
],
],
'increment' => [
'path' => '/{controller}/increment',
'action' => 'increment',
'verb' => 'post',
'tokens' => [
'controller' => ALPHA_SLUG_PATTERN,
],
],
'update' => [
'path' => '/{controller}/update',
'action' => 'update',
'verb' => 'post',
'tokens' => [
'controller' => ALPHA_SLUG_PATTERN,
],
],
'update.post' => [
'path' => '/{controller}/update_form',
'action' => 'formUpdate',
'verb' => 'post',
'tokens' => [
'controller' => ALPHA_SLUG_PATTERN,
],
],
'edit' => [
'path' => '/{controller}/edit/{id}/{status}',
'action' => 'edit',
'tokens' => [
'id' => SLUG_PATTERN,
'status' => SLUG_SPACE_PATTERN,
],
],
'list' => [
'path' => '/{controller}/{status}{/view}',
'tokens' => [
'status' => ALPHA_SLUG_PATTERN,
'view' => ALPHA_SLUG_PATTERN,
],
],
'index_redirect' => [
'path' => '/',
'action' => 'redirectToDefaultRoute',
],
];
$defaultMap = [
'action' => DEFAULT_CONTROLLER_METHOD,
'controller' => DEFAULT_CONTROLLER,
'params' => [],
'verb' => 'get',
];
foreach ($routes as &$route)
{
foreach ($defaultMap as $key => $val)
{
if ( ! array_key_exists($key, $route))
{
$route[$key] = $val;
}
}
}
return $routes;

View File

@ -1,81 +0,0 @@
<?php
/**
* Base API Model
*/
namespace AnimeClient;
use \GuzzleHttp\Client;
use \GuzzleHttp\Cookie\CookieJar;
/**
* Base model for api interaction
*/
class BaseApiModel extends BaseModel {
/**
* Base url for making api requests
* @var string
*/
protected $base_url = '';
/**
* The Guzzle http client object
* @var object
*/
protected $client;
/**
* Cookie jar object for api requests
* @var object
*/
protected $cookieJar;
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
$this->cookieJar = new CookieJar();
$this->client = new Client([
'base_url' => $this->base_url,
'defaults' => [
'cookies' => $this->cookieJar,
'headers' => [
'User-Agent' => $_SERVER['HTTP_USER_AGENT'],
'Accept-Encoding' => 'application/json'
],
'timeout' => 5,
'connect_timeout' => 5
]
]);
}
/**
* Attempt login via the api
*
* @codeCoverageIgnore
* @param string $username
* @param string $password
* @return bool
*/
public function authenticate($username, $password)
{
$result = $this->client->post('https://hummingbird.me/api/v1/users/authenticate', [
'body' => [
'username' => $username,
'password' => $password
]
]);
if ($result->getStatusCode() === 201)
{
$_SESSION['hummingbird_anime_token'] = $result->json();
return TRUE;
}
return FALSE;
}
}
// End of BaseApiModel.php

View File

@ -1,254 +0,0 @@
<?php
/**
* Base Controller
*/
namespace AnimeClient;
use Aura\Web\WebFactory;
/**
* Base class for controllers, defines output methods
*/
class BaseController {
/**
* The global configuration object
* @var object $config
*/
protected $config;
/**
* Request object
* @var object $request
*/
protected $request;
/**
* Response object
* @var object $response
*/
protected $response;
/**
* The api model for the current controller
* @var object
*/
protected $model;
/**
* Common data to be sent to views
* @var array
*/
protected $base_data = [];
/**
* Constructor
*/
public function __construct(Config $config, Array $web)
{
$this->config = $config;
list($request, $response) = $web;
$this->request = $request;
$this->response = $response;
}
public function __destruct()
{
$this->output();
}
/**
* Get the string output of a partial template
*
* @param string $template
* @param array|object $data
* @return string
*/
public function load_partial($template, $data=[])
{
if (isset($this->base_data))
{
$data = array_merge($this->base_data, $data);
}
global $router, $defaultHandler;
$route = $router->get_route();
$data['route_path'] = ($route) ? $router->get_route()->path : "";
$defaultHandler->addDataTable('Template Data', $data);
$template_path = _dir(APP_DIR, 'views', "{$template}.php");
if ( ! is_file($template_path))
{
throw new Exception("Invalid template : {$path}");
}
ob_start();
extract($data);
include _dir(APP_DIR, 'views', 'header.php');
include $template_path;
include _dir(APP_DIR, 'views', 'footer.php');
$buffer = ob_get_contents();
ob_end_clean();
return $buffer;
}
/**
* Output a template to HTML, using the provided data
*
* @param string $template
* @param array|object $data
* @return void
*/
public function outputHTML($template, $data=[])
{
$buffer = $this->load_partial($template, $data);
$this->response->content->setType('text/html');
$this->response->content->set($buffer);
}
/**
* Output json with the proper content type
*
* @param mixed $data
* @return void
*/
public function outputJSON($data)
{
if ( ! is_string($data))
{
$data = json_encode($data);
}
$this->response->content->setType('application/json');
$this->response->content->set($data);
}
/**
* Redirect to the selected page
*
* @param string $url
* @param int $code
* @return void
*/
public function redirect($url, $code, $type="anime")
{
$url = full_url($url, $type);
$codes = [
301 => 'Moved Permanently',
302 => 'Found',
303 => 'See Other'
];
header("HTTP/1.1 {$code} {$codes[$code]}");
header("Location: {$url}");
}
/**
* Add a message box to the page
*
* @param string $type
* @param string $message
* @return string
*/
public function show_message($type, $message)
{
return $this->load_partial('message', [
'stat_class' => $type,
'message' => $message
]);
}
/**
* Clear the api session
*
* @return void
*/
public function logout()
{
session_destroy();
$this->response->redirect->seeOther(full_url(''));
}
/**
* Show the login form
*
* @param string $status
* @return void
*/
public function login($status="")
{
$message = "";
if ($status != "")
{
$message = $this->show_message('error', $status);
}
$this->outputHTML('login', [
'title' => 'Api login',
'message' => $message
]);
}
/**
* Attempt to log in with the api
*
* @return void
*/
public function login_action()
{
if (
$this->model->authenticate(
$this->config->hummingbird_username,
$this->request->post->get('password')
)
)
{
$this->response->redirect->afterPost(full_url('', $this->base_data['url_type']));
return;
}
$this->login("Invalid username or password.");
}
/**
* Send the appropriate response
*
* @return void
*/
private function output()
{
// send status
@header($this->response->status->get(), true, $this->response->status->getCode());
// headers
foreach($this->response->headers->get() as $label => $value)
{
@header("{$label}: {$value}");
}
// cookies
foreach($this->response->cookies->get() as $name => $cookie)
{
@setcookie(
$name,
$cookie['value'],
$cookie['expire'],
$cookie['path'],
$cookie['domain'],
$cookie['secure'],
$cookie['httponly']
);
}
// send the actual response
echo $this->response->content->get();
}
}
// End of BaseController.php

View File

@ -1,32 +0,0 @@
<?php
/**
* Base DB model
*/
namespace AnimeClient;
/**
* Base model for database interaction
*/
class BaseDBModel extends BaseModel {
/**
* The query builder object
* @var object $db
*/
protected $db;
/**
* The database connection information array
* @var array $db_config
*/
protected $db_config;
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
$this->db_config = $this->config->database;
}
}
// End of BaseDBModel.php

View File

@ -1,106 +0,0 @@
<?php
/**
* Base for base models
*/
namespace AnimeClient;
use abeautifulsite\SimpleImage;
/**
* Common base for all Models
*/
class BaseModel {
/**
* The global configuration object
* @var object $config
*/
protected $config;
/**
* Constructor
*/
public function __construct()
{
global $config;
$this->config = $config;
}
/**
* Get the path of the cached version of the image. Create the cached image
* if the file does not already exist
*
* @codeCoverageIgnore
* @param string $api_path - The original image url
* @param string $series_slug - The part of the url with the series name, becomes the image name
* @param string $type - Anime or Manga, controls cache path
* @return string - the frontend path for the cached image
*/
public function get_cached_image($api_path, $series_slug, $type="anime")
{
$api_path = str_replace("jjpg", "jpg", $api_path);
$path_parts = explode('?', basename($api_path));
$path = current($path_parts);
$ext_parts = explode('.', $path);
$ext = end($ext_parts);
// Workaround for some broken extensions
if ($ext == "jjpg") $ext = "jpg";
// Failsafe for weird urls
if (strlen($ext) > 3) return $api_path;
$cached_image = "{$series_slug}.{$ext}";
$cached_path = "{$this->config->img_cache_path}/{$type}/{$cached_image}";
// Cache the file if it doesn't already exist
if ( ! file_exists($cached_path))
{
if (ini_get('allow_url_fopen'))
{
copy($api_path, $cached_path);
}
elseif (function_exists('curl_init'))
{
$ch = curl_init($api_path);
$fp = fopen($cached_path, 'wb');
curl_setopt_array($ch, [
CURLOPT_FILE => $fp,
CURLOPT_HEADER => 0
]);
curl_exec($ch);
curl_close($ch);
fclose($ch);
}
else
{
throw new Exception("Couldn't cache images because they couldn't be downloaded.");
}
// Resize the image
if ($type == 'anime')
{
$resize_width = 220;
$resize_height = 319;
$this->_resize($cached_path, $resize_width, $resize_height);
}
}
return "/public/images/{$type}/{$cached_image}";
}
/**
* Resize an image
*
* @codeCoverageIgnore
* @param string $path
* @param string $width
* @param string $height
*/
private function _resize($path, $width, $height)
{
$img = new SimpleImage($path);
$img->resize($width,$height)->save();
}
}
// End of BaseModel.php

View File

@ -1,56 +0,0 @@
<?php
namespace AnimeClient;
/**
* Wrapper for configuration values
*/
class Config {
/**
* Config object
*
* @var array
*/
protected $config = [];
/**
* Constructor
*
* @param array $config_files
*/
public function __construct(Array $config_files=[])
{
// @codeCoverageIgnoreStart
if (empty($config_files))
{
require_once _dir(CONF_DIR, 'config.php'); // $config
require_once _dir(CONF_DIR, 'base_config.php'); // $base_config
}
else // @codeCoverageIgnoreEnd
{
$config = $config_files['config'];
$base_config = $config_files['base_config'];
}
$this->config = array_merge($config, $base_config);
}
/**
* Getter for config values
*
* @param string $key
* @return mixed
*/
public function __get($key)
{
if (isset($this->config[$key]))
{
return $this->config[$key];
}
return NULL;
}
}
// End of config.php

View File

@ -1,175 +0,0 @@
<?php
/**
* Routing logic
*/
namespace AnimeClient;
/**
* Basic routing/ dispatch
*/
class Router {
/**
* The route-matching object
* @var object $router
*/
protected $router;
/**
* The global configuration object
* @var object $config
*/
protected $config;
/**
* Array containing request and response objects
* @var array $web
*/
protected $web;
/**
* Constructor
*
* @param
*/
public function __construct(Config $config, \Aura\Router\Router $router, \Aura\Web\Request $request, \Aura\Web\Response $response)
{
$this->config = $config;
$this->router = $router;
$this->web = [$request, $response];
$this->_setup_routes();
}
/**
* Get the current route object, if one matches
*
* @return object
*/
public function get_route()
{
global $defaultHandler;
$raw_route = $_SERVER['REQUEST_URI'];
$route_path = str_replace([$this->config->anime_path, $this->config->manga_path], '', $raw_route);
$route_path = "/" . trim($route_path, '/');
$defaultHandler->addDataTable('Route Info', [
'route_path' => $route_path
]);
$route = $this->router->match($route_path, $_SERVER);
return $route;
}
/**
* Handle the current route
*
* @param [object] $route
* @return void
*/
public function dispatch($route = NULL)
{
global $defaultHandler;
if (is_null($route))
{
$route = $this->get_route();
}
if ( ! $route)
{
$failure = $this->router->getFailedRoute();
$defaultHandler->addDataTable('failed_route', (array)$failure);
$controller_name = 'BaseController';
$action_method = 'outputHTML';
$params = [
'template' => '404',
'data' => [
'title' => 'Page Not Found'
]
];
}
else
{
list($controller_name, $action_method) = $route->params['action'];
$params = (isset($route->params['params'])) ? $route->params['params'] : [];
if ( ! empty($route->tokens))
{
foreach($route->tokens as $key => $v)
{
if (array_key_exists($key, $route->params))
{
$params[$key] = $route->params[$key];
}
}
}
}
$controller = new $controller_name($this->config, $this->web);
// Run the appropriate controller method
$defaultHandler->addDataTable('controller_args', $params);
call_user_func_array([$controller, $action_method], $params);
}
/**
* Select controller based on the current url, and apply its relevent routes
*
* @return void
*/
private function _setup_routes()
{
$route_map = [
'anime' => '\\AnimeClient\\AnimeController',
'manga' => '\\AnimeClient\\MangaController',
];
$route_type = "anime";
if ($this->config->manga_host !== "" && strpos($_SERVER['HTTP_HOST'], $this->config->manga_host) !== FALSE)
{
$route_type = "manga";
}
else if ($this->config->manga_path !== "" && strpos($_SERVER['REQUEST_URI'], $this->config->manga_path) !== FALSE)
{
$route_type = "manga";
}
$routes = $this->config->routes;
// Add routes
foreach(['common', $route_type] as $key)
{
foreach($routes[$key] as $name => &$route)
{
$path = $route['path'];
unset($route['path']);
// Prepend the controller to the route parameters
array_unshift($route['action'], $route_map[$route_type]);
// Select the appropriate router method based on the http verb
$add = (array_key_exists('verb', $route)) ? "add" . ucfirst(strtolower($route['verb'])) : "addGet";
if ( ! array_key_exists('tokens', $route))
{
$this->router->$add($name, $path)->addValues($route);
}
else
{
$tokens = $route['tokens'];
unset($route['tokens']);
$this->router->$add($name, $path)
->addValues($route)
->addTokens($tokens);
}
}
}
}
}
// End of Router.php

View File

@ -1,134 +0,0 @@
<?php
/**
* Global functions
*/
/**
* Check if the user is currently logged in
*
* @return bool
*/
function is_logged_in()
{
return array_key_exists('hummingbird_anime_token', $_SESSION);
}
/**
* HTML selection helper function
*
* @param string $a - First item to compare
* @param string $b - Second item to compare
* @return string
*/
function is_selected($a, $b)
{
return ($a === $b) ? 'selected' : '';
}
/**
* Inverse of selected helper function
*
* @param string $a - First item to compare
* @param string $b - Second item to compare
* @return string
*/
function is_not_selected($a, $b)
{
return ($a !== $b) ? 'selected' : '';
}
/**
* Get the base url for css/js/images
*
* @return string
*/
function asset_url(/*...*/)
{
global $config;
$args = func_get_args();
$base_url = rtrim($config->asset_path, '/');
array_unshift($args, $base_url);
return implode("/", $args);
}
/**
* Get the base url from the config
*
* @param string $type - (optional) The controller
# @param object $config - (optional) Config
* @return string
*/
function base_url($type="anime", $config=NULL)
{
if (is_null($config)) global $config;
$config_path = trim($config->{"{$type}_path"}, "/");
$config_host = $config->{"{$type}_host"};
// Set the appropriate HTTP host
$host = ($config_host !== '') ? $config_host : $_SERVER['HTTP_HOST'];
$path = ($config_path !== '') ? $config_path : "";
return implode("/", ['/', $host, $path]);
}
/**
* Generate full url path from the route path based on config
*
* @param string $path - (optional) The route path
* @param string $type - (optional) The controller (anime or manga), defaults to anime
# @param object $config - (optional) Config
* @return string
*/
function full_url($path="", $type="anime", $config=NULL)
{
if (is_null($config)) global $config;
$config_path = trim($config->{"{$type}_path"}, "/");
$config_host = $config->{"{$type}_host"};
$config_default_route = $config->{"default_{$type}_path"};
// Remove beginning/trailing slashes
$config_path = trim($config_path, '/');
$path = trim($path, '/');
// Remove any optional parameters from the route
$path = preg_replace('`{/.*?}`i', '', $path);
// Set the appropriate HTTP host
$host = ($config_host !== '') ? $config_host : $_SERVER['HTTP_HOST'];
// Set the default view
if ($path === '')
{
$path .= trim($config_default_route, '/');
if ($config->default_to_list_view) $path .= '/list';
}
// Set an leading folder
if ($config_path !== '')
{
$path = "{$config_path}/{$path}";
}
return "//{$host}/{$path}";
}
/**
* Get the last segment of the current url
*
* @return string
*/
function last_segment()
{
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = explode('/', $path);
return end($segments);
}
// End of functions.php

View File

@ -1,43 +0,0 @@
<?php
/**
* Functions that need to be included before config
*/
/**
* Joins paths together. Variadic to take an
* arbitrary number of arguments
*
* @return string
*/
function _dir()
{
return implode(DIRECTORY_SEPARATOR, func_get_args());
}
/**
* Set up autoloaders
*
* @codeCoverageIgnore
* @return void
*/
function _setup_autoloaders()
{
require _dir(ROOT_DIR, '/vendor/autoload.php');
spl_autoload_register(function ($class) {
$class_parts = explode('\\', $class);
$class = end($class_parts);
$dirs = ["base", "controllers", "models"];
foreach($dirs as $dir)
{
$file = _dir(APP_DIR, $dir, "{$class}.php");
if (file_exists($file))
{
require_once $file;
return;
}
}
});
}

View File

@ -1,57 +1,200 @@
<?php
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 8.1
*
* @copyright 2015 - 2023 Timothy J. Warren <tim@timshome.page>
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5.2
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace AnimeClient;
namespace Aviat\AnimeClient;
use \Whoops\Handler\PrettyPageHandler;
use \Whoops\Handler\JsonResponseHandler;
use \Aura\Web\WebFactory;
use \Aura\Router\RouterFactory;
use \GuzzleHttp\Client;
use \GuzzleHttp\Cookie\CookieJar;
use Aura\Html\HelperLocatorFactory;
use Aura\Router\RouterContainer;
use Aura\Session\SessionFactory;
use Aviat\AnimeClient\API\{Anilist, Kitsu};
use Aviat\AnimeClient\{Component, Model};
use Aviat\Banker\Teller;
use Aviat\Ion\Config;
use Aviat\Ion\Di\{Container, ContainerInterface};
use Laminas\Diactoros\ServerRequestFactory;
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Logger;
use Psr\SimpleCache\CacheInterface;
use function Aviat\Ion\_dir;
if ( ! defined('HB_APP_DIR'))
{
define('HB_APP_DIR', __DIR__);
define('ROOT_DIR', dirname(HB_APP_DIR));
define('TEMPLATE_DIR', _dir(HB_APP_DIR, 'templates'));
}
// -----------------------------------------------------------------------------
// Setup error handling
// Setup DI container
// -----------------------------------------------------------------------------
$whoops = new \Whoops\Run();
return static function (array $configArray = []): Container {
$container = new Container();
// Set up default handler for general errors
$defaultHandler = new PrettyPageHandler();
$whoops->pushHandler($defaultHandler);
// -------------------------------------------------------------------------
// Logging
// -------------------------------------------------------------------------
$LOG_DIR = _dir(HB_APP_DIR, 'logs');
// Set up json handler for ajax errors
$jsonHandler = new JsonResponseHandler();
$jsonHandler->onlyForAjaxRequests(true);
$whoops->pushHandler($jsonHandler);
$appLogger = new Logger('animeclient');
$appLogger->pushHandler(new RotatingFileHandler(_dir($LOG_DIR, 'app.log'), 2, Logger::WARNING));
$container->setLogger($appLogger);
$whoops->register();
foreach (['anilist-request', 'kitsu-request', 'kitsu-graphql'] as $channel)
{
$logger = new Logger($channel);
$handler = new RotatingFileHandler(_dir($LOG_DIR, "{$channel}.log"), 2, Logger::WARNING);
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);
// -----------------------------------------------------------------------------
// Injected Objects
// -----------------------------------------------------------------------------
$container->setLogger($logger, $channel);
}
// Create Config Object
$config = new Config();
require _dir(BASE_DIR, '/functions.php');
// -------------------------------------------------------------------------
// Injected Objects
// -------------------------------------------------------------------------
// Create Aura Router Object
$router_factory = new RouterFactory();
$aura_router = $router_factory->newInstance();
// Create Config Object
$container->set('config', static fn () => new Config($configArray));
// Create Request/Response Objects
$web_factory = new WebFactory([
'_GET' => $_GET,
'_POST' => $_POST,
'_COOKIE' => $_COOKIE,
'_SERVER' => $_SERVER,
'_FILES' => $_FILES
]);
$request = $web_factory->newRequest();
$response = $web_factory->newResponse();
// Create Cache Object
$container->set('cache', static function (ContainerInterface $container): CacheInterface {
$logger = $container->getLogger();
$config = $container->get('config')->get('cache');
// -----------------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------------
$router = new Router($config, $aura_router, $request, $response);
$router->dispatch();
return new Teller($config, $logger);
});
// Create Aura Router Object
$container->set('aura-router', static fn () => new RouterContainer());
// Create Html helpers
$container->set('html-helper', static function (ContainerInterface $container) {
$htmlHelper = (new HelperLocatorFactory())->newInstance();
$helpers = [
'menu' => Helper\Menu::class,
'field' => Helper\Form::class,
'picture' => Helper\Picture::class,
];
foreach ($helpers as $name => $class)
{
$htmlHelper->set($name, static function () use ($class, $container) {
$helper = new $class();
$helper->setContainer($container);
return $helper;
});
}
return $htmlHelper;
});
// Create Component helpers
$container->set('component-helper', static function (ContainerInterface $container) {
$helper = (new HelperLocatorFactory())->newInstance();
$components = [
'animeCover' => Component\AnimeCover::class,
'mangaCover' => Component\MangaCover::class,
'character' => Component\Character::class,
'media' => Component\Media::class,
'tabs' => Component\Tabs::class,
'verticalTabs' => Component\VerticalTabs::class,
];
foreach ($components as $name => $componentClass)
{
$helper->set($name, static function () use ($container, $componentClass) {
$helper = new $componentClass();
$helper->setContainer($container);
return $helper;
});
}
return $helper;
});
// Create Request Object
$container->set('request', static fn () => ServerRequestFactory::fromGlobals(
$GLOBALS['_SERVER'],
$_GET,
$_POST,
$_COOKIE,
$_FILES
));
// Create session Object
$container->set('session', static fn () => (new SessionFactory())->newInstance($_COOKIE));
// Miscellaneous helper methods
$container->set('util', static fn ($container) => new Util($container));
// Models
$container->set('kitsu-model', static function (ContainerInterface $container): Kitsu\Model {
$requestBuilder = new Kitsu\RequestBuilder($container);
$requestBuilder->setLogger($container->getLogger('kitsu-request'));
$listItem = new Kitsu\ListItem();
$listItem->setContainer($container);
$listItem->setRequestBuilder($requestBuilder);
$model = new Kitsu\Model($listItem);
$model->setContainer($container);
$model->setRequestBuilder($requestBuilder);
$cache = $container->get('cache');
$model->setCache($cache);
return $model;
});
$container->set('anilist-model', static function (ContainerInterface $container): Anilist\Model {
$requestBuilder = new Anilist\RequestBuilder($container);
$requestBuilder->setLogger($container->getLogger('anilist-request'));
$listItem = new Anilist\ListItem();
$listItem->setContainer($container);
$listItem->setRequestBuilder($requestBuilder);
$model = new Anilist\Model($listItem);
$model->setContainer($container);
$model->setRequestBuilder($requestBuilder);
return $model;
});
$container->set('anime-model', static fn ($container) => new Model\Anime($container));
$container->set('manga-model', static fn ($container) => new Model\Manga($container));
$container->set('anime-collection-model', static fn ($container) => new Model\AnimeCollection($container));
$container->set('manga-collection-model', static fn ($container) => new Model\MangaCollection($container));
$container->set('settings-model', static function ($container) {
$model = new Model\Settings($container->get('config'));
$model->setContainer($container);
return $model;
});
// Miscellaneous Classes
$container->set('auth', static fn ($container) => new Kitsu\Auth($container));
$container->set('url-generator', static fn ($container) => new UrlGenerator($container));
// -------------------------------------------------------------------------
// Dispatcher
// -------------------------------------------------------------------------
$container->set('dispatcher', static fn ($container) => new Dispatcher($container));
return $container;
};
// End of bootstrap.php

View File

@ -0,0 +1,6 @@
################################################################################
# Anilist API #
################################################################################
client_id = "your_client_id"
client_secret = "your_client_secret"
username = "user123"

View File

@ -1,15 +0,0 @@
<?php
// ----------------------------------------------------------------------------
// Lower level configuration
//
// You shouldn't generally need to change anything below this line
// ----------------------------------------------------------------------------
$base_config = [
// Cache paths
'data_cache_path' => _dir(APP_DIR, 'cache'),
'img_cache_path' => _dir(ROOT_DIR, 'public/images'),
// Included config files
'routes' => require _dir(CONF_DIR, 'routes.php'),
'database' => require _dir(CONF_DIR, 'database.php'),
];

View File

@ -0,0 +1,22 @@
################################################################################
# Cache Setup #
################################################################################
# See https://git.timshomepage.net/aviat/banker for more information
# Available drivers are memcached, redis or null
# Null cache driver means no caching
driver = "redis"
[connection]
# Host or socket to connect to
host = "127.0.0.1"
# Connection port
#port = 6379
# Connection password
#password = ""
# Database number
database = 2

View File

@ -1,40 +0,0 @@
<?php
$config = [
// ----------------------------------------------------------------------------
// Username for anime and manga lists
// ----------------------------------------------------------------------------
'hummingbird_username' => 'timw4mail',
// ----------------------------------------------------------------------------
// General config
// ----------------------------------------------------------------------------
// do you wish to show the anime collection tab?
'show_anime_collection' => TRUE,
// path to public directory
'asset_path' => '//' . $_SERVER['HTTP_HOST'] . '/public',
// path to public directory on the server
'asset_dir' => __DIR__ . '/../../public',
// ----------------------------------------------------------------------------
// Routing
//
// Route by path, or route by domain. To route by path, set the _host suffixed
// options to an empty string. To route by host, set the _path suffixed options
// to an empty string
// ----------------------------------------------------------------------------
'anime_host' => 'anime.timshomepage.net',
'manga_host' => 'manga.timshomepage.net',
'anime_path' => '',
'manga_path' => '',
// Default pages for anime/manga
'default_anime_path' => '/watching',
'default_manga_path' => '/all',
// Default to list view?
'default_to_list_view' => FALSE,
];

View File

@ -0,0 +1,39 @@
################################################################################
# Main User Configuration #
################################################################################
# Username for anime and manga lists
kitsu_username = "johnsmith"
# Whose list is it?
whose_list = "Someone"
# do you wish to show the anime collection?
show_anime_collection = true
# do you wish to show the manga collection?
show_manga_collection = false
# what theme would you like to use? light, dark, or auto
theme = "auto"
################################################################################
# Default views and paths
################################################################################
# Which list should be the default?
default_list = "anime" # anime or manga
# Default pages for anime/manga
default_anime_list_path = "watching" # watching|plan_to_watch|on_hold|dropped|completed|all
default_manga_list_path = "reading" # reading|plan_to_read|on_hold|dropped|completed|all
################################################################################
# Not on Settings Page
#
# These settings are not available to change on the settings page
################################################################################
# Use HTTPs for URLs
# It is not recommended to change this setting
secure_urls = true

View File

@ -1,13 +0,0 @@
<?php
return [
'collection' => [
'type' => 'sqlite',
'host' => '',
'user' => '',
'pass' => '',
'port' => '',
'name' => 'default',
'database' => '',
'file' => __DIR__ . '/../../anime_collection.sqlite',
]
];

View File

@ -0,0 +1,11 @@
################################################################################
# Database Configuration #
################################################################################
type = "sqlite"
host = ""
user = ""
pass = ""
port = ""
database = ""
file = "anime_collection.sqlite3"

View File

@ -1,60 +0,0 @@
<?php
/**
* Easy Min
*
* Simple minification for better website performance
*
* @author Timothy J. Warren
* @copyright Copyright (c) 2012
* @link https://github.com/aviat4ion/Easy-Min
* @license http://philsturgeon.co.uk/code/dbad-license
*/
// --------------------------------------------------------------------------
/* $config = */require 'config.php';
$config = (object)$config;
// Should we use myth to preprocess?
$use_myth = FALSE;
/*
|--------------------------------------------------------------------------
| CSS Folder
|--------------------------------------------------------------------------
|
| The folder where css files exist, in relation to the document root
|
*/
$css_root = $config->asset_dir. '/css/';
/*
|--------------------------------------------------------------------------
| Path from
|--------------------------------------------------------------------------
|
| Path fragment to rewrite in css files
|
*/
$path_from = '';
/*
|--------------------------------------------------------------------------
| Path to
|--------------------------------------------------------------------------
|
| The path fragment replacement for the css files
|
*/
$path_to = '';
/*
|--------------------------------------------------------------------------
| JS Folder
|--------------------------------------------------------------------------
|
| The folder where javascript files exist, in relation to the document root
|
*/
$js_root = $config->asset_dir. '/js/';

View File

@ -1,36 +0,0 @@
<?php
/**
* Easy Min
*
* Simple minification for better website performance
*
* @author Timothy J. Warren
* @copyright Copyright (c) 2012
* @link https://github.com/aviat4ion/Easy-Min
* @license http://philsturgeon.co.uk/code/dbad-license
*/
// --------------------------------------------------------------------------
/**
* This is the config array for css files to concatenate and minify
*/
return [
/*-----
Css
-----*/
/*
For each group create an array like so
'my_group' => array(
'path/to/css/file1.css',
'path/to/css/file2.css'
),
*/
'base' => [
'marx.css',
'base.css'
]
];
// End of css_groups.php

View File

@ -1,40 +0,0 @@
<?php
/**
* Easy Min
*
* Simple minification for better website performance
*
* @author Timothy J. Warren
* @copyright Copyright (c) 2012
* @link https://github.com/aviat4ion/Easy-Min
* @license http://philsturgeon.co.uk/code/dbad-license
*/
// --------------------------------------------------------------------------
/**
* This is the config array for javascript files to concatenate and minify
*/
return [
/*
For each group create an array like so
'my_group' => array(
'path/to/js/file1.js',
'path/to/js/file2.js'
),
*/
'table' => [
'lib/jquery.min.js',
'lib/table_sorter/jquery.tablesorter.min.js',
'sort_tables.js'
],
'edit' => [
'lib/jquery.min.js',
'show_message.js',
'anime_edit.js',
'manga_edit.js'
]
];
// End of js_groups.php

View File

@ -1,188 +0,0 @@
<?php
return [
// Routes on all controllers
'common' => [
'update' => [
'path' => '/update',
'action' => ['update'],
'verb' => 'post'
],
'login_form' => [
'path' => '/login',
'action' => ['login'],
'verb' => 'get'
],
'login_action' => [
'path' => '/login',
'action' => ['login_action'],
'verb' => 'post'
],
'logout' => [
'path' => '/logout',
'action' => ['logout']
],
],
// Routes on anime controller
'anime' => [
'index' => [
'path' => '/',
'action' => ['redirect'],
'params' => [
'url' => '', // Determined by config
'code' => '301'
]
],
'all' => [
'path' => '/all{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'all',
'title' => WHOSE . " Anime List &middot; All"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'watching' => [
'path' => '/watching{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'currently-watching',
'title' => WHOSE . " Anime List &middot; Watching"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'plan_to_watch' => [
'path' => '/plan_to_watch{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'plan-to-watch',
'title' => WHOSE . " Anime List &middot; Plan to Watch"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'on_hold' => [
'path' => '/on_hold{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'on-hold',
'title' => WHOSE . " Anime List &middot; On Hold"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'dropped' => [
'path' => '/dropped{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'dropped',
'title' => WHOSE . " Anime List &middot; Dropped"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'completed' => [
'path' => '/completed{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'completed',
'title' => WHOSE . " Anime List &middot; Completed"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'collection' => [
'path' => '/collection{/view}',
'action' => ['collection'],
'params' => [],
'tokens' => [
'view' => '[a-z_]+'
]
]
],
'manga' => [
'index' => [
'path' => '/',
'action' => ['redirect'],
'params' => [
'url' => '', // Determined by config
'code' => '301',
'type' => 'manga'
]
],
'all' => [
'path' => '/all{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'all',
'title' => WHOSE . " Manga List &middot; All"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'reading' => [
'path' => '/reading{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'Reading',
'title' => WHOSE . " Manga List &middot; Reading"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'plan_to_read' => [
'path' => '/plan_to_read{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'Plan to Read',
'title' => WHOSE . " Manga List &middot; Plan to Read"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'on_hold' => [
'path' => '/on_hold{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'On Hold',
'title' => WHOSE . " Manga List &middot; On Hold"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'dropped' => [
'path' => '/dropped{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'Dropped',
'title' => WHOSE . " Manga List &middot; Dropped"
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'completed' => [
'path' => '/completed{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'Completed',
'title' => WHOSE . " Manga List &middot; Completed"
],
'tokens' => [
'view' => '[a-z_]+'
]
]
]
];

View File

@ -1,121 +0,0 @@
<?php
/**
* Anime Controller
*/
namespace AnimeClient;
/**
* Controller for Anime-related pages
*/
class AnimeController extends BaseController {
/**
* The anime list model
* @var object $model
*/
protected $model;
/**
* The anime collection model
* @var object $collection_model
*/
private $collection_model;
/**
* Data to ve sent to all routes in this controller
* @var array $base_data
*/
protected $base_data;
/**
* Route mapping for main navigation
* @var array $nav_routes
*/
private $nav_routes = [
'Watching' => '/watching{/view}',
'Plan to Watch' => '/plan_to_watch{/view}',
'On Hold' => '/on_hold{/view}',
'Dropped' => '/dropped{/view}',
'Completed' => '/completed{/view}',
'Collection' => '/collection{/view}',
'All' => '/all{/view}'
];
/**
* Constructor
*/
public function __construct(Config $config, Array $web)
{
parent::__construct($config, $web);
if ($this->config->show_anime_collection === FALSE)
{
unset($this->nav_routes['Collection']);
}
$this->model = new AnimeModel();
$this->collection_model = new AnimeCollectionModel();
$this->base_data = [
'message' => '',
'url_type' => 'anime',
'other_type' => 'manga',
'nav_routes' => $this->nav_routes,
];
}
/**
* Show a portion, or all of the anime list
*
* @param string $type - The section of the list
* @param string $title - The title of the page
* @return void
*/
public function anime_list($type, $title, $view)
{
$view_map = [
'' => 'cover',
'list' => 'list'
];
$data = ($type != 'all')
? $this->model->get_list($type)
: $this->model->get_all_lists();
$this->outputHTML('anime/' . $view_map[$view], [
'title' => $title,
'sections' => $data
]);
}
/**
* Show the anime collection page
*
* @return void
*/
public function collection($view)
{
$view_map = [
'' => 'collection',
'list' => 'collection_list'
];
$data = $this->collection_model->get_collection();
$this->outputHTML('anime/' . $view_map[$view], [
'title' => WHOSE . " Anime Collection",
'sections' => $data
]);
}
/**
* Update an anime item
*
* @return bool
*/
public function update()
{
print_r($this->model->update($this->request->post->get()));
}
}
// End of AnimeController.php

View File

@ -1,87 +0,0 @@
<?php
/**
* Manga Controller
*/
namespace AnimeClient;
/**
* Controller for manga list
*/
class MangaController extends BaseController {
/**
* The manga model
* @var object $model
*/
protected $model;
/**
* Data to ve sent to all routes in this controller
* @var array $base_data
*/
protected $base_data;
/**
* Route mapping for main navigation
* @var array $nav_routes
*/
private $nav_routes = [
'Reading' => '/reading{/view}',
'Plan to Read' => '/plan_to_read{/view}',
'On Hold' => '/on_hold{/view}',
'Dropped' => '/dropped{/view}',
'Completed' => '/completed{/view}',
'All' => '/all{/view}'
];
/**
* Constructor
*/
public function __construct(Config $config, Array $web)
{
parent::__construct($config, $web);
$this->model = new MangaModel();
$this->base_data = [
'url_type' => 'manga',
'other_type' => 'anime',
'nav_routes' => $this->nav_routes
];
}
/**
* Update an anime item
*
* @return bool
*/
public function update()
{
$this->outputJSON($this->model->update($this->request->post->get()));
}
/**
* Get a section of the manga list
*
* @param string $status
* @param string $title
* @param string $view
* @return void
*/
public function manga_list($status, $title, $view)
{
$view_map = [
'' => 'cover',
'list' => 'list'
];
$data = ($status !== 'all')
? [$status => $this->model->get_list($status)]
: $this->model->get_all_lists();
$this->outputHTML('manga/' . $view_map[$view], [
'title' => $title,
'sections' => $data
]);
}
}
// End of MangaController.php

View File

View File

@ -1,206 +0,0 @@
<?php
/**
* Anime Collection DB Model
*/
namespace AnimeClient;
/**
* Model for getting anime collection data
*/
class AnimeCollectionModel extends BaseDBModel {
/**
* Anime API Model
* @var object $anime_model
*/
private $anime_model;
/**
* Whether the database is valid for querying
* @var bool
*/
private $valid_database = FALSE;
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
$this->db = \Query($this->db_config['collection']);
$this->anime_model = new AnimeModel();
// Is database valid? If not, set a flag so the
// app can be run without a valid database
$db_file = file_get_contents($this->db_config['collection']['file']);
$this->valid_database = (strpos($db_file, 'SQLite format 3') === 0);
// Do an import if an import file exists
$this->json_import();
}
/**
* Get collection from the database, and organize by media type
*
* @return array
*/
public function get_collection()
{
$raw_collection = $this->_get_collection();
$collection = [];
foreach($raw_collection as $row)
{
if (array_key_exists($row['media'], $collection))
{
$collection[$row['media']][] = $row;
}
else
{
$collection[$row['media']] = [$row];
}
}
return $collection;
}
/**
* Get full collection from the database
*
* @return array
*/
private function _get_collection()
{
if ( ! $this->valid_database) return [];
$query = $this->db->select('hummingbird_id, slug, title, alternate_title, show_type, age_rating, episode_count, episode_length, cover_image, notes, media.type as media')
->from('anime_set a')
->join('media', 'media.id=a.media_id', 'inner')
->order_by('media')
->order_by('title')
->get();
return $query->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Import anime into collection from a json file
*
* @return void
*/
private function json_import()
{
if ( ! file_exists('import.json')) return;
if ( ! $this->valid_database) return;
$anime = json_decode(file_get_contents("import.json"));
foreach($anime as $item)
{
$this->db->set([
'hummingbird_id' => $item->id,
'slug' => $item->slug,
'title' => $item->title,
'alternate_title' => $item->alternate_title,
'show_type' => $item->show_type,
'age_rating' => $item->age_rating,
'cover_image' => $this->get_cached_image($item->cover_image, $item->slug, 'anime'),
'episode_count' => $item->episode_count,
'episode_length' => $item->episode_length
])->insert('anime_set');
}
// Delete the import file
unlink('import.json');
// Update genre info
$this->update_genres();
}
/**
* Update genre information
*
* @return void
*/
private function update_genres()
{
$genres = [];
$flipped_genres = [];
$links = [];
// Get existing genres
$query = $this->db->select('id, genre')
->from('genres')
->get();
foreach($query->fetchAll(PDO::FETCH_ASSOC) as $genre)
{
$genres[$genre['id']] = $genre['genre'];
}
// Get existing link table entries
$query = $this->db->select('hummingbird_id, genre_id')
->from('genre_anime_set_link')
->get();
foreach($query->fetchAll(PDO::FETCH_ASSOC) as $link)
{
if (array_key_exists($link['hummingbird_id'], $links))
{
$links[$link['hummingbird_id']][] = $link['genre_id'];
}
else
{
$links[$link['hummingbird_id']] = [$link['genre_id']];
}
}
// Get the anime collection
$collection = $this->_get_collection();
foreach($collection as $anime)
{
// Get api information
$api = $this->anime_model->get_anime($anime['hummingbird_id']);
foreach($api['genres'] as $genre)
{
// Add genres that don't currently exist
if ( ! in_array($genre['name'], $genres))
{
$this->db->set('genre', $genre['name'])
->insert('genres');
$genres[] = $genre['name'];
}
// Update link table
// Get id of genre to put in link table
$flipped_genres = array_flip($genres);
$insert_array = [
'hummingbird_id' => $anime['hummingbird_id'],
'genre_id' => $flipped_genres[$genre['name']]
];
if (array_key_exists($anime['hummingbird_id'], $links))
{
if ( ! in_array($flipped_genres[$genre['name']], $links[$anime['hummingbird_id']]))
{
$this->db->set($insert_array)->insert('genre_anime_set_link');
}
}
else
{
$this->db->set($insert_array)->insert('genre_anime_set_link');
}
}
}
}
}
// End of AnimeCollectionModel.php

View File

@ -1,245 +0,0 @@
<?php
/**
* Anime API Model
*/
namespace AnimeClient;
/**
* Model for handling requests dealing with the anime list
*/
class AnimeModel extends BaseApiModel {
/**
* The base url for api requests
* @var string $base_url
*/
protected $base_url = "https://hummingbird.me/api/v1/";
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
}
/**
* Update the selected anime
*
* @param array $data
* @return array
*/
public function update($data)
{
$data['auth_token'] = $_SESSION['hummingbird_anime_token'];
$result = $this->client->post("libraries/{$data['id']}", [
'body' => $data
]);
return $result->json();
}
/**
* Get the full set of anime lists
*
* @return array
*/
public function get_all_lists()
{
$output = [
'Watching' => [],
'Plan to Watch' => [],
'On Hold' => [],
'Dropped' => [],
'Completed' => [],
];
$data = $this->_get_list();
foreach($data as $datum)
{
switch($datum['status'])
{
case "completed":
$output['Completed'][] = $datum;
break;
case "plan-to-watch":
$output['Plan to Watch'][] = $datum;
break;
case "dropped":
$output['Dropped'][] = $datum;
break;
case "on-hold":
$output['On Hold'][] = $datum;
break;
case "currently-watching":
$output['Watching'][] = $datum;
break;
}
}
// Sort anime by name
foreach($output as &$status_list)
{
$this->sort_by_name($status_list);
}
return $output;
}
/**
* Get a category out of the full list
*
* @param string $status
* @return array
*/
public function get_list($status)
{
$map = [
'currently-watching' => 'Watching',
'plan-to-watch' => 'Plan to Watch',
'on-hold' => 'On Hold',
'dropped' => 'Dropped',
'completed' => 'Completed',
];
$data = $this->_get_list($status);
$this->sort_by_name($data);
$output = [];
$output[$map[$status]] = $data;
return $output;
}
/**
* Get information about an anime from its id
*
* @param string $anime_id
* @return array
*/
public function get_anime($anime_id)
{
$config = [
'query' => [
'id' => $anime_id
]
];
$response = $this->client->get("anime/{$anime_id}", $config);
return $response->json();
}
/**
* Search for anime by name
*
* @param string $name
* @return array
*/
public function search($name)
{
global $defaultHandler;
$config = [
'query' => [
'query' => $name
]
];
$response = $this->client->get('search/anime', $config);
$defaultHandler->addDataTable('anime_search_response', (array)$response);
if ($response->getStatusCode() != 200)
{
throw new Exception($response->getEffectiveUrl());
}
return $response->json();
}
/**
* Actually retreive the data from the api
*
* @param string $status - Status to filter by
* @return array
*/
private function _get_list($status="all")
{
global $defaultHandler;
$cache_file = "{$this->config->data_cache_path}/anime-{$status}.json";
$config = [
'allow_redirects' => FALSE
];
if ($status != "all")
{
$config['query']['status'] = $status;
}
$response = $this->client->get("users/{$this->config->hummingbird_username}/library", $config);
$defaultHandler->addDataTable('anime_list_response', (array)$response);
if ($response->getStatusCode() != 200)
{
if ( ! file_exists($cache_file))
{
throw new Exception($response->getEffectiveUrl());
}
else
{
$output = json_decode(file_get_contents($cache_file), TRUE);
}
}
else
{
$output = $response->json();
$output_json = json_encode($output);
if (( ! file_exists($cache_file)) || file_get_contents($cache_file) !== $output_json)
{
// Attempt to create the cache folder if it doesn't exist
if ( ! is_dir($this->config->data_cache_path))
{
mkdir($this->config->data_cache_path);
}
// Cache the call in case of downtime
file_put_contents($cache_file, json_encode($output));
}
}
foreach($output as &$row)
{
$row['anime']['cover_image'] = $this->get_cached_image($row['anime']['cover_image'], $row['anime']['slug'], 'anime');
}
return $output;
}
/**
* Sort the list by title
*
* @param array $array
* @return void
*/
private function sort_by_name(&$array)
{
$sort = array();
foreach($array as $key => $item)
{
$sort[$key] = $item['anime']['title'];
}
array_multisort($sort, SORT_ASC, $array);
}
}
// End of AnimeModel.php

View File

@ -1,193 +0,0 @@
<?php
/**
* Manga API Model
*/
namespace AnimeClient;
/**
* Model for handling requests dealing with the manga list
*/
class MangaModel extends BaseApiModel {
/**
* The base url for api requests
* @var string
*/
protected $base_url = "https://hummingbird.me/";
/**
* Update the selected manga
*
* @param array $data
* @return array
*/
public function update($data)
{
$id = $data['id'];
unset($data['id']);
$result = $this->client->put("manga_library_entries/{$id}", [
'cookies' => ['token' => $_SESSION['hummingbird_anime_token']],
'json' => ['manga_library_entry' => $data]
]);
return $result->json();
}
/**
* Get the full set of anime lists
*
* @return array
*/
public function get_all_lists()
{
$data = $this->_get_list();
foreach ($data as $key => &$val)
{
$this->sort_by_name($val);
}
return $data;
}
/**
* Get a category out of the full list
*
* @param string $status
* @return array
*/
public function get_list($status)
{
$data = $this->_get_list($status);
$this->sort_by_name($data);
return $data;
}
/**
* Massage the list of manga entries into something more usable
*
* @param string $status
* @return array
*/
private function _get_list($status="all")
{
global $defaultHandler;
$cache_file = _dir($this->config->data_cache_path, 'manga.json');
$config = [
'query' => [
'user_id' => $this->config->hummingbird_username
],
'allow_redirects' => FALSE
];
$response = $this->client->get('manga_library_entries', $config);
$defaultHandler->addDataTable('response', (array)$response);
if ($response->getStatusCode() != 200)
{
if ( ! file_exists($cache_file))
{
throw new Exception($response->getEffectiveUrl());
}
else
{
$raw_data = json_decode(file_get_contents($cache_file), TRUE);
}
}
else
{
// Reorganize data to be more usable
$raw_data = $response->json();
// Attempt to create the cache dir if it doesn't exist
if ( ! is_dir($this->config->data_cache_path))
{
mkdir($this->config->data_cache_path);
}
// Cache data in case of downtime
file_put_contents($cache_file, json_encode($raw_data));
}
// Bail out early if there isn't any manga data
if (empty($raw_data)) return [];
$data = [
'Reading' => [],
'Plan to Read' => [],
'On Hold' => [],
'Dropped' => [],
'Completed' => [],
];
$manga_data = [];
// Massage the two lists into one
foreach($raw_data['manga'] as $manga)
{
$manga_data[$manga['id']] = $manga;
}
// Filter data by status
foreach($raw_data['manga_library_entries'] as &$entry)
{
$entry['manga'] = $manga_data[$entry['manga_id']];
// Cache poster images
$entry['manga']['poster_image'] = $this->get_cached_image($entry['manga']['poster_image'], $entry['manga_id'], 'manga');
switch($entry['status'])
{
case "Plan to Read":
$data['Plan to Read'][] = $entry;
break;
case "Dropped":
$data['Dropped'][] = $entry;
break;
case "On Hold":
$data['On Hold'][] = $entry;
break;
case "Currently Reading":
$data['Reading'][] = $entry;
break;
case "Completed":
default:
$data['Completed'][] = $entry;
break;
}
}
//file_put_contents(_dir($this->config->data_cache_path, "manga-processed.json"), json_encode($data, JSON_PRETTY_PRINT));
return (array_key_exists($status, $data)) ? $data[$status] : $data;
}
/**
* Sort the manga entries by their title
*
* @param array $array
* @return void
*/
private function sort_by_name(&$array)
{
$sort = array();
foreach($array as $key => $item)
{
$sort[$key] = $item['manga']['romaji_title'];
}
array_multisort($sort, SORT_ASC, $array);
}
}
// End of MangaModel.php

View File

@ -0,0 +1,100 @@
<article
class="media"
data-kitsu-id="<?= $item['id'] ?>"
data-anilist-id="<?= $item['anilist_id'] ?>"
data-mal-id="<?= $item['mal_id'] ?>"
>
<?php if ($auth->isAuthenticated()): ?>
<button title="Increment episode count" class="plus-one" hidden>+1 Episode</button>
<?php endif ?>
<?= $helper->img($item['anime']['cover_image'], ['width' => 220, 'loading' => 'lazy']) ?>
<div class="name">
<a href="<?= $url->generate('anime.details', ['id' => $item['anime']['slug']]) ?>">
<span class="canonical"><?= $item['anime']['title'] ?></span>
<?php foreach ($item['anime']['titles'] as $title): ?>
<br/>
<small><?= $title ?></small>
<?php endforeach ?>
</a>
</div>
<div class="table">
<?php if (isset($item['private']) || isset($item['rewatching'])): ?>
<div class="row">
<?php foreach (['private', 'rewatching'] as $attr): ?>
<?php if ($item[$attr]): ?>
<span class="item-<?= $attr ?>"><?= ucfirst($attr) ?></span>
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>
<?php if ($item['rewatched'] > 0): ?>
<div class="row">
<?php if ($item['rewatched'] == 1): ?>
<div>Rewatched once</div>
<?php elseif ($item['rewatched'] == 2): ?>
<div>Rewatched twice</div>
<?php elseif ($item['rewatched'] == 3): ?>
<div>Rewatched thrice</div>
<?php else: ?>
<div>Rewatched <?= $item['rewatched'] ?> times</div>
<?php endif ?>
</div>
<?php endif ?>
<?php if (count($item['anime']['streaming_links']) > 0): ?>
<div class="row">
<?php foreach ($item['anime']['streaming_links'] as $link): ?>
<div class="cover-streaming-link">
<?php if ($link['meta']['link']): ?>
<a href="<?= $link['link'] ?>"
title="Stream '<?= $item['anime']['title'] ?>' on <?= $link['meta']['name'] ?>">
<?= $helper->img("/public/images/{$link['meta']['image']}", [
'class' => 'streaming-logo',
'width' => 20,
'height' => 20,
'alt' => "{$link['meta']['name']} logo",
]); ?>
</a>
<?php else: ?>
<?= $helper->img("/public/images/{$link['meta']['image']}", [
'class' => 'streaming-logo',
'width' => 20,
'height' => 20,
'alt' => "{$link['meta']['name']} logo",
]); ?>
<?php endif ?>
</div>
<?php endforeach ?>
</div>
<?php endif ?>
<?php if ($auth->isAuthenticated()): ?>
<div class="row">
<span class="edit">
<a class="bracketed" title="Edit information about this anime" href="<?=
$url->generate('edit', [
'controller' => 'anime',
'id' => $item['id'],
'status' => $item['watching_status']
]);
?>">Edit</a>
</span>
</div>
<?php endif ?>
<div class="row">
<div class="user-rating">Rating: <?= $item['user_rating'] ?> / 10</div>
<div class="completion">Episodes:
<span class="completed_number"><?= $item['episodes']['watched'] ?></span> /
<span class="total_number"><?= $item['episodes']['total'] ?></span>
</div>
</div>
<div class="row">
<div class="media_type"><?= $escape->html($item['anime']['show_type']) ?></div>
<div class="airing-status"><?= $escape->html($item['airing']['status']) ?></div>
<div class="age-rating"><?= $escape->html($item['anime']['age_rating']) ?></div>
</div>
</div>
</article>

View File

@ -0,0 +1,6 @@
<article class="<?= $className ?>">
<div class="name">
<a href="<?= $link ?>"><?= $name ?></a>
</div>
<a href="<?= $link ?>"><?= $picture ?></a>
</article>

View File

@ -0,0 +1,68 @@
<article class="media" data-kitsu-id="<?= $item['id'] ?>" data-mal-id="<?= $item['mal_id'] ?>">
<?php if ($auth->isAuthenticated()): ?>
<div class="edit-buttons" hidden>
<button class="plus-one-chapter">+1 Chapter</button>
</div>
<?php endif ?>
<?= $helper->img($item['manga']['image'], ['width' => 220, 'loading' => 'lazy']) ?>
<div class="name">
<a href="<?= $url->generate('manga.details', ['id' => $item['manga']['slug']]) ?>">
<?= $escape->html($item['manga']['title']) ?>
<?php foreach($item['manga']['titles'] as $title): ?>
<br /><small><?= $title ?></small>
<?php endforeach ?>
</a>
</div>
<div class="table">
<?php if ($auth->isAuthenticated()): ?>
<div class="row">
<span class="edit">
<a class="bracketed"
title="Edit information about this manga"
href="<?= $url->generate('edit', [
'controller' => 'manga',
'id' => $item['id'],
'status' => $name
]) ?>">
Edit
</a>
</span>
</div>
<?php endif ?>
<div class="row">
<div><?= $item['manga']['type'] ?></div>
<div class="user-rating">Rating: <?= $item['user_rating'] ?> / 10</div>
</div>
<?php if ($item['rereading']): ?>
<div class="row">
<?php foreach(['rereading'] as $attr): ?>
<?php if($item[$attr]): ?>
<span class="item-<?= $attr ?>"><?= ucfirst($attr) ?></span>
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>
<?php if ($item['reread'] > 0): ?>
<div class="row">
<?php if ($item['reread'] == 1): ?>
<div>Reread once</div>
<?php elseif ($item['reread'] == 2): ?>
<div>Reread twice</div>
<?php elseif ($item['reread'] == 3): ?>
<div>Reread thrice</div>
<?php else: ?>
<div>Reread <?= $item['reread'] ?> times</div>
<?php endif ?>
</div>
<?php endif ?>
<div class="row">
<div class="chapter_completion">
Chapters: <span class="chapters_read"><?= $item['chapters']['read'] ?></span> /
<span class="chapter_count"><?= $item['chapters']['total'] ?></span>
</div>
</div>
</div>
</article>

12
app/templates/media.php Normal file
View File

@ -0,0 +1,12 @@
<article class="<?= $className ?>">
<a href="<?= $link ?>"><?= $picture ?></a>
<div class="name">
<a href="<?= $link ?>">
<?= array_shift($titles) ?>
<?php foreach ($titles as $title): ?>
<br />
<small><?= $title ?></small>
<?php endforeach ?>
</a>
</div>
</article>

View File

@ -0,0 +1,5 @@
<section class="<?= $className ?>">
<?php foreach ($data as $tabName => $tabData): ?>
<?= $callback($tabData, $tabName) ?>
<?php endforeach ?>
</section>

32
app/templates/tabs.php Normal file
View File

@ -0,0 +1,32 @@
<div class="tabs">
<?php $i = 0; foreach ($data as $tabName => $tabData): ?>
<?php if ( ! empty($tabData)): ?>
<?php $id = "{$name}-{$i}"; ?>
<input
role='tab'
aria-controls="_<?= $id ?>"
type="radio"
name="<?= $name ?>"
id="<?= $id ?>"
<?= ($i === 0) ? 'checked="checked"' : '' ?>
/>
<label for="<?= $id ?>"><?= ucfirst($tabName) ?></label>
<?php if ($hasSectionWrapper): ?>
<div class="content full-height">
<?php endif ?>
<section
id="_<?= $id ?>"
role="tabpanel"
class="<?= $className ?>"
>
<?= $callback($tabData, $tabName) ?>
</section>
<?php if ($hasSectionWrapper): ?>
</div>
<?php endif ?>
<?php endif ?>
<?php $i++; endforeach ?>
</div>

View File

@ -0,0 +1,25 @@
<div class="vertical-tabs">
<?php $i = 0; ?>
<?php foreach ($data as $tabName => $tabData): ?>
<?php $id = "{$name}-{$i}" ?>
<div class="tab">
<input
type="radio"
role='tab'
aria-controls="_<?= $id ?>"
name="<?= $name ?>"
id="<?= $id ?>"
<?= $i === 0 ? 'checked="checked"' : '' ?>
/>
<label for="<?= $id ?>"><?= $tabName ?></label>
<section
id='_<?= $id ?>'
role="tabpanel"
class="<?= $className ?>"
>
<?= $callback($tabData, $tabName) ?>
</section>
</div>
<?php $i++; ?>
<?php endforeach ?>
</div>

View File

@ -1,7 +1,6 @@
<body>
<main>
<h1>404</h1>
<h2>Page Not Found</h2>
</main>
</body>
</html>
<main>
<h1>404</h1>
<h2><?= $message ?></h2>
<pre>(╯°□°)╯︵ ┻━┻
┬─┬ノ( º _ ºノ)</pre>
</main>

40
app/views/anime/add.php Normal file
View File

@ -0,0 +1,40 @@
<?php if ($auth->isAuthenticated()): ?>
<main>
<h2>Add Anime to your List</h2>
<form action="<?= $action_url ?>" method="post">
<?php include realpath(__DIR__ . '/../js-warning.php') ?>
<section>
<div class="cssload-loader" hidden="hidden">
<div class="cssload-inner cssload-one"></div>
<div class="cssload-inner cssload-two"></div>
<div class="cssload-inner cssload-three"></div>
</div>
<label for="search">Search for anime by name:&nbsp;&nbsp;&nbsp;&nbsp;<input type="search" id="search" /></label>
<section id="series-list" class="media-wrap">
</section>
</section>
<br />
<table class="invisible form">
<tbody>
<tr>
<td><label for="status">Watching Status</label></td>
<td>
<select name="status" id="status">
<?php foreach($status_list as $status_key => $status_title): ?>
<option value="<?= $status_key ?>"><?= $status_title ?></option>
<?php endforeach ?>
</select>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<input type="hidden" name="type" value="anime" />
<button type="submit">Save</button>
</td>
</tr>
</tbody>
</table>
</form>
</main>
<?php endif ?>

View File

@ -1,28 +0,0 @@
<main>
<?php foreach ($sections as $name => $items): ?>
<section class="status">
<h2><?= $name ?></h2>
<section class="media-wrap">
<?php foreach($items as $item): ?>
<a href="https://hummingbird.me/anime/<?= $item['slug'] ?>">
<article class="media" id="a-<?= $item['hummingbird_id'] ?>">
<img src="<?= $item['cover_image'] ?>" />
<div class="name">
<?= $item['title'] ?>
<?= ($item['alternate_title'] != "") ? "<br />({$item['alternate_title']})" : ""; ?>
</div>
<div class="table">
<div class="row">
<div class="completion">Episodes: <?= $item['episode_count'] ?></div>
<div class="media_type"><?= $item['show_type'] ?></div>
<div class="age_rating"><?= $item['age_rating'] ?></div>
</div>
</div>
</article>
</a>
<?php endforeach ?>
</section>
</section>
<?php endforeach ?>
</main>

View File

@ -1,43 +0,0 @@
<main>
<?php foreach ($sections as $name => $items): ?>
<h2><?= $name ?></h2>
<table>
<thead>
<tr>
<th>Title</th>
<th>Alternate Title</th>
<th>Episode Count</th>
<th>Episode Length</th>
<th>Show Type</th>
<th>Age Rating</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<?php foreach($items as $item): ?>
<tr>
<td class="align_left">
<a href="https://hummingbird.me/anime/<?= $item['slug'] ?>">
<?= $item['title'] ?>
</a>
</td>
<td class="align_left"><?= $item['alternate_title'] ?></td>
<td><?= $item['episode_count'] ?></td>
<td><?= $item['episode_length'] ?></td>
<td><?= $item['show_type'] ?></td>
<td><?= $item['age_rating'] ?></td>
<td class="align_left"><?= $item['notes'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<br />
<?php endforeach ?>
</main>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<script src="/public/js/table_sorter/jquery.tablesorter.min.js"></script>
<script>
$(function() {
$('table').tablesorter();
});
</script>

View File

@ -1,40 +1,30 @@
<main>
<main class="media-list">
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $url->generate('anime.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
<?php else: ?>
<br />
<label>Filter: <input type='text' class='media-filter' /></label>
<br />
<?php foreach ($sections as $name => $items): ?>
<?php if (empty($items)): ?>
<section class="status">
<h2><?= $name ?></h2>
<h2><?= $escape->html($name) ?></h2>
<h3>There's nothing here!</h3>
</section>
<?php else: ?>
<section class="status">
<h2><?= $escape->html($name) ?></h2>
<section class="media-wrap">
<?php foreach($items as $item): ?>
<article class="media" id="a-<?= $item['anime']['id'] ?>">
<?php if (is_logged_in()): ?>
<button class="plus_one" hidden>+1 Episode</button>
<?php endif ?>
<img src="<?= $item['anime']['cover_image'] ?>" />
<div class="name">
<a href="<?= $item['anime']['url'] ?>">
<?= $item['anime']['title'] ?>
<?= ($item['anime']['alternate_title'] != "") ? "<br />({$item['anime']['alternate_title']})" : ""; ?>
</a>
</div>
<div class="table">
<div class="row">
<div class="user_rating">Rating: <?= ($item['rating']['value'] > 0) ? (int)($item['rating']['value'] * 2) : " - " ?> / 10</div>
<div class="completion">Episodes:
<span class="completed_number"><?= $item['episodes_watched'] ?></span> /
<span class="total_number"><?= ($item['anime']['episode_count'] != 0) ? $item['anime']['episode_count'] : "-" ?></span>
</div>
</div>
<div class="row">
<div class="media_type"><?= $item['anime']['show_type'] ?></div>
<div class="airing_status"><?= $item['anime']['status'] ?></div>
<div class="age_rating"><?= $item['anime']['age_rating'] ?></div>
</div>
</div>
</article>
<?php if ($item['private'] && ! $auth->isAuthenticated()) continue; ?>
<?= $component->animeCover($item) ?>
<?php endforeach ?>
</section>
</section>
<?php endif ?>
<?php endforeach ?>
</main>
<?php if (is_logged_in()): ?>
<script src="<?= asset_url('js.php?g=edit') ?>"></script>
<?php endif ?>
</main>

205
app/views/anime/details.php Normal file
View File

@ -0,0 +1,205 @@
<?php
use function Aviat\AnimeClient\friendlyTime;
?>
<main class="details fixed">
<section class="flex" unselectable>
<aside class="info">
<?= $helper->img($data['cover_image'], ['width' => '390']) ?>
<br />
<table class="media-details">
<tr>
<td class="align-right">Airing Status</td>
<td><?= $data['status'] ?></td>
</tr>
<?php if ( ! empty($data['airDate'])): ?>
<tr>
<td>Original Airing</td>
<td><?= $data['airDate'] ?></td>
</tr>
<?php endif ?>
<tr>
<td>Show Type</td>
<td><?= (strlen($data['show_type']) > 3) ? ucfirst(strtolower($data['show_type'])) : $data['show_type'] ?></td>
</tr>
<?php if ($data['episode_count'] !== 1): ?>
<tr>
<td>Episode Count</td>
<td><?= $data['episode_count'] ?? '-' ?></td>
</tr>
<?php endif ?>
<?php if (( ! empty($data['episode_length'])) && $data['episode_count'] !== 1): ?>
<tr>
<td>Episode Length</td>
<td><?= friendlyTime($data['episode_length']) ?></td>
</tr>
<?php endif ?>
<?php if (isset($data['total_length'], $data['episode_count']) && $data['total_length'] > 0): ?>
<tr>
<td>Total Length</td>
<td><?= friendlyTime($data['total_length']) ?></td>
</tr>
<?php endif ?>
<?php if ( ! empty($data['age_rating'])): ?>
<tr>
<td>Age Rating</td>
<td><abbr title="<?= $data['age_rating_guide'] ?>"><?= $data['age_rating'] ?></abbr>
</td>
</tr>
<?php endif ?>
<?php if (count($data['links']) > 0): ?>
<tr>
<td>External Links</td>
<td>
<?php foreach ($data['links'] as $urlName => $externalUrl): ?>
<a rel='external' href="<?= $externalUrl ?>"><?= $urlName ?></a><br />
<?php endforeach ?>
</td>
</tr>
<?php endif ?>
<tr>
<td>Genres</td>
<td>
<?= implode(', ', $data['genres']) ?>
</td>
</tr>
</table>
<br />
</aside>
<article class="text">
<h2 class="toph"><?= $data['title'] ?></h2>
<?php foreach ($data['titles_more'] as $title): ?>
<h3><?= $title ?></h3>
<?php endforeach ?>
<br />
<div class="description">
<p><?= str_replace("\n", '</p><p>', $data['synopsis']) ?></p>
</div>
<?php if (count($data['streaming_links']) > 0): ?>
<hr />
<h4>Streaming on:</h4>
<table class="full-width invisible streaming-links">
<thead>
<tr>
<th class="align-left">Service</th>
<th>Subtitles</th>
<th>Dubs</th>
</tr>
</thead>
<tbody>
<?php foreach ($data['streaming_links'] as $link): ?>
<tr>
<td class="align-left">
<?php if ($link['meta']['link'] !== FALSE): ?>
<a
href="<?= $link['link'] ?>"
title="Stream '<?= $data['title'] ?>' on <?= $link['meta']['name'] ?>"
>
<?= $helper->img("/public/images/{$link['meta']['image']}", [
'class' => 'streaming-logo',
'width' => 50,
'height' => 50,
'alt' => "{$link['meta']['name']} logo",
]) ?>
&nbsp;&nbsp;<?= $link['meta']['name'] ?>
</a>
<?php else: ?>
<?= $helper->img("/public/images/{$link['meta']['image']}", [
'class' => 'streaming-logo',
'width' => 50,
'height' => 50,
'alt' => "{$link['meta']['name']} logo",
]) ?>
&nbsp;&nbsp;<?= $link['meta']['name'] ?>
<?php endif ?>
</td>
<td><?= implode(', ', array_map(fn ($sub) => Locale::getDisplayLanguage($sub, 'en'), $link['subs'])) ?></td>
<td><?= implode(', ', array_map(fn ($dub) => Locale::getDisplayLanguage($dub, 'en'), $link['dubs'])) ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php endif ?>
<?php if ( ! empty($data['trailer_id'])): ?>
<div class="responsive-iframe">
<h4>Trailer</h4>
<iframe
width="560"
height="315"
role='img'
src="https://www.youtube.com/embed/<?= $data['trailer_id'] ?>"
allow="autoplay; encrypted-media"
allowfullscreen
tabindex='0'
title="<?= $data['title'] ?> trailer video"
></iframe>
</div>
<?php endif ?>
</article>
</section>
<?php if (count($data['characters']) > 0): ?>
<section>
<h2>Characters</h2>
<?= $component->tabs('character-types', $data['characters'], static function ($characterList, $role)
use ($component, $url, $helper) {
$rendered = [];
foreach ($characterList as $id => $character):
if (empty($character['image']))
{
continue;
}
$rendered[] = $component->character(
$character['name'],
$url->generate('character', ['slug' => $character['slug']]),
$helper->img($character['image']),
(strtolower($role) !== 'main') ? 'small-character' : 'character'
);
endforeach;
return implode('', array_map('mb_trim', $rendered));
}) ?>
</section>
<?php endif ?>
<?php if (count($data['staff']) > 0): ?>
<section>
<h2>Staff</h2>
<?= $component->verticalTabs('staff-role', $data['staff'], static function ($staffList)
use ($component, $url, $helper) {
$rendered = [];
foreach ($staffList as $id => $person):
if (empty($person['image']))
{
continue;
}
$rendered[] = $component->character(
$person['name'],
$url->generate('person', ['slug' => $person['slug']]),
$helper->img($person['image']),
'character small-person',
);
endforeach;
return implode('', array_map('mb_trim', $rendered));
}) ?>
</section>
<?php endif ?>
</main>

View File

@ -1,8 +1,112 @@
<body>
<?php include 'nav.php' ?>
<?php if ($auth->isAuthenticated()): ?>
<main>
<h2>Edit Anime List Item</h2>
<form action="<?= $action ?>" method="post">
<table class="invisible form">
<thead>
<tr>
<th>
<h3><?= $escape->html($item['anime']['title']) ?></h3>
<?php foreach($item['anime']['titles'] as $title): ?>
<h4><?= $escape->html($title) ?></h4>
<?php endforeach ?>
</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="9">
<?= $helper->img($item['anime']['cover_image']) ?>
</td>
</tr>
<tr>
<td><label for="private">Is Private?</label></td>
<td>
<input type="checkbox" name="private" id="private"
<?php if($item['private']): ?>checked="checked"<?php endif ?>
/>
</td>
</tr>
<tr>
<td><label for="watching_status">Watching Status</label></td>
<td>
<select name="watching_status" id="watching_status">
<?php foreach($statuses as $status_key => $status_title): ?>
<option <?php if(strtolower($item['watching_status']) === $status_key): ?>selected="selected"<?php endif ?>
value="<?= $status_key ?>"><?= $status_title ?></option>
<?php endforeach ?>
</select>
</td>
</tr>
<tr>
<td><label for="series_rating">Rating</label></td>
<td>
<input type="number" min="0" max="10" maxlength="2" name="user_rating" id="series_rating" value="<?= $item['user_rating'] ?>" id="series_rating" size="2" /> / 10
</td>
</tr>
<tr>
<td><label for="episodes_watched">Episodes Watched</label></td>
<td>
<input type="number" min="0" size="4" maxlength="4" value="<?= $item['episodes']['watched'] ?>" name="episodes_watched" id="episodes_watched" />
<?php if($item['episodes']['total'] > 0): ?>
/ <?= $item['episodes']['total'] ?>
<?php endif ?>
</td>
</tr>
<tr>
<td><label for="rewatching_flag">Rewatching?</label></td>
<td>
<input type="checkbox" name="rewatching" id="rewatching_flag"
<?php if($item['rewatching'] === TRUE): ?>checked="checked"<?php endif ?>
/>
</td>
</tr>
<tr>
<td><label for="rewatched">Rewatch Count</label></td>
<td>
<input type="number" min="0" id="rewatched" name="rewatched" value="<?= $item['rewatched'] ?>" />
</td>
</tr>
<tr>
<td><label for="notes">Notes</label></td>
<td>
<textarea name="notes" id="notes"><?= $escape->html($item['notes']) ?></textarea>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<input type="hidden" value="<?= $item['id'] ?>" name="id" />
<?php if ( ! empty($item['mal_id'])): ?>
<input type="hidden" value="<?= $item['mal_id'] ?? '' ?>" name="mal_id" />
<?php endif ?>
<input type="hidden" value="true" name="edit" />
<button type="submit">Submit</button>
</td>
</tr>
</tbody>
</table>
</form>
<form class="js-delete" action="<?= $url->generate('anime.delete') ?>" method="post">
<fieldset>
<legend>Danger Zone</legend>
<table class="form invisible">
<tbody>
<tr>
<td class="danger">
<strong>Permanently</strong> remove this list item and <strong>all</strong> its data?
</td>
<td>
<input type="hidden" value="<?= $item['id'] ?>" name="id" />
<?php if (!empty($item['mal_id'])): ?>
<input type="hidden" value="<?= $item['mal_id'] ?? '' ?>" name="mal_id" />
<?php endif ?>
<button type="submit" class="danger">Delete Entry</button>
</td>
</tr>
</tbody>
</table>
</fieldset>
</form>
</main>
</body>
</html>
<?php endif ?>

View File

@ -1,36 +1,113 @@
<main>
<?php use function Aviat\AnimeClient\colNotEmpty; ?>
<main class="media-list">
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $url->generate('anime.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
<?php else: ?>
<br />
<label>Filter: <input type='text' class='media-filter' /></label>
<br />
<?php foreach ($sections as $name => $items): ?>
<h2><?= $name ?></h2>
<table>
<thead>
<tr>
<th>Title</th>
<th>Alternate Title</th>
<th>Airing Status</th>
<th>Score</th>
<th>Type</th>
<th>Progress</th>
<th>Rated</th>
</tr>
</thead>
<tbody>
<?php foreach($items as $item): ?>
<tr id="a-<?= $item['anime']['id'] ?>">
<td class="align_left">
<a href="<?= $item['anime']['url'] ?>">
<?= $item['anime']['title'] ?>
</a>
</td>
<td class="align_left"><?= $item['anime']['alternate_title'] ?></td>
<td class="align_left"><?= $item['anime']['status'] ?></td>
<td><?= (int)($item['rating']['value'] * 2) ?> / 10 </td>
<td><?= $item['anime']['show_type'] ?></td>
<td>Episodes: <?= $item['episodes_watched'] ?> / <?= $item['anime']['episode_count'] ?></td>
<td><?= $item['anime']['age_rating'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php if (empty($items)): ?>
<h3>There's nothing here!</h3>
<?php else: ?>
<?php
$hasNotes = colNotEmpty($items, 'notes');
?>
<table class='media-wrap'>
<thead>
<tr>
<?php if($auth->isAuthenticated()): ?>
<td class="no-border">&nbsp;</td>
<?php endif ?>
<th>Title</th>
<th>Airing Status</th>
<th class='numeric'>Score</th>
<th>Type</th>
<th class='numeric'>Progress</th>
<th class='rating'>Age Rating</th>
<th>Attributes</th>
<?php if($hasNotes): ?><th>Notes</th><?php endif ?>
</tr>
</thead>
<tbody>
<?php foreach($items as $item): ?>
<?php if ($item['private'] && ! $auth->isAuthenticated()) continue; ?>
<tr id="a-<?= $item['id'] ?>">
<?php if ($auth->isAuthenticated()): ?>
<td>
<a class="bracketed" href="<?= $url->generate('edit', [
'controller' => 'anime',
'id' => $item['id'],
'status' => $item['watching_status']
]) ?>">Edit</a>
</td>
<?php endif ?>
<td class="align-left justify">
<a href="<?= $url->generate('anime.details', ['id' => $item['anime']['slug']]) ?>">
<?= $item['anime']['title'] ?>
</a>
<br />
<?= implode('<br />', $item['anime']['titles']) ?>
</td>
<td><?= $item['airing']['status'] ?></td>
<td><?= $item['user_rating'] ?> / 10 </td>
<td><?= $item['anime']['show_type'] ?></td>
<td id="<?= $item['anime']['slug'] ?>">
Episodes: <br />
<span class="completed_number"><?= $item['episodes']['watched'] ?></span>&nbsp;/&nbsp;<span class="total_number"><?= $item['episodes']['total'] ?></span>
</td>
<td><?= $item['anime']['age_rating'] ?></td>
<td>
<?php foreach($item['anime']['streaming_links'] as $link): ?>
<?php if ($link['meta']['link'] !== FALSE): ?>
<a href="<?= $link['link'] ?>" title="Stream '<?= $item['anime']['title'] ?>' on <?= $link['meta']['name'] ?>">
<?= $helper->img("/public/images/{$link['meta']['image']}", [
'class' => 'small-streaming-logo',
'width' => 25,
'height' => 25,
'alt' => "{$link['meta']['name']} logo",
]) ?>
</a>
<?php else: ?>
<?= $helper->img("/public/images/{$link['meta']['image']}", [
'class' => 'small-streaming-logo',
'width' => 25,
'height' => 25,
'alt' => "{$link['meta']['name']} logo",
]) ?>
<?php endif ?>
<?php endforeach ?>
<br />
<ul>
<?php if ($item['rewatched'] > 0): ?>
<?php if ($item['rewatched'] == 1): ?>
<li>Rewatched once</li>
<?php elseif ($item['rewatched'] == 2): ?>
<li>Rewatched twice</li>
<?php elseif ($item['rewatched'] == 3): ?>
<li>Rewatched thrice</li>
<?php else: ?>
<li>Rewatched <?= $item['rewatched'] ?> times</li>
<?php endif ?>
<?php endif ?>
<?php foreach(['private','rewatching'] as $attr): ?>
<?php if($item[$attr]): ?><li><?= ucfirst($attr); ?></li><?php endif ?>
<?php endforeach ?>
</ul>
</td>
<?php if ($hasNotes): ?><td><p><?= $escape->html($item['notes']) ?></p></td><?php endif ?>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php endif ?>
<?php endforeach ?>
<?php endif ?>
</main>
<script src="<?= asset_url('js.php?g=table') ?>"></script>
<script defer="defer" src="<?= $urlGenerator->assetUrl('js/tables.min.js') ?>"></script>

3
app/views/blank.php Normal file
View File

@ -0,0 +1,3 @@
<main>
<h1><?= $title ?></h1>
</main>

View File

@ -0,0 +1,162 @@
<?php
use function Aviat\AnimeClient\getLocalImg;
use Aviat\AnimeClient\Kitsu;
?>
<main class="character-page details fixed">
<section class="flex flex-no-wrap">
<aside>
<?= $helper->img($data['image']) ?>
</aside>
<div>
<h2 class="toph"><?= $data['name'] ?></h2>
<?php foreach ($data['names'] as $name): ?>
<h3><?= $name ?></h3>
<?php endforeach ?>
<?php if ( ! empty($data['otherNames'])): ?>
<h4>Also Known As:</h4>
<ul>
<?php foreach ($data['otherNames'] as $name): ?>
<li><h5><?= $name ?></h5></li>
<?php endforeach ?>
</ul>
<?php endif ?>
<br />
<hr />
<div class="description">
<p><?= nl2br($data['description']) ?></p>
</div>
</div>
</section>
<?php if ( ! (empty($data['media']['anime']) || empty($data['media']['manga']))): ?>
<h3>Media</h3>
<?= $component->tabs('character-media', $data['media'], static function ($media, $mediaType) use ($url, $component, $helper) {
$rendered = [];
foreach ($media as $id => $item)
{
$rendered[] = $component->media(
array_merge([$item['title']], $item['titles']),
$url->generate("{$mediaType}.details", ['id' => $item['slug']]),
$helper->img(Kitsu::getPosterImage($item), ['width' => 220, 'loading' => 'lazy']),
);
}
return implode('', array_map('mb_trim', $rendered));
}, 'media-wrap content') ?>
<?php endif ?>
<section>
<?php if (count($data['castings']) > 0): ?>
<h3>Castings</h3>
<?php
$vas = $data['castings']['Voice Actor'];
unset($data['castings']['Voice Actor']);
ksort($vas)
?>
<?php foreach ($data['castings'] as $role => $entries): ?>
<h4><?= $role ?></h4>
<?php foreach ($entries as $language => $casting): ?>
<h5><?= $language ?></h5>
<table class="min-table">
<tr>
<th>Cast Member</th>
<th>Series</th>
</tr>
<?php foreach ($casting as $cid => $c): ?>
<tr>
<td>
<article class="character">
<?php
$link = $url->generate('person', ['id' => $c['person']['id']]);
?>
<a href="<?= $link ?>">
<?= $helper->img($c['person']['image']) ?>
<div class="name">
<?= $c['person']['name'] ?>
</div>
</a>
</article>
</td>
<td>
<section class="align-left media-wrap">
<?php foreach ($c['series'] as $series): ?>
<article class="media">
<?php
$link = $url->generate('anime.details', ['id' => $series['attributes']['slug']]);
$titles = Kitsu::filterTitles($series['attributes']);
?>
<a href="<?= $link ?>">
<?= $helper->img(Kitsu::getPosterImage($series['attributes'])) ?>
</a>
<div class="name">
<a href="<?= $link ?>">
<?= array_shift($titles) ?>
<?php foreach ($titles as $title): ?>
<br />
<small><?= $title ?></small>
<?php endforeach ?>
</a>
</div>
</article>
<?php endforeach ?>
</section>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endforeach ?>
<?php endforeach ?>
<?php if ( ! empty($vas)): ?>
<h4>Voice Actors</h4>
<?= $component->tabs('character-vas', $vas, static function ($casting) use ($url, $component, $helper) {
$castings = [];
foreach ($casting as $id => $c):
$person = $component->character(
$c['person']['name'],
$url->generate('person', ['slug' => $c['person']['slug']]),
$helper->img($c['person']['image']['original']['url']),
);
$medias = array_map(fn ($series) => $component->media(
array_merge([$series['title']], $series['titles']),
$url->generate('anime.details', ['id' => $series['slug']]),
$helper->img(Kitsu::getPosterImage($series)),
), $c['series']);
$media = implode('', array_map('mb_trim', $medias));
$castings[] = <<<HTML
<tr>
<td>{$person}</td>
<td width="75%">
<section class="align-left media-wrap-flex">
{$media}
</section>
</td>
</tr>
HTML;
endforeach;
$languages = implode('', array_map('mb_trim', $castings));
return <<<HTML
<table class="borderless max-table">
<thead>
<tr>
<th>Cast Member</th>
<th>Series</th>
</tr>
</thead>
<tbody>{$languages}</tbody>
</table>
HTML;
}, 'content') ?>
<?php endif ?>
<?php endif ?>
</section>
</main>

View File

@ -0,0 +1,39 @@
<?php if ($auth->isAuthenticated()): ?>
<main>
<h2>Add <?= ucfirst($collection_type) ?> to your Collection</h2>
<form action="<?= $action_url ?>" method="post">
<?php include realpath(__DIR__ . '/../js-warning.php') ?>
<section>
<div class="cssload-loader" hidden="hidden">
<div class="cssload-inner cssload-one"></div>
<div class="cssload-inner cssload-two"></div>
<div class="cssload-inner cssload-three"></div>
</div>
<label for="search-anime-collection">Search for <?= $collection_type ?> by name:&nbsp;&nbsp;&nbsp;&nbsp;<input type="search" id="search-anime-collection" name="search" /></label>
<section id="series-list" class="media-wrap">
</section>
</section>
<br />
<table class="invisible form">
<tbody>
<tr>
<td class="align-right"><label for="media_id">Media</label></td>
<td class='align-left'>
<?php include 'media-select-list.php' ?>
</td>
</tr>
<tr>
<td><label for="notes">Notes</label></td>
<td><textarea id="notes" name="notes"></textarea></td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<button type="submit">Save</button>
</td>
</tr>
</tbody>
</table>
</form>
</main>
<?php endif ?>

View File

@ -0,0 +1,28 @@
<article class="media" id="a-<?= $item['hummingbird_id'] ?>">
<?= $helper->picture("images/anime/{$item['hummingbird_id']}.webp") ?>
<div class="name">
<a href="<?= $url->generate('anime.details', ['id' => $item['slug']]) ?>">
<?= $item['title'] ?>
<?= ($item['alternate_title'] != "") ? "<small><br />{$item['alternate_title']}</small>" : ""; ?>
</a>
</div>
<div class="table">
<?php if ($auth->isAuthenticated()): ?>
<div class="row">
<span class="edit">
<a class="bracketed"
href="<?= $url->generate($collection_type . '.collection.edit.get', [
'id' => $item['hummingbird_id']
]) ?>">Edit</a>
</span>
</div>
<?php endif ?>
<div class="row">
<?php if ($item['episode_count'] > 1): ?>
<div class="completion">Episodes: <?= $item['episode_count'] ?></div>
<?php endif ?>
<div class="media_type"><?= $item['show_type'] ?></div>
<div class="age-rating"><?= $item['age_rating'] ?></div>
</div>
</div>
</article>

View File

@ -0,0 +1,28 @@
<?php use function Aviat\AnimeClient\renderTemplate; ?>
<main class="media-list">
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $url->generate($collection_type . '.collection.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
<?php else: ?>
<br />
<label>Filter: <input type='text' class='media-filter' /></label>
<br />
<?= $component->tabs('collection-tab', $sections, static function ($items) use ($auth, $collection_type, $helper, $url, $component) {
$rendered = [];
foreach ($items as $item)
{
$rendered[] = renderTemplate(__DIR__ . '/cover-item.php', [
'auth' => $auth,
'collection_type' => $collection_type,
'helper' => $helper,
'item' => $item,
'url' => $url,
]);
}
return implode('', array_map('mb_trim', $rendered));
}, 'media-wrap', true) ?>
<?php endif ?>
</main>

View File

@ -0,0 +1,66 @@
<?php use function Aviat\AnimeClient\renderTemplate ?>
<?php if ($auth->isAuthenticated()): ?>
<main>
<h2>Edit Anime Collection Item</h2>
<form action="<?= $action_url ?>" method="post">
<table class="invisible form">
<tbody>
<tr>
<td rowspan="6" class="align-center">
<?= $helper->picture("images/anime/{$item['hummingbird_id']}-original.webp", "jpg", [], ["width" => "390"]) ?>
</td>
</tr>
<tr>
<td class="align-right"><label for="title">Title</label></td>
<td class="align-left">
<input type="text" id="title" name="title" value="<?= $item['title'] ?>" />
</td>
</tr>
<tr>
<td class="align-right"><label for="alternate_title">Alternate Title</label></td>
<td class="align-left">
<input type="text" id="alternate_title" name="alternate_title" value="<?= $item['alternate_title'] ?>"/>
</td>
</tr>
<tr>
<td class="align-right"><label for="media_id">Media</label></td>
<td class="align-left">
<?php include 'media-select-list.php' ?>
</td>
</tr>
<tr>
<td><label for="notes">Notes</label></td>
<td><textarea id="notes" name="notes"><?= $escape->html($item['notes']) ?></textarea></td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<?php if($action === 'Edit'): ?>
<input type="hidden" name="hummingbird_id" value="<?= $item['hummingbird_id'] ?>" />
<?php endif ?>
<button type="submit">Save</button>
</td>
</tr>
</tbody>
</table>
</form>
<form class="js-delete" action="<?= $url->generate($collection_type . '.collection.delete') ?>" method="post">
<fieldset>
<legend>Danger Zone</legend>
<table class="form invisible">
<tbody>
<tr>
<td class="danger">
<strong>Permanently</strong> remove this list item and <strong>all</strong> its data?
</td>
<td>
<input type="hidden" value="<?= $item['hummingbird_id'] ?>" name="hummingbird_id" />
<button type="submit" class="danger">Delete Entry</button>
</td>
</tr>
</tbody>
</table>
</fieldset>
</form>
</main>
<?php endif ?>

View File

@ -0,0 +1,23 @@
<tr>
<?php if ($auth->isAuthenticated()): ?>
<td>
<a class="bracketed"
href="<?= $url->generate($collection_type . '.collection.edit.get', ['id' => $item['hummingbird_id']]) ?>">Edit</a>
</td>
<?php endif ?>
<td class="align-left">
<a href="<?= $url->generate('anime.details', ['id' => $item['slug']]) ?>">
<?= $item['title'] ?>
</a>
<?= ! empty($item['alternate_title']) ? ' <br /><small> ' . $item['alternate_title'] . '</small>' : '' ?>
</td>
<?php if ($hasMedia): ?>
<td><?= implode(', ', $item['media']) ?></td>
<?php endif ?>
<td><?= ($item['episode_count'] > 1) ? $item['episode_count'] : '-' ?></td>
<td><?= $item['episode_length'] ?></td>
<td><?= $item['show_type'] ?></td>
<td><?= $item['age_rating'] ?></td>
<?php if ($hasNotes): ?><td class="align-left"><?= nl2br($item['notes'], TRUE) ?></td><?php endif ?>
<td class="align-left"><?= implode(', ', $item['genres']) ?></td>
</tr>

View File

@ -0,0 +1,56 @@
<?php use function Aviat\AnimeClient\{colNotEmpty, renderTemplate}; ?>
<main>
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $url->generate($collection_type . '.collection.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
<?php else: ?>
<br />
<label>Filter: <input type='text' class='media-filter' /></label>
<br />
<?= $component->tabs('collection-tab', $sections, static function ($items, $section) use ($auth, $helper, $url, $collection_type) {
$hasNotes = colNotEmpty($items, 'notes');
$hasMedia = $section === 'All';
$firstTh = ($auth->isAuthenticated()) ? '<td>&nbsp;</td>' : '';
$mediaTh = ($hasMedia) ? '<th>Media</th>' : '';
$noteTh = ($hasNotes) ? '<th>Notes</th>' : '';
$rendered = [];
foreach ($items as $item)
{
$rendered[] = renderTemplate(__DIR__ . '/list-item.php', [
'auth' => $auth,
'collection_type' => $collection_type,
'hasMedia' => $hasMedia,
'hasNotes' => $hasNotes,
'helper' => $helper,
'item' => $item,
'url' => $url,
]);
}
$rows = implode('', array_map('mb_trim', $rendered));
return <<<HTML
<table class="full-width media-wrap">
<thead>
<tr>
{$firstTh}
<th>Title</th>
{$mediaTh}
<th class='numeric'>Episode Count</th>
<th class='numeric'>Episode Length</th>
<th>Show Type</th>
<th class='rating'>Age Rating</th>
{$noteTh}
<th>Genres</th>
</tr>
</thead>
<tbody>{$rows}</tbody>
</table>
HTML;
}) ?>
<?php endif ?>
</main>
<script defer="defer" src="<?= $urlGenerator->assetUrl('js/tables.min.js') ?>"></script>

View File

@ -0,0 +1,11 @@
<select name="media_id[]" id="media_id" multiple size="13">
<?php foreach ($media_items as $group => $items): ?>
<optgroup label='<?= $group ?>'>
<?php foreach ($items as $id => $name): ?>
<option <?= in_array($id, ($item['media_id'] ?? []), FALSE) ? 'selected="selected"' : '' ?> value="<?= $id ?>">
<?= $name ?>
</option>
<?php endforeach ?>
</optgroup>
<?php endforeach ?>
</select>

5
app/views/error.php Normal file
View File

@ -0,0 +1,5 @@
<main>
<h1><?= $title ?></h1>
<h2><?= $message ?></h2>
<div><?= $long_message ?></div>
</main>

View File

@ -1,2 +1,16 @@
<section id="loading-shadow" hidden="hidden">
<div class="loading-wrapper">
<div class="loading-content">
<h3>Updating List Item...</h3>
<div class="cssload-loader">
<div class="cssload-inner cssload-one"></div>
<div class="cssload-inner cssload-two"></div>
<div class="cssload-inner cssload-three"></div>
</div>
</div>
</div>
</section>
<script nomodule="nomodule" src="https://polyfill.io/v3/polyfill.min.js?features=es5%2CObject.assign"></script>
<script async="async" defer="defer" src="<?= $urlGenerator->assetUrl('js/scripts.min.js') ?>"></script>
</body>
</html>

View File

@ -1,35 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title><?= $title ?></title>
<meta charset="utf-8" />
<link rel="stylesheet" href="<?= asset_url('css.php?g=base') ?>" />
<script>
var BASE_URL = "<?= base_url($url_type) ?>";
var CONTROLLER = "<?= $url_type ?>";
</script>
<meta http-equiv="cache-control" content="no-store" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self'" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=1" />
<link rel="stylesheet" href="<?= $urlGenerator->assetUrl('css/' . $config->get('theme') . '.min.css') ?>" />
<link rel="<?= $config->get('theme') === 'dark' ? '' : 'alternate ' ?>stylesheet" title="Dark Theme" href="<?= $urlGenerator->assetUrl('css/dark.min.css') ?>" />
<link rel="icon" href="<?= $urlGenerator->assetUrl('images/icons/favicon.ico') ?>" />
<link rel="apple-touch-icon" sizes="57x57" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-57x57.png') ?>">
<link rel="apple-touch-icon" sizes="60x60" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-60x60.png') ?>">
<link rel="apple-touch-icon" sizes="72x72" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-72x72.png') ?>">
<link rel="apple-touch-icon" sizes="76x76" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-76x76.png') ?>">
<link rel="apple-touch-icon" sizes="114x114" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-114x114.png') ?>">
<link rel="apple-touch-icon" sizes="120x120" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-120x120.png') ?>">
<link rel="apple-touch-icon" sizes="144x144" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-144x144.png') ?>">
<link rel="apple-touch-icon" sizes="152x152" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-152x152.png') ?>">
<link rel="apple-touch-icon" sizes="180x180" href="<?= $urlGenerator->assetUrl('images/icons/apple-icon-180x180.png') ?>">
<link rel="icon" type="image/png" sizes="192x192" href="<?= $urlGenerator->assetUrl('images/icons/android-icon-192x192.png') ?>">
<link rel="icon" type="image/png" sizes="32x32" href="<?= $urlGenerator->assetUrl('images/icons/favicon-32x32.png') ?>">
<link rel="icon" type="image/png" sizes="96x96" href="<?= $urlGenerator->assetUrl('images/icons/favicon-96x96.png') ?>">
<link rel="icon" type="image/png" sizes="16x16" href="<?= $urlGenerator->assetUrl('images/icons/favicon-16x16.png') ?>">
</head>
<body class="<?= $url_type ?> list">
<h1 class="flex flex-align-end flex-wrap">
<span class="flex-no-wrap grow-1"><?= WHOSE ?> <?= ucfirst($url_type) ?> <?= (strpos($route_path, 'collection') !== FALSE) ? 'Collection' : 'List' ?> [<a href="<?= full_url("", $other_type) ?>"><?= ucfirst($other_type) ?> List</a>]</span>
<span class="flex-no-wrap small-font">
<?php if (is_logged_in()): ?>
[<a href="<?= full_url("/logout", $url_type) ?>">Logout</a>]
<?php else: ?>
[<a href="<?= full_url("/login", $url_type) ?>"><?= WHOSE ?> Login</a>]
<?php endif ?>
</span>
</h1>
<nav>
<ul>
<?php foreach($nav_routes as $title => $nav_path): ?>
<li class="<?= is_selected($nav_path, $route_path) ?>"><a href="<?= full_url($nav_path, $url_type) ?>"><?= $title ?></a></li>
<?php endforeach ?>
</ul>
<br />
<ul>
<li class="<?= is_not_selected('list', last_segment()) ?>"><a href="<?= full_url($route_path, $url_type) ?>">Cover View</a></li>
<li class="<?= is_selected('list', last_segment()) ?>"><a href="<?= full_url("{$route_path}/list", $url_type) ?>">List View</a></li>
</ul>
</nav>
<br />
<body class="<?= $escape->attr($url_type) ?> list">
<?php include 'setup-check.php' ?>
<header>
<?php
include 'main-menu.php';
if(isset($message) && is_array($message))
{
foreach($message as $m)
{
$message = $m['message'];
$message_type = $m['message_type'];
include 'message.php';
}
}
?>
</header>

47
app/views/history.php Normal file
View File

@ -0,0 +1,47 @@
<main class="details fixed">
<?php if (empty($items)): ?>
<h3>No recent history.</h3>
<?php else: ?>
<section>
<?php foreach ($items as $name => $item): ?>
<article class="flex flex-no-wrap flex-justify-start">
<section class="flex-self-center history-img">
<a href="<?= $item['url'] ?>">
<?= $helper->img(
$item['coverImg'],
['width' => '110px', 'height' => '156px'],
) ?>
</a>
</section>
<section class="flex-self-center">
<?= $helper->a($item['url'], $item['title']) ?>
<br />
<br />
<?= $item['action'] ?>
<br />
<small>
<?php if ( ! empty($item['dateRange'])):
[$startDate, $endDate] = array_map(
fn ($date) => $date->format('l, F d'),
$item['dateRange']
);
[$startTime, $endTime] = array_map(
fn ($date) => $date->format('h:i:s A'),
$item['dateRange']
);
?>
<?php if ($startDate === $endDate): ?>
<?= "{$startDate}, {$startTime} &ndash; {$endTime}" ?>
<?php else: ?>
<?= "{$startDate} {$startTime} &ndash; {$endDate} {$endTime}" ?>
<?php endif ?>
<?php else: ?>
<?= $item['updated']->format('l, F d h:i:s A') ?>
<?php endif ?>
</small>
</section>
</article>
<?php endforeach ?>
</section>
<?php endif ?>
</main>

6
app/views/js-warning.php Normal file
View File

@ -0,0 +1,6 @@
<noscript>
<div class="message error">
<span class="icon"></span>
This feature requires Javascript to function :(
</div>
</noscript>

View File

@ -1,17 +1,16 @@
<main>
<h2><?= $config->get('whose_list'); ?>'s Login</h2>
<?= $message ?>
<aside>
<form method="post" action="<?= full_url('/login', $url_type) ?>">
<dl>
<dt><label for="username">Username: </label></dt>
<dd><input type="text" id="username" name="username" required="required" /></dd>
<dt><label for="password">Password: </label></dt>
<dd><input type="password" id="password" name="password" required="required" /></dd>
<dt>&nbsp;</dt>
<dd><input type="submit" value="Login" /></dd>
</dl>
</form>
</aside>
<form method="post" action="<?= $url->generate('login.post') ?>">
<table class="form invisible">
<tr>
<td><label for="password">Password: </label></td>
<td><input type="password" id="password" name="password" required="required" /></td>
</tr>
<tr>
<td>&nbsp;</td>
<td><button type="submit">Login</button></td>
</tr>
</table>
</form>
</main>

114
app/views/main-menu.php Normal file
View File

@ -0,0 +1,114 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 8.1
*
* @copyright 2015 - 2023 Timothy J. Warren <tim@timshome.page>
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5.2
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient;
$whose = $config->get('whose_list') . "'s ";
$lastSegment = $urlGenerator->lastSegment();
$extraSegment = $lastSegment === 'list' ? '/list' : '';
$hasAnime = str_contains($GLOBALS['_SERVER']['REQUEST_URI'], 'anime');
$hasManga = str_contains($GLOBALS['_SERVER']['REQUEST_URI'], 'manga');
?>
<div id="main-nav" class="flex flex-align-end flex-wrap">
<span class="flex-no-wrap grow-1">
<?php if( ! str_contains($route_path, 'collection')): ?>
<?= $helper->a(
$urlGenerator->defaultUrl($url_type),
$whose . ucfirst($url_type) . ' List',
['aria-current'=> 'page']
) ?>
<?php if($config->get("show_{$url_type}_collection")): ?>
[<?= $helper->a(
$url->generate("{$url_type}.collection.view") . $extraSegment,
ucfirst($url_type) . ' Collection'
) ?>]
<?php endif ?>
<?php if($config->get("show_{$other_type}_collection")): ?>
[<?= $helper->a(
$url->generate("{$other_type}.collection.view") . $extraSegment,
ucfirst($other_type) . ' Collection'
) ?>]
<?php endif ?>
[<?= $helper->a(
$urlGenerator->defaultUrl($other_type) . $extraSegment,
ucfirst($other_type) . ' List'
) ?>]
<?php else: ?>
<?= $helper->a(
$url->generate("{$url_type}.collection.view") . $extraSegment,
$whose . ucfirst($url_type) . ' Collection',
['aria-current'=> 'page']
) ?>
<?php if($config->get("show_{$other_type}_collection")): ?>
[<?= $helper->a(
$url->generate("{$other_type}.collection.view") . $extraSegment,
ucfirst($other_type) . ' Collection'
) ?>]
<?php endif ?>
[<?= $helper->a($urlGenerator->defaultUrl('anime') . $extraSegment, 'Anime List') ?>]
[<?= $helper->a($urlGenerator->defaultUrl('manga') . $extraSegment, 'Manga List') ?>]
<?php endif ?>
<?php if ($auth->isAuthenticated() && $config->get(['cache', 'driver']) !== 'null'): ?>
<span class="flex-no-wrap small-font">
<button type="button" class="js-clear-cache user-btn">Clear API Cache</button>
</span>
<?php endif ?>
</span>
<span class="flex-no-wrap small-font">[<?= $helper->a(
$url->generate('default_user_info'),
'About '. $config->get('whose_list')
) ?>]</span>
<?php if ($auth->isAuthenticated()): ?>
<span class="flex-no-wrap small-font">
<?= $helper->a(
$url->generate('settings'),
'Settings',
['class' => 'bracketed']
) ?>
</span>
<span class="flex-no-wrap small-font">
<?= $helper->a(
$url->generate('logout'),
'Logout',
['class' => 'bracketed']
) ?>
</span>
<?php else: ?>
<span class="flex-no-wrap small-font">
[<?= $helper->a($url->generate('login'), "{$whose} Login") ?>]
</span>
<?php endif ?>
</div>
<?php if ($container->get('util')->isViewPage() && ($hasAnime || $hasManga)): ?>
<nav>
<?= $helper->menu($menu_name) ?>
<?php if (stripos($GLOBALS['_SERVER']['REQUEST_URI'], 'history') === FALSE): ?>
<br />
<ul>
<?php $currentView = Util::eq('list', $lastSegment) ? 'list' : 'cover' ?>
<li class="<?= Util::isNotSelected('list', $lastSegment) ?>">
<a aria-current="<?= Util::ariaCurrent($currentView === 'cover') ?>"
href="<?= $urlGenerator->url($route_path) ?>">Cover View</a>
</li>
<li class="<?= Util::isSelected('list', $lastSegment) ?>">
<a aria-current="<?= Util::ariaCurrent($currentView === 'list') ?>"
href="<?= $urlGenerator->url("{$route_path}/list") ?>">List View</a>
</li>
</ul>
<?php endif ?>
</nav>
<?php endif ?>

40
app/views/manga/add.php Normal file
View File

@ -0,0 +1,40 @@
<?php if ($auth->isAuthenticated()): ?>
<main>
<h2>Add Manga to your List</h2>
<form action="<?= $action_url ?>" method="post">
<?php include realpath(__DIR__ . '/../js-warning.php') ?>
<section>
<div class="cssload-loader" hidden="hidden">
<div class="cssload-inner cssload-one"></div>
<div class="cssload-inner cssload-two"></div>
<div class="cssload-inner cssload-three"></div>
</div>
<label for="search">Search for manga by name:&nbsp;&nbsp;&nbsp;&nbsp;<input type="search" id="search" /></label>
<section id="series-list" class="media-wrap">
</section>
</section>
<br />
<table class="invisible form">
<tbody>
<tr>
<td><label for="status">Reading Status</label></td>
<td>
<select name="status" id="status">
<?php foreach($status_list as $status_key => $status_title): ?>
<option value="<?= $status_key ?>"><?= $status_title ?></option>
<?php endforeach ?>
</select>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<input type="hidden" name="type" value="manga" />
<button type="submit">Save</button>
</td>
</tr>
</tbody>
</table>
</form>
</main>
<?php endif ?>

View File

@ -1,49 +1,29 @@
<main>
<main class="media-list">
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $url->generate('manga.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
<?php else: ?>
<br />
<label>Filter: <input type='text' class='media-filter' /></label>
<br />
<?php foreach ($sections as $name => $items): ?>
<?php if (empty($items)): ?>
<section class="status">
<h2><?= $name ?></h2>
<h2><?= $escape->html($name) ?></h2>
<h3>There's nothing here!</h3>
</section>
<?php else: ?>
<section class="status">
<h2><?= $escape->html($name) ?></h2>
<section class="media-wrap">
<?php foreach($items as $item): ?>
<article class="media" id="manga-<?= $item['id'] ?>">
<?php if (is_logged_in()): ?>
<div class="edit_buttons" hidden>
<button class="plus_one_chapter">+1 Chapter</button>
<button class="plus_one_volume">+1 Volume</button>
</div>
<?php endif ?>
<img src="<?= $item['manga']['poster_image'] ?>" />
<div class="name">
<a href="https://hummingbird.me/manga/<?= $item['manga_id'] ?>">
<?= $item['manga']['romaji_title'] ?>
<?= (isset($item['manga']['english_title'])) ? "<br />({$item['manga']['english_title']})" : ""; ?>
</a>
</div>
<div class="table">
<div class="row">
<div class="user_rating">Rating: <?= ($item['rating'] > 0) ? (int)($item['rating'] * 2) : '-' ?> / 10</div>
</div>
<div class="row">
<div class="chapter_completion">
Chapters: <span class="chapters_read"><?= $item['chapters_read'] ?></span> /
<span class="chapter_count"><?= ($item['manga']['chapter_count'] > 0) ? $item['manga']['chapter_count'] : "-" ?></span>
</div>
</div>
<div class="row">
<div class="volume_completion">
Volumes: <span class="volumes_read"><?= $item['volumes_read'] ?></span> /
<span class="volume_count"><?= ($item['manga']['volume_count'] > 0) ? $item['manga']['volume_count'] : "-" ?></span>
</div>
</div>
</div>
<?php /*<div class="medium_metadata">
<div class="media_type"><?= $item['manga']['manga_type'] ?></div>
</div> */ ?>
</article>
<?= $component->mangaCover($item, $name) ?>
<?php endforeach ?>
</section>
</section>
<?php endif ?>
<?php endforeach ?>
</main>
<?php if (is_logged_in()): ?>
<script src="<?= asset_url('js.php?g=edit') ?>"></script>
<?php endif ?>
</main>

105
app/views/manga/details.php Normal file
View File

@ -0,0 +1,105 @@
<main class="details fixed">
<section class="flex flex-no-wrap">
<aside class="info">
<?= $helper->img($data['cover_image'], ['class' => 'cover', 'width' => '350']) ?>
<br />
<table class="media-details">
<tr>
<td class="align-right">Publishing Status</td>
<td><?= $data['status'] ?></td>
</tr>
<tr>
<td>Manga Type</td>
<td><?= ucfirst(strtolower($data['manga_type'])) ?></td>
</tr>
<?php if ( ! empty($data['volume_count'])): ?>
<tr>
<td>Volume Count</td>
<td><?= $data['volume_count'] ?></td>
</tr>
<?php endif ?>
<?php if ( ! empty($data['chapter_count'])): ?>
<tr>
<td>Chapter Count</td>
<td><?= $data['chapter_count'] ?></td>
</tr>
<?php endif ?>
<?php if ( ! empty($data['age_rating'])): ?>
<tr>
<td>Age Rating</td>
<td><abbr title="<?= $data['age_rating_guide'] ?>"><?= $data['age_rating'] ?></abbr>
</td>
</tr>
<?php endif ?>
<?php if (count($data['links']) > 0): ?>
<tr>
<td>External Links</td>
<td>
<?php foreach ($data['links'] as $urlName => $externalUrl): ?>
<a rel='external' href="<?= $externalUrl ?>"><?= $urlName ?></a><br />
<?php endforeach ?>
</td>
</tr>
<?php endif ?>
<tr>
<td>Genres</td>
<td>
<?= implode(', ', $data['genres']); ?>
</td>
</tr>
</table>
<br />
</aside>
<article class="text">
<h2 class="toph"><?= $data['title'] ?></h2>
<?php foreach ($data['titles_more'] as $title): ?>
<h3><?= $title ?></h3>
<?php endforeach ?>
<br />
<div class="description">
<p><?= str_replace("\n", '</p><p>', $data['synopsis']) ?></p>
</div>
</article>
</section>
<?php if (count($data['characters']) > 0): ?>
<h2>Characters</h2>
<?= $component->tabs('manga-characters', $data['characters'], static function($list, $role) use ($component, $helper, $url) {
$rendered = [];
foreach ($list as $id => $char)
{
$rendered[] = $component->character(
$char['name'],
$url->generate('character', ['slug' => $char['slug']]),
$helper->img($char['image'], ['loading' => 'lazy']),
($role !== 'main') ? 'small-character' : 'character'
);
}
return implode('', array_map('mb_trim', $rendered));
}) ?>
<?php endif ?>
<?php if (count($data['staff']) > 0): ?>
<h2>Staff</h2>
<?= $component->verticalTabs('manga-staff', $data['staff'],
fn($people) => implode('', array_map(
fn ($person) => $component->character(
$person['name'],
$url->generate('person', ['slug' => $person['slug']]),
$helper->img($person['image']),
),
$people
))
) ?>
<?php endif ?>
</main>

104
app/views/manga/edit.php Normal file
View File

@ -0,0 +1,104 @@
<?php if ($auth->isAuthenticated()): ?>
<main>
<h2>
Edit Manga List Item
</h2>
<form action="<?= $action ?>" method="post">
<table class="invisible form">
<thead>
<tr>
<th>
<h3><?= $escape->html($item['manga']['title']) ?></h3>
<?php foreach ($item['manga']['titles'] as $title): ?>
<h4><?= $escape->html($title) ?></h4>
<?php endforeach ?>
</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="9">
<?= $helper->img($item['manga']['image']) ?>
</td>
</tr>
<tr>
<td><label for="status">Reading Status</label></td>
<td>
<select name="status" id="status">
<?php foreach ($status_list as $val => $status): ?>
<option <?php if ($item['reading_status'] === $val): ?>selected="selected"<?php endif ?>
value="<?= $val ?>"><?= $status ?></option>
<?php endforeach ?>
</select>
</td>
</tr>
<tr>
<td><label for="series_rating">Rating</label></td>
<td>
<input type="number" min="0" max="10" maxlength="2" name="new_rating"
value="<?= $item['user_rating'] ?>" id="series_rating" size="2"/> / 10
</td>
</tr>
<tr>
<td><label for="chapters_read">Chapters Read</label></td>
<td>
<input type="number" min="0" name="chapters_read" id="chapters_read"
value="<?= $item['chapters']['read'] ?>"/> / <?= $item['chapters']['total'] ?>
</td>
</tr>
<tr>
<td><label for="rereading_flag">Rereading?</label></td>
<td>
<input type="checkbox" name="rereading" id="rereading_flag"
<?php if ($item['rereading'] === TRUE): ?>checked="checked"<?php endif ?>
/>
</td>
</tr>
<tr>
<td><label for="reread_count">Reread Count</label></td>
<td>
<input type="number" min="0" id="reread_count" name="reread_count"
value="<?= $item['reread'] ?>"/>
</td>
</tr>
<tr>
<td><label for="notes">Notes</label></td>
<td>
<textarea name="notes" id="notes"><?= $escape->html($item['notes']) ?></textarea>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<input type="hidden" value="<?= $item['id'] ?>" name="id"/>
<input type="hidden" value="<?= $item['mal_id'] ?>" name="mal_id"/>
<input type="hidden" value="<?= $item['manga']['slug'] ?>" name="manga_id"/>
<input type="hidden" value="<?= $item['user_rating'] ?>" name="old_rating"/>
<input type="hidden" value="true" name="edit"/>
<button type="submit">Submit</button>
</td>
</tr>
</tbody>
</table>
</form>
<fieldset>
<legend>Danger Zone</legend>
<form class="js-delete" action="<?= $url->generate('manga.delete') ?>" method="post">
<table class="form invisible">
<tbody>
<tr>
<td class="danger">
<strong>Permanently</strong> remove this list item and <strong>all</strong> its data?
</td>
<td>
<input type="hidden" value="<?= $item['id'] ?>" name="id"/>
<input type="hidden" value="<?= $item['mal_id'] ?>" name="mal_id"/>
<button type="submit" class="danger">Delete Entry</button>
</td>
</tr>
</tbody>
</table>
</form>
</fieldset>
</main>
<?php endif ?>

View File

@ -1,33 +1,78 @@
<main>
<main class="media-list">
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $url->generate('manga.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
<?php else: ?>
<br />
<label>Filter: <input type='text' class='media-filter' /></label>
<br />
<?php foreach ($sections as $name => $items): ?>
<h2><?= $name ?></h2>
<table>
<thead>
<tr>
<th>Title</th>
<th>Rating</th>
<th>Chapters</th>
<th>Volumes</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<?php foreach($items as $item): ?>
<tr id="manga-<?= $item['manga']['id'] ?>">
<td class="align_left">
<a href="https://hummingbird.me/manga/<?= $item['manga']['id'] ?>">
<?= $item['manga']['romaji_title'] ?>
</a>
<?= (array_key_exists('english_title', $item['manga'])) ? " &middot; " . $item['manga']['english_title'] : "" ?>
</td>
<td><?= ($item['rating'] > 0) ? (int)($item['rating'] * 2) : '-' ?> / 10</td>
<td><?= $item['chapters_read'] ?> / <?= ($item['manga']['chapter_count'] > 0) ? $item['manga']['chapter_count'] : "-" ?></td>
<td><?= $item['volumes_read'] ?> / <?= ($item['manga']['volume_count'] > 0) ? $item['manga']['volume_count'] : "-" ?></td>
<td><?= $item['manga']['manga_type'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php if (empty($items)): ?>
<h3>There's nothing here!</h3>
<?php else: ?>
<table class='media-wrap'>
<thead>
<tr>
<?php if ($auth->isAuthenticated()): ?>
<td>&nbsp;</td>
<?php endif ?>
<th>Title</th>
<th class='numeric'>Score</th>
<th class='numeric'>Completed Chapters</th>
<th>Attributes</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<?php foreach($items as $item): ?>
<tr id="manga-<?= $item['id'] ?>">
<?php if($auth->isAuthenticated()): ?>
<td>
<a class="bracketed" href="<?= $url->generate('edit', [
'controller' => 'manga',
'id' => $item['id'],
'status' => $name
]) ?>">Edit</a>
</td>
<?php endif ?>
<td class="align-left">
<a href="<?= $url->generate('manga.details', ['id' => $item['manga']['slug']]) ?>">
<?= $item['manga']['title'] ?>
</a>
<?php foreach($item['manga']['titles'] as $title): ?>
<br /><?= $title ?>
<?php endforeach ?>
</td>
<td><?= $item['user_rating'] ?> / 10</td>
<td><?= $item['chapters']['read'] ?> / <?= $item['chapters']['total'] ?></td>
<td>
<ul>
<?php if ($item['reread'] == 1): ?>
<li>Reread once</li>
<?php elseif ($item['reread'] == 2): ?>
<li>Reread twice</li>
<?php elseif ($item['reread'] == 3): ?>
<li>Reread thrice</li>
<?php elseif ($item['reread'] > 3): ?>
<li>Reread <?= $item['reread'] ?> times</li>
<?php endif ?>
<?php foreach(['rereading'] as $attr): ?>
<?php if($item[$attr]): ?>
<li><?= ucfirst($attr); ?></li>
<?php endif ?>
<?php endforeach ?>
</ul>
</td>
<td><?= $item['manga']['type'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php endif ?>
<?php endforeach ?>
<?php endif ?>
</main>
<script src="<?= asset_url('js.php?g=table') ?>"></script>
<script defer="defer" src="<?= $urlGenerator->assetUrl('js/tables.min.js') ?>"></script>

View File

@ -1,5 +1,5 @@
<div class="message <?= $stat_class ?>">
<div class="message <?= $escape->attr($message_type) ?>">
<span class="icon"></span>
<?= $message ?>
<span class="close" onclick="this.parentElement.style.display='none'">x</span>
<?= $escape->html($message) ?>
<span class="close"></span>
</div>

View File

@ -0,0 +1,104 @@
<main class="details fixed">
<section class="flex flex-no-wrap">
<div>
<?= $helper->img($data['image'], ['class' => 'cover' ]) ?>
</div>
<div>
<h2 class="toph"><?= $data['name'] ?></h2>
<?php foreach ($data['names'] as $name): ?>
<h3><?= $name ?></h3>
<?php endforeach ?>
<?php if ( ! empty($data['birthday'])): ?>
<h4><?= $data['birthday'] ?></h4>
<?php endif ?>
<br />
<hr />
<div class="description">
<p><?= str_replace("\n", '</p><p>', $data['description']) ?></p>
</div>
</div>
</section>
<?php if ( ! empty($data['staff'])): ?>
<section>
<h3>Castings</h3>
<div class="vertical-tabs">
<?php $i = 0 ?>
<?php foreach ($data['staff'] as $role => $entries): ?>
<div class="tab">
<input
type="radio" name="staff-roles" id="staff-role<?= $i ?>" <?= $i === 0 ? 'checked' : '' ?> />
<label for="staff-role<?= $i ?>"><?= $role ?></label>
<?php foreach ($entries as $type => $casting): ?>
<?php if (isset($entries['manga'], $entries['anime'])): ?>
<h4><?= ucfirst($type) ?></h4>
<?php endif ?>
<section class="content media-wrap flex flex-wrap flex-justify-start">
<?php foreach ($casting as $sid => $series): ?>
<?php $mediaType = in_array($type, ['anime', 'manga'], TRUE) ? $type : 'anime'; ?>
<?= $component->media(
$series['titles'],
$url->generate("{$mediaType}.details", ['id' => $series['slug']]),
$helper->img($series['image'], ['width' => 220, 'loading' => 'lazy'])
) ?>
<?php endforeach; ?>
</section>
<?php endforeach ?>
</div>
<?php $i++ ?>
<?php endforeach ?>
</div>
</section>
<?php endif ?>
<?php if ( ! empty($data['characters'])): ?>
<section>
<h3>Voice Acting Roles</h3>
<?= $component->tabs('voice-acting-roles', $data['characters'], static function ($characterList) use ($component, $helper, $url) {
$voiceRoles = [];
foreach ($characterList as $cid => $item):
$character = $component->character(
$item['character']['canonicalName'],
$url->generate('character', ['slug' => $item['character']['slug']]),
$helper->img($item['character']['image'], ['loading' => 'lazy']),
);
$medias = [];
foreach ($item['media'] as $sid => $series)
{
$medias[] = $component->media(
$series['titles'],
$url->generate('anime.details', ['id' => $series['slug']]),
$helper->img($series['image'], ['width' => 220, 'loading' => 'lazy'])
);
}
$media = implode('', array_map('mb_trim', $medias));
$voiceRoles[] = <<<HTML
<tr>
<td>{$character}</td>
<td>
<section class="align-left media-wrap">{$media}</section>
</td>
</tr>
HTML;
endforeach;
$roles = implode('', array_map('mb_trim', $voiceRoles));
return <<<HTML
<table class="borderless max-table">
<thead>
<tr>
<th>Character</th>
<th>Series</th>
</tr>
</thead>
<tbody>{$roles}</tbody>
</table>
HTML;
}) ?>
</section>
<?php endif ?>
</main>

View File

@ -0,0 +1,24 @@
<?php if ( ! $hasRequiredAnilistConfig): ?>
<p class="static-message info">See the <a href="https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient/wiki/anilist">wiki</a> to learn how to set up Anilist integration. </p>
<?php else: ?>
<?php $auth = $anilistModel->checkAuth(); ?>
<?php if (array_key_exists('errors', $auth)): ?>
<p class="static-message error">Anilist API Client is Not Authorized.</p>
<?= $helper->a(
$url->generate('anilist-redirect'),
'Link Anilist Account',
['class' => 'bracketed user-btn']
) ?>
<?php else: ?>
<?php $expires = $config->get(['anilist', 'access_token_expires']); ?>
<p class="static-message info">
Linked to Anilist. Your access token will expire around <?= date('F j, Y, g:i a T', $expires) ?>
</p>
<?php require __DIR__ . '/_form.php' ?>
<?= $helper->a(
$url->generate('anilist-redirect'),
'Update Access Token',
['class' => 'bracketed user-btn']
) ?>
<?php endif ?>
<?php endif ?>

View File

@ -0,0 +1,5 @@
<article>
<label for="<?= $fieldName ?>"><?= $field['title'] ?></label><br />
<small><?= $field['description'] ?></small><br />
<?= $helper->field($fieldName, $field); ?>
</article>

View File

@ -0,0 +1,24 @@
<?php
// Higher scoped variables:
// $fields
// $hiddenFields
// $nestedPrefix
?>
<?php foreach ($fields as $name => $field): ?>
<?php
$fieldName = ($section === 'config' || $nestedPrefix !== 'config')
? "{$nestedPrefix}[{$name}]"
: "{$nestedPrefix}[{$section}][{$name}]";
?>
<?php if ($field['type'] === 'subfield'): ?>
<section>
<h4><?= $field['title'] ?></h4>
<?php include '_subfield.php'; ?>
</section>
<?php elseif ( ! empty($field['display'])): ?>
<?php include '_field.php' ?>
<?php else: ?>
<?php $hiddenFields[] = $helper->field($fieldName, $field); ?>
<?php endif ?>
<?php endforeach ?>

View File

@ -0,0 +1,20 @@
<?php
// Higher scoped variables:
// $field
// $fields
// $hiddenFields
// $nestedPrefix
?>
<?php foreach ($field['fields'] as $name => $field): ?>
<?php
$fieldName = ($section === 'config' || $nestedPrefix !== 'config')
? "{$nestedPrefix}[{$name}]"
: "{$nestedPrefix}[{$section}][{$name}]";
?>
<?php if ( ! empty($field['display'])): ?>
<?php include '_field.php' ?>
<?php else: ?>
<?php $hiddenFields[] = $helper->field($fieldName, $field); ?>
<?php endif ?>
<?php endforeach ?>

View File

@ -0,0 +1,46 @@
<?php
if ( ! $auth->isAuthenticated())
{
echo '<h1>Not Authorized</h1>';
return;
}
$sectionMapping = [
'anilist' => 'Anilist API Integration',
'config' => 'General Settings',
'cache' => 'Caching',
'database' => 'Collection Database Settings',
];
$hiddenFields = [];
$nestedPrefix = 'config';
?>
<form action="<?= $url->generate('settings-post') ?>" method="POST">
<main class='settings form'>
<button type="submit">Save Changes</button>
<div class="tabs">
<?php $i = 0; ?>
<?php foreach ($form as $section => $fields): ?>
<input <?= $i === 0 ? 'checked="checked"' : '' ?> type="radio" id="settings-tab<?= $i ?>"
name="settings-tabs"
/>
<label for="settings-tab<?= $i ?>"><h3><?= $sectionMapping[$section] ?></h3></label>
<section class="content">
<?php
($section === 'anilist')
? require __DIR__ . '/_anilist.php'
: require __DIR__ . '/_form.php'
?>
</section>
<?php $i++; ?>
<?php endforeach ?>
</div>
<br />
<?php foreach ($hiddenFields as $field): ?>
<?= $field->__toString() ?>
<?php endforeach ?>
<button type="submit">Save Changes</button>
</main>
</form>

30
app/views/setup-check.php Normal file
View File

@ -0,0 +1,30 @@
<?php declare(strict_types=1);
use function Aviat\AnimeClient\checkFolderPermissions;
$setupErrors = checkFolderPermissions($container->get('config'));
?>
<?php if ( ! empty($setupErrors)): ?>
<aside class="message error">
<h1>Issues with server setup:</h1>
<?php if (array_key_exists('missing', $setupErrors)): ?>
<h3>The following folders need to be created, and writable.</h3>
<ul>
<?php foreach ($setupErrors['missing'] as $error): ?>
<li><?= $error ?></li>
<?php endforeach ?>
</ul>
<?php endif ?>
<?php if (array_key_exists('writable', $setupErrors)): ?>
<h3>The following folders are not writable by the server.</h3>
<ul>
<?php foreach($setupErrors['writable'] as $error): ?>
<li><?= $error ?></li>
<?php endforeach ?>
</ul>
<?php endif ?>
</aside>
<?php endif ?>

118
app/views/user/details.php Normal file
View File

@ -0,0 +1,118 @@
<?php
use Aviat\AnimeClient\Kitsu;
?>
<main class="user-page details">
<h2 class="toph">
About
<?= $helper->a(
"https://kitsu.io/users/{$data['slug']}",
$data['name'], [
'title' => 'View profile on Kitsu'
])
?>
</h2>
<section class="flex flex-no-wrap">
<aside class="info">
<table class="media-details invisible">
<tr>
<?php if($data['avatar'] !== null): ?>
<td><?= $helper->img($data['avatar'], ['alt' => '', 'width' => '225']); ?></td>
<?php endif ?>
<td><?= $escape->html($data['about']) ?></td>
</tr>
</table>
<br />
<table class="media-details">
<?php foreach ([
'joinDate' => 'Joined',
'birthday' => 'Birthday',
'gender' => 'Gender',
'location' => 'Location'
] as $key => $label): ?>
<?php if ($data[$key] !== null): ?>
<tr>
<td><?= $label ?></td>
<td><?= $data[$key] ?></td>
</tr>
<?php endif ?>
<?php endforeach; ?>
<?php if ($data['website'] !== null): ?>
<tr>
<td>Website</td>
<td><?= $helper->a($data['website'], $data['website']) ?></td>
</tr>
<?php endif ?>
<?php if ($data['waifu']['character'] !== null): ?>
<tr>
<td><?= $escape->html($data['waifu']['label']) ?></td>
<td>
<?php
$character = $data['waifu']['character'];
echo $component->character(
$character['names']['canonical'],
$url->generate('character', ['slug' => $character['slug']]),
$helper->img(Kitsu::getImage($character))
);
?>
</td>
</tr>
<?php endif ?>
</table>
<h3>User Stats</h3><br />
<table class="media-details">
<?php foreach($data['stats'] as $label => $stat): ?>
<tr>
<td><?= $label ?></td>
<td><?= $stat ?></td>
</tr>
<?php endforeach ?>
</table>
</aside>
<article>
<?php if ( ! empty($data['favorites'])): ?>
<h3>Favorites</h3>
<?= $component->tabs('user-favorites', $data['favorites'], static function ($items, $type) use ($component, $helper, $url) {
$rendered = [];
if ($type === 'character')
{
uasort($items, fn ($a, $b) => $a['names']['canonical'] <=> $b['names']['canonical']);
}
else
{
uasort($items, fn ($a, $b) => $a['titles']['canonical'] <=> $b['titles']['canonical']);
}
foreach ($items as $id => $item)
{
if ($type === 'character')
{
$rendered[] = $component->character(
$item['names']['canonical'],
$url->generate('character', ['slug' => $item['slug']]),
$helper->img(Kitsu::getImage($item))
);
}
else
{
$rendered[] = $component->media(
array_merge(
[$item['titles']['canonical']],
Kitsu::getFilteredTitles($item['titles']),
),
$url->generate("{$type}.details", ['id' => $item['slug']]),
$helper->img(Kitsu::getPosterImage($item), ['width' => 220]),
);
}
}
return implode('', array_map('mb_trim', $rendered));
}, 'content full-width media-wrap') ?>
<?php endif ?>
</article>
</section>
</main>

84
build/phpcs.xml Normal file
View File

@ -0,0 +1,84 @@
<?xml version="1.0"?>
<ruleset name="Tim's Coding Standard">
<description>A variation of the CodeIgniter standard</description>
<file>../src/</file>
<encoding>utf-8</encoding>
<rule ref="Generic.Files.LineEndings">
<properties>
<property name="eolChar" value="\n"/>
</properties>
</rule>
<!-- PHP files should OMIT the closing PHP tag -->
<rule ref="Zend.Files.ClosingTag"/>
<!-- Always use full PHP opening tags -->
<rule ref="Generic.PHP.DisallowShortOpenTag"/>
<!-- Constants should always be fully uppercase -->
<rule ref="Generic.NamingConventions.UpperCaseConstantName"/>
<!-- TRUE, FALSE, and NULL keywords should always be fully uppercase -->
<rule ref="Generic.PHP.UpperCaseConstant"/>
<!-- One statement per line -->
<rule ref="Generic.Formatting.DisallowMultipleStatements"/>
<!-- Classes and functions should be commented -->
<rule ref="PEAR.Commenting.ClassComment">
<exclude name="PEAR.Commenting.ClassComment.MissingCategoryTag" />
<exclude name="PEAR.Commenting.ClassComment.MissingPackageTag" />
<exclude name="PEAR.Commenting.ClassComment.MissingAuthorTag" />
<exclude name="PEAR.Commenting.ClassComment.MissingLicenseTag" />
<exclude name="PEAR.Commenting.ClassComment.MissingLinkTag" />
</rule>
<rule ref="PEAR.Commenting.FunctionComment">
<!-- Exclude this sniff because it doesn't understand multiple types -->
<exclude name="PEAR.Commenting.FunctionComment.MissingParamComment" />
<exclude name="PEAR.Commenting.FunctionComment.SpacingAfterParamType" />
<exclude name="PEAR.Commenting.FunctionComment.SpacingAfterParamName" />
</rule>
<!-- Use warnings for docblock comments for files and variables, since nothing is clearly explained -->
<rule ref="PEAR.Commenting.FileComment">
<exclude name="PEAR.Commenting.FileComment.InvalidVersion" />
<exclude name="PEAR.Commenting.FileComment.MissingCategoryTag" />
<properties>
<property name="error" value="false"/>
</properties>
</rule>
<rule ref="Squiz.Commenting.FunctionCommentThrowTag"/>
<rule ref="Squiz.Commenting.VariableComment">
<properties>
<property name="error" value="false"/>
</properties>
</rule>
<!-- Use Allman style indenting. With the exception of Class declarations,
braces are always placed on a line by themselves, and indented at the same level as the control statement that "owns" them. -->
<rule ref="Generic.Functions.OpeningFunctionBraceBsdAllman"/>
<rule ref="PEAR.WhiteSpace.ScopeClosingBrace">
<exclude name="PEAR.WhiteSpace.ScopeClosingBrace.BreakIndent" />
</rule>
<rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
<!-- Use only short array syntax -->
<rule ref="Generic.Arrays.DisallowLongArraySyntax" />
<rule ref="Generic.PHP.ForbiddenFunctions">
<properties>
<property name="forbiddenFunctions" type="array" value="create_function=>null,eval=>null" />
</properties>
</rule>
<!-- Inherit CodeIgniter Rules -->
<rule ref="./CodeIgniter">
<properties>
<property name="error" value="false" />
</properties>
</rule>
</ruleset>

32
build/phpunit.xml Normal file
View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" colors="true" stopOnFailure="false" bootstrap="../tests/bootstrap.php" beStrictAboutTestsThatDoNotTestAnything="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd">
<coverage>
<report>
<clover outputFile="logs/clover.xml"/>
<html outputDirectory="../coverage"/>
</report>
</coverage>
<testsuites>
<testsuite name="AnimeClient">
<directory>../tests/AnimeClient</directory>
</testsuite>
<testsuite name="Ion">
<directory>../tests/Ion</directory>
</testsuite>
</testsuites>
<logging>
<junit outputFile="logs/junit.xml"/>
</logging>
<php>
<server name="HTTP_USER_AGENT" value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:38.0) Gecko/20100101 Firefox/38.0"/>
<server name="HTTP_HOST" value="localhost"/>
<server name="SERVER_NAME" value="localhost"/>
<server name="REQUEST_URI" value="/"/>
<server name="REQUEST_METHOD" value="GET"/>
</php>
<source>
<include>
<directory suffix=".php">../src</directory>
</include>
</source>
</phpunit>

View File

@ -1,11 +1,78 @@
{
"require": {
"guzzlehttp/guzzle": "5.3.*",
"filp/whoops": "1.1.*",
"aura/router": "2.2.*",
"aura/web": "2.0.*",
"aviat4ion/query": "2.0.*",
"robmorgan/phinx": "*",
"abeautifulsite/simpleimage": "*"
"name": "aviat/hummingbird-anime-client",
"description": "A self-hosted anime/manga client for Kitsu.",
"license": "MIT",
"authors": [
{
"name": "Timothy J. Warren",
"email": "tim@timshomepage.net",
"homepage": "https://timshomepage.net",
"role": "Developer"
}
],
"autoload": {
"files": [
"src/Ion/functions.php",
"src/AnimeClient.php",
"src/AnimeClient/constants.php"
],
"psr-4": {
"Aviat\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Aviat\\AnimeClient\\Tests\\": "tests/AnimeClient",
"Aviat\\Ion\\Tests\\": "tests/Ion"
}
},
"config": {
"lock": false
},
"require": {
"amphp/http-client": "^4.5.0",
"aura/html": "^2.5.0",
"aura/router": "^3.1.0",
"aura/session": "^2.1.0",
"aviat/banker": "^4.1.2",
"aviat/query": "^4.1.0",
"ext-dom": "*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"laminas/laminas-diactoros": "^3.0.0",
"laminas/laminas-httphandlerrunner": "^2.6.1",
"maximebf/consolekit": "^1.0.3",
"monolog/monolog": "^3.0.0",
"php": ">= 8.2.0",
"psr/http-message": "^1.0.1",
"symfony/polyfill-mbstring": "^1.0.0",
"symfony/polyfill-util": "^1.0.0",
"tracy/tracy": "^2.8.0",
"yosymfony/toml": "^1.0.4"
},
"require-dev": {
"phpstan/phpstan": "^1.2.0",
"phpunit/phpunit": "^10.0.0",
"roave/security-advisories": "dev-master",
"spatie/phpunit-snapshot-assertions": "^5.0.1"
},
"scripts": {
"build:css": "cd public && npm run build:css && cd ..",
"build:js": "cd public && npm run build:js && cd ..",
"coverage": "php -dpcov.enabled=1 -dpcov.directory=. -dpcov.exclude=\"~vendor~\" ./vendor/bin/phpunit -c build",
"phpstan": "phpstan analyse -c phpstan.neon",
"watch:css": "cd public && npm run watch:css",
"watch:js": "cd public && npm run watch:js",
"test": "vendor/bin/phpunit -c build --no-coverage",
"test-update": "vendor/bin/phpunit -c build --no-coverage -d --update-snapshots"
},
"scripts-descriptions": {
"build:css": "Generate browser css",
"coverage": "Generate a test coverage report",
"phpstan": "Run PHP Static analysis",
"test": "Run the unit tests"
}
}

33
console Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env php
<?php declare(strict_types=1);
// Set up autoloader for third-party dependencies
require_once __DIR__ . '/vendor/autoload.php';
use Aviat\AnimeClient\Command;
use ConsoleKit\Console;
$GLOBALS['_SERVER']['HTTP_HOST'] = 'localhost';
const APP_DIR = __DIR__ . '/app';
const TEMPLATE_DIR = APP_DIR . '/templates';
// -----------------------------------------------------------------------------
// Start console script
// -----------------------------------------------------------------------------
try
{
(new Console([
'clear:cache' => Command\CacheClear::class,
'clear:thumbnails' => Command\ClearThumbnails::class,
'refresh:cache' => Command\CachePrime::class,
'refresh:thumbnails' => Command\UpdateThumbnails::class,
'lists:sync' => Command\SyncLists::class,
'sync:lists' => Command\SyncLists::class
]))->run();
}
catch (Throwable)
{
}

70
frontEndSrc/css.js Normal file
View File

@ -0,0 +1,70 @@
/**
* Script for optimizing css
*/
const fs = require('fs');
const postcss = require('postcss');
const atImport = require('postcss-import');
const cssNext = require('postcss-preset-env');
const cssNano = require('cssnano');
const lightCss = fs.readFileSync('css/light.css', 'utf-8');
const darkCss = fs.readFileSync('css/src/dark-override.css', 'utf-8');
const fullDarkCss = fs.readFileSync('css/dark.css', 'utf-8');
const minOptions = {
autoprefixer: false,
colormin: false,
minifyFontValues: false,
options: {
sourcemap: false
}
};
const processOptions = {
browser: '> 0.5%',
features: {
'custom-properties': true,
},
stage: 0,
};
try {
(async () => {
// Basic theme
const lightMin = await postcss()
.use(atImport())
.use(cssNext(processOptions))
.use(cssNano(minOptions))
.process(lightCss, {
from: 'css/light.css',
to: '/public/css/light.min.css',
}).catch(console.error);
fs.writeFileSync('../public/css/light.min.css', lightMin.css);
// Dark theme
const darkFullMin = await postcss()
.use(atImport())
.use(cssNext(processOptions))
.use(cssNano(minOptions))
.process(fullDarkCss, {
from: 'css/dark.css',
to: '/public/css/dark.min.css',
});
fs.writeFileSync('../public/css/dark.min.css', darkFullMin.css);
// Dark override
const darkMin = await postcss()
.use(atImport())
.use(cssNext(processOptions))
.use(cssNano(minOptions))
.process(darkCss, {
from: 'css/dark-override.css',
to: '/public/css/dark.min.css',
}).catch(console.error);
const autoDarkCss = `${lightMin} @media (prefers-color-scheme: dark) { ${darkMin.css} }`
fs.writeFileSync('../public/css/auto.min.css', autoDarkCss)
})();
} catch (e) {
console.error(e)
}

3
frontEndSrc/css/auto.css Normal file
View File

@ -0,0 +1,3 @@
@media (prefers-color-scheme: dark) {
@import "src/dark-override.css";
}

5
frontEndSrc/css/dark.css Normal file
View File

@ -0,0 +1,5 @@
@import "src/-marx-.css";
@import "src/general.css";
@import "src/components.css";
@import "src/responsive.css";
@import "src/dark-override.css";

View File

@ -0,0 +1,4 @@
@import "src/-marx-.css";
@import "src/general.css";
@import "src/components.css";
@import "src/responsive.css";

View File

@ -0,0 +1,531 @@
:root {
--default-font-list: system-ui,sans-serif;
--monospace-font-list:'Anonymous Pro','Fira Code',Menlo,Monaco,Consolas,'Courier New',monospace;
--serif-font-list:Georgia,Times,'Times New Roman',serif;
-ms-text-size-adjust:100%;
-webkit-text-size-adjust:100%;
box-sizing:border-box;
cursor:default;
font-family:var(--default-font-list);
line-height:1.4;
overflow-y:scroll;
text-size-adjust:100%;
scroll-behavior:smooth;
}
audio:not([controls]) {
display:none;
}
details {
display:block;
}
input[type=search] {
-webkit-appearance:textfield;
}
input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration {
-webkit-appearance:none;
}
main {
display:block;
margin:0 auto;
padding:0 1.6em 1.6em;
padding:0 1.6rem 1.6rem;
}
summary {
display:block;
}
pre {
background:#efefef;
color:#444;
display:block;
font-family:var(--monospace-font-list);
font-size:1.4em;
font-size:1.4rem;
margin:1.6em 0;
margin:1.6rem 0;
overflow:auto;
padding:1.6em;
padding:1.6rem;
word-break:break-all;
word-wrap:break-word;
}
progress {
display:inline-block;
}
small {
color:#777;
font-size:75%;
}
big {
font-size:125%;
}
template {
display:none;
}
textarea {
border:.1rem solid #ccc;
border-radius:0;
display:block;
margin-bottom:.8rem;
overflow:auto;
padding:.8rem;
resize:vertical;
vertical-align:middle;
}
[hidden] {
display:none;
}
[unselectable] {
-moz-user-select:none;
-ms-user-select:none;
-webkit-user-select:none;
user-select:none;
}
*,::before,::after {
/* border-style:solid;
border-width:0; */
box-sizing:inherit;
}
* {
font-size:inherit;
line-height:inherit;
margin:0;
padding:0;
}
::before,::after {
text-decoration:inherit;
vertical-align:inherit;
}
a {
-webkit-transition:.25s ease;
color:#1271db;
text-decoration:none;
transition:.25s ease;
}
audio,canvas,iframe,img,svg,video {
vertical-align:middle;
}
input,/*select*/,textarea {
border:.1rem solid #ccc;
color:inherit;
font-family:inherit;
font-style:inherit;
font-weight:inherit;
min-height:1.4em;
}
code,kbd,pre,samp {
font-family:var(--monospace-font-list);
}
table {
border-collapse:collapse;
border-spacing:0;
margin-bottom:1.6rem;
}
::-moz-selection {
background-color:#b3d4fc;
text-shadow:none;
}
::selection {
background-color:#b3d4fc;
text-shadow:none;
}
button::-moz-focus-inner {
border:0;
}
body {
color:#444;
font-family:var(--default-font-list);
font-size:1.6rem;
font-style:normal;
font-weight:400;
padding:0;
}
p {
margin:0 0 1.6rem;
}
h1,h2,h3,h4,h5,h6 {
font-family:var(--default-font-list);
margin:2em 0 1.6em;
margin:2rem 0 1.6rem;
}
h1 {
border-bottom:.1rem solid rgba(0,0,0,0.2);
font-size:3.6em;
font-size:3.6rem;
font-style:normal;
font-weight:500;
}
h2 {
font-size:3em;
font-size:3rem;
font-style:normal;
font-weight:500;
}
h3 {
font-size:2.4em;
font-size:2.4rem;
font-style:normal;
font-weight:500;
margin:1.6rem 0 .4rem;
}
h4 {
font-size:1.8em;
font-size:1.8rem;
font-style:normal;
font-weight:600;
margin:1.6rem 0 .4rem;
}
h5 {
font-size:1.6em;
font-size:1.6rem;
font-style:normal;
font-weight:600;
margin:1.6rem 0 .4rem;
}
h6 {
color:#777;
font-size:1.4em;
font-size:1.4rem;
font-style:normal;
font-weight:600;
margin:1.6rem 0 .4rem;
}
code {
background:#efefef;
color:#444;
font-family:var(--monospace-font-list);
font-size:1.4rem;
word-break:break-all;
word-wrap:break-word;
}
a:hover,a:focus {
text-decoration:none;
}
dl {
margin-bottom:1.6rem;
}
dd {
margin-left:4rem;
}
ul,ol {
margin-bottom:.8rem;
padding-left:2rem;
}
blockquote {
border-left:.2rem solid #1271db;
font-family:var(--serif-font-list);
font-style:italic;
margin:1.6rem 0;
padding-left:1.6rem;
}
figcaption {
font-family:var(--serif-font-list);
}
html {
font-size:62.5%;
}
main,header,footer,article,section,aside,details,summary {
display:block;
height:auto;
margin:0 auto;
width:100%;
}
footer {
border-top:.1rem solid rgba(0,0,0,0.2);
clear:both;
display:inline-block;
float:left;
max-width:100%;
padding:1rem 0;
text-align:center;
}
hr {
border-top:.1rem solid rgba(0,0,0,0.2);
display:block;
margin-bottom:1.6rem;
width:100%;
}
img {
height:auto;
/* max-width:100%; */
vertical-align:baseline;
}
input[type=text],input[type=password],input[type=email],input[type=url],input[type=date],input[type=month],input[type=time],input[type=datetime],input[type=datetime-local],input[type=week],input[type=number],input[type=search],input[type=tel],input[type=color]/*,select */ {
border:.1rem solid #ccc;
border-radius:0;
display:inline-block;
padding:.8rem;
vertical-align:middle;
}
input:not([type]) {
-webkit-appearance:none;
background-clip:padding-box;
background-color:#fff;
border:.1rem solid #ccc;
border-radius:0;
color:#444;
display:inline-block;
padding:.8rem;
text-align:left;
}
input[type=color] {
padding:.8rem 1.6rem;
}
input[type=text]:focus,input[type=password]:focus,input[type=email]:focus,input[type=url]:focus,input[type=date]:focus,input[type=month]:focus,input[type=time]:focus,input[type=datetime]:focus,input[type=datetime-local]:focus,input[type=week]:focus,input[type=number]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=color]:focus,/* select:focus */,textarea:focus {
border-color:#b3d4fc;
}
input:not([type]):focus {
border-color:#b3d4fc;
}
input[type=radio],input[type=checkbox] {
vertical-align:middle;
}
input[type=file]:focus,input[type=radio]:focus,input[type=checkbox]:focus {
outline:.1rem solid thin #444;
}
input[type=text][disabled],input[type=password][disabled],input[type=email][disabled],input[type=url][disabled],input[type=date][disabled],input[type=month][disabled],input[type=time][disabled],input[type=datetime][disabled],input[type=datetime-local][disabled],input[type=week][disabled],input[type=number][disabled],input[type=search][disabled],input[type=tel][disabled],input[type=color][disabled],/*select[disabled]*/,textarea[disabled] {
background-color:#efefef;
color:#777;
cursor:not-allowed;
}
input:not([type])[disabled] {
background-color:#efefef;
color:#777;
cursor:not-allowed;
}
input[readonly],/*select[readonly]*/,textarea[readonly] {
background-color:#efefef;
border-color:#ccc;
color:#777;
}
input:focus:invalid,textarea:focus:invalid/*,select:focus:invalid*/ {
border-color:#e9322d;
color:#b94a48;
}
input[type=file]:focus:invalid:focus,input[type=radio]:focus:invalid:focus,input[type=checkbox]:focus:invalid:focus {
outline-color:#ff4136;
}
/* select {
background-color:#fff;
border:.1rem solid #ccc;
}*/
select[multiple] {
height:auto;
}
label {
line-height:2;
}
fieldset {
border:0;
margin:0;
padding:.8rem 0;
}
legend {
border-bottom:.1rem solid #ccc;
color:#444;
display:block;
margin-bottom:.8rem;
padding:.8rem 0;
width:100%;
}
input[type=submit],button {
-moz-user-select:none;
-ms-user-select:none;
-webkit-transition:.25s ease;
-webkit-user-drag:none;
-webkit-user-select:none;
border:.2rem solid #444;
border-radius:0;
color:#444;
cursor:pointer;
display:inline-block;
margin-bottom:.8rem;
margin-right:.4rem;
padding:.8rem 1.6rem;
text-align:center;
text-decoration:none;
text-transform:uppercase;
transition:.25s ease;
user-select:none;
vertical-align:baseline;
}
input[type=submit] a,button a {
color:#444;
}
input[type=submit]::-moz-focus-inner,button::-moz-focus-inner {
padding:0;
}
input[type=submit]:hover,button:hover {
background:#444;
border-color:#444;
color:#fff;
}
input[type=submit]:hover a,button:hover a {
color:#fff;
}
input[type=submit]:active,button:active {
background:#6a6a6a;
border-color:#6a6a6a;
color:#fff;
}
input[type=submit]:active a,button:active a {
color:#fff;
}
input[type=submit]:disabled,button:disabled {
box-shadow:none;
cursor:not-allowed;
opacity:.4;
}
nav ul {
list-style:none;
margin:0;
padding:0;
text-align:center;
}
nav ul li {
display:inline;
}
nav a {
-webkit-transition:.25s ease;
border-bottom:.2rem solid transparent;
color:#444;
padding:.8rem 1.6rem;
text-decoration:none;
transition:.25s ease;
}
nav a:hover,nav li.selected a {
border-color:rgba(0,0,0,0.2);
}
nav a:active {
border-color:rgba(0,0,0,0.56);
}
caption {
padding:.8rem 0;
}
thead th {
background:#efefef;
color:#444;
}
tr {
background:#fff;
margin-bottom:.8rem;
}
th,td {
border:.1rem solid #ccc;
padding:.8rem 1.6rem;
text-align:center;
vertical-align:inherit;
}
tfoot tr {
background:none;
}
tfoot td {
color:#efefef;
font-size:.8rem;
font-style:italic;
padding:1.6rem .4rem;
}
@media screen {
[hidden~=screen] {
display:inherit;
}
[hidden~=screen]:not(:active):not(:focus):not(:target) {
clip:rect(0000)!important;
position:absolute!important;
}
}
@media screen and max-width 40rem {
article,section,aside {
clear:both;
display:block;
max-width:100%;
}
img {
margin-right:1.6rem;
}
}

View File

@ -0,0 +1,271 @@
/* -----------------------------------------------------------------------------
CSS loading icon
------------------------------------------------------------------------------*/
.cssload-loader {
position: relative;
left: calc(50% - 31px);
width: 62px;
height: 62px;
border-radius: 50%;
perspective: 780px;
}
.cssload-inner {
position: absolute;
width: 100%;
height: 100%;
box-sizing: border-box;
border-radius: 50%;
}
.cssload-inner.cssload-one {
left: 0%;
top: 0%;
animation: cssload-rotate-one 1.15s linear infinite;
border-bottom: 3px solid rgb(0, 0, 0);
}
.cssload-inner.cssload-two {
right: 0%;
top: 0%;
animation: cssload-rotate-two 1.15s linear infinite;
border-right: 3px solid rgb(0, 0, 0);
}
.cssload-inner.cssload-three {
right: 0%;
bottom: 0%;
animation: cssload-rotate-three 1.15s linear infinite;
border-top: 3px solid rgb(0, 0, 0);
}
@keyframes cssload-rotate-one {
0% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(360deg);
}
}
@keyframes cssload-rotate-two {
0% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(0deg);
}
100% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(360deg);
}
}
@keyframes cssload-rotate-three {
0% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(360deg);
}
}
/* ----------------------------------------------------------------------------
Loading overlay
-----------------------------------------------------------------------------*/
#loading-shadow {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 500;
}
#loading-shadow .loading-wrapper {
position: fixed;
z-index: 501;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#loading-shadow .loading-content {
position: relative;
color: #fff
}
.loading-content .cssload-inner.cssload-one,
.loading-content .cssload-inner.cssload-two,
.loading-content .cssload-inner.cssload-three {
border-color: #fff
}
/* ----------------------------------------------------------------------------
CSS Tabs
-----------------------------------------------------------------------------*/
.tabs {
display: inline-block;
display: flex;
flex-wrap: wrap;
background: #efefef;
box-shadow: 0 48px 80px -32px rgba(0, 0, 0, 0.3);
margin-top: 1.5em;
}
.tabs > label {
border: 1px solid #e5e5e5;
width: 100%;
padding: 20px 30px;
background: #e5e5e5;
cursor: pointer;
font-weight: bold;
font-size: 18px;
color: #7f7f7f;
transition: background 0.1s, color 0.1s;
/* margin-left: 4em; */
}
.tabs > label:hover {
background: #d8d8d8;
}
.tabs > label:active {
background: #ccc;
}
.tabs > [type=radio]:focus + label {
box-shadow: inset 0px 0px 0px 3px #2aa1c0;
z-index: 1;
}
.tabs > [type=radio] {
position: absolute;
opacity: 0;
}
.tabs > [type=radio]:checked + label {
border-bottom: 1px solid #fff;
background: #fff;
color: #000;
}
.tabs > [type=radio]:checked + label + .content {
border: 1px solid #e5e5e5;
border-top: 0;
display: block;
padding: 15px;
background: #fff;
width: 100%;
margin: 0 auto;
overflow: auto;
/* text-align: center; */
}
.tabs .content, .single-tab {
display: none;
max-height: 950px;
border: 1px solid #e5e5e5;
border-top: 0;
padding: 15px;
background: #fff;
width: 100%;
margin: 0 auto;
overflow: auto;
}
.single-tab {
display: block;
border: 1px solid #e5e5e5;
box-shadow: 0 48px 80px -32px rgba(0, 0, 0, 0.3);
margin-top: 1.5em;
}
.tabs .content.full-height, .single-tab.full-height {
max-height: none;
}
@media (min-width: 800px) {
.tabs > label {
width: auto;
}
.tabs .content {
order: 99;
}
}
/* ---------------------------------------------------------------------------
Vertical Tabs
----------------------------------------------------------------------------*/
.vertical-tabs {
border: 1px solid #e5e5e5;
box-shadow: 0 48px 80px -32px rgba(0, 0, 0, 0.3);
margin: 0 auto;
position: relative;
width: 100%;
}
.vertical-tabs input[type="radio"] {
position: absolute;
opacity: 0;
}
.vertical-tabs .tab {
align-items: center;
display: inline-block;
display: flex;
flex-wrap: nowrap;
}
.vertical-tabs .tab label {
align-items: center;
background: #e5e5e5;
border: 1px solid #e5e5e5;
color: #7f7f7f;
cursor: pointer;
font-size: 18px;
font-weight: bold;
padding: 0 20px;
width: 28%;
}
.vertical-tabs .tab label:hover {
background: #d8d8d8;
}
.vertical-tabs .tab label:active {
background: #ccc;
}
.vertical-tabs .tab .content {
display: none;
border: 1px solid #e5e5e5;
border-left: 0;
border-right: 0;
max-height: 950px;
overflow: auto;
}
.vertical-tabs .tab .content.full-height {
max-height: none;
}
.vertical-tabs [type=radio]:checked + label {
border: 0;
background: #fff;
color: #000;
width: 38%;
}
.vertical-tabs [type=radio]:focus + label {
box-shadow: inset 0px 0px 0px 3px #2aa1c0;
z-index: 1;
}
.vertical-tabs [type=radio]:checked ~ .content {
display: block;
}

View File

@ -0,0 +1,172 @@
a {
color: rgb(25, 120, 226);
text-shadow: var(--link-shadow);
}
a:hover {
color: #9e34fd;
}
body,
legend,
nav ul li a {
background: #333;
color: #eee;
}
nav a:hover, nav li.selected a {
border-color: #fff;
}
header button {
background: transparent;
}
table {
box-shadow: none;
}
td, th {
border-color: #111;
}
thead td,
thead th {
background: #333;
color: #eee;
}
tbody > tr:nth-child(2n) {
background: #555;
color: #eee;
}
tbody > tr:nth-child(2n+1) {
background: #333;
}
footer, legend, hr {
border-color: #ddd;
}
small {
color: #fff;
}
input, input[type], select, textarea {
border-color: #bbb;
color: #bbb;
background: #333;
padding:.8em;
}
button {
background: #444;
background: linear-gradient(#666, #555, #444, #555, #666);
border-radius: 0.5em;
margin: 0;
text-transform: none;
border-color: #ddd;
color: #ddd;
}
button:hover {
background: #222;
background: linear-gradient(#444, #333, #222, #333, #444);
border-color: #ddd;
color: #ddd;
}
button:active {
background: #333;
background: linear-gradient(#333, #333);
}
.media:hover button {
background: linear-gradient(#666, #555, #444, #555, #666);
}
.media:hover button:hover {
background: linear-gradient(#444, #555, #666, #555, #444);
}
.message, .static-message {
text-shadow: var(--white-link-shadow);
}
.message.success, .static-message.success {
background: #1f8454;
border-color: #70dda9;
}
.message.error, .static-message.error {
border-color:#f3e6e6;
background: #924949;
}
.message.info, .static-message.info {
border-color: #FFFFCC;
background: #bfbe3a;
}
.invisible tr,
.invisible td,
.invisible th,
.invisible tbody > tr:nth-child(2n),
.invisible tbody > tr:nth-child(2n+1) {
background: transparent;
}
#main-nav {
border-bottom: .1rem solid #ddd;
}
.tabs,
.vertical-tabs{
background: #333;
}
.tabs > label,
.vertical-tabs .tab label {
background: #222;
border: 0;
color: #eee;
}
.vertical-tabs .tab label {
width: 100%;
}
.tabs > label:hover,
.vertical-tabs .tab > label:hover {
background: #888;
}
.tabs > label:active,
.vertical-tabs .tab > label:active {
background: #999;
}
.tabs > [type="radio"]:checked + label,
.tabs > [type="radio"]:checked + label + .content,
.vertical-tabs [type="radio"]:checked + label,
.vertical-tabs [type="radio"]:checked ~ .content,
.single-tab {
/* border-color: #333; */
border: 0;
background: #666;
color: #eee;
}
.vertical-tabs {
background: #222;
border: 1px solid #444;
}
.vertical-tabs .tab {
background: #666;
border-bottom: 1px solid #444;
}
.streaming-logo {
-webkit-filter: drop-shadow(0 0 2px #fff);
filter: drop-shadow(0 0 2px #fff);
}

View File

@ -0,0 +1,931 @@
:root {
--blue-link: rgb(18, 113, 219);
--link-shadow: 1px 1px 1px #000;
--white-link-shadow: 1px 1px 1px #fff;
--shadow: 2px 2px 2px #000;
--title-overlay: rgba(0, 0, 0, 0.45);
--title-overlay-fallback: #000;
--text-color: #ffffff;
--normal-padding: 0.25em 0.125em;
--link-hover-color: #7d12db;
--edit-link-hover-color: #db7d12;
--edit-link-color: #12db18;
--radius: 5px;
}
template, [hidden="hidden"], .media[hidden] {
display: none
}
body {
margin: 0.5em;
}
button {
background: #fff;
background: linear-gradient(#ddd, #eee, #fff, #eee, #ddd);
border-radius: 0.5em;
margin: 0;
text-transform: none;
border-color: #555;
color: #555;
}
button:hover {
background: #bbb;
background: linear-gradient(#cfcfcf, #dfdfdf, #efefef, #dfdfdf, #cfcfcf);
border-color: #555;
color: #555;
}
button:active {
background: #ddd;
background: linear-gradient(#ddd, #ddd);
}
.media:hover button {
background: linear-gradient(#bbb, #ccc, #ddd, #ccc, #bbb);
}
.media:hover button:hover {
background: linear-gradient(#afafaf, #bfbfbf, #cfcfcf, #bfbfbf, #afafaf);
}
table {
/* min-width: 85%; */
box-shadow: 0 48px 80px -32px rgba(0, 0, 0, 0.3);
margin: 0 auto;
}
td {
padding: 1em;
padding: 1rem;
}
thead td, thead th {
padding: 0.5em;
padding: 0.5rem;
}
input[type=number] {
min-width: 0;
width: 4.5em;
}
input[type=checkbox], input[type=radio] {
min-width: auto;
vertical-align: inherit;
}
input, textarea {
min-width: 30em;
min-width: 30rem;
}
tbody > tr:nth-child(odd) {
background: #ddd;
}
a:hover, a:active {
color: var(--link-hover-color)
}
iframe {
display: block;
margin: 0 auto;
border: 0;
}
/* -----------------------------------------------------------------------------
Utility classes
------------------------------------------------------------------------------*/
.bracketed {
color: var(--edit-link-color);
}
.bracketed, #main-nav a {
text-shadow: var(--link-shadow);
}
.bracketed:before {
content: '[\00a0'
}
.bracketed:after {
content: '\00a0]'
}
.bracketed:hover, .bracketed:active {
color: var(--edit-link-hover-color)
}
.grow-1 {
flex-grow: 1
}
.flex-wrap {
flex-wrap: wrap
}
.flex-no-wrap {
flex-wrap: nowrap
}
.flex-align-start {
align-content: flex-start;
}
.flex-align-end {
align-items: flex-end
}
.flex-align-space-around {
align-content: space-around
}
.flex-justify-start {
justify-content: flex-start;
}
.flex-justify-space-around {
justify-content: space-around
}
.flex-center {
justify-content: center;
}
.flex-self-center {
align-self: center
}
.flex-space-evenly {
justify-content: space-evenly;
}
.flex {
display: inline-block;
display: flex
}
.small-font {
font-size: 1.6rem;
}
.justify {
text-align: justify
}
.align-center {
text-align: center !important
}
.align-left {
text-align: left !important
}
.align-right {
text-align: right !important
}
.valign-top {
vertical-align: top
}
.no-border {
border: none
}
.media-wrap {
text-align: center;
margin: 0 auto;
position: relative;
}
.media-wrap-flex {
display: inline-block;
display: flex;
flex-wrap: wrap;
align-content: space-evenly;
justify-content: space-between;
position: relative;
}
td .media-wrap-flex {
justify-content: center;
}
.danger {
background-color: #ff4136;
border-color: #924949;
color: #924949;
/* color: #fff; */
}
.danger:hover, .danger:active {
background-color: #924949;
border-color: #ff4136;
color: #ff4136;
/* color: #fff; */
}
td.danger, td.danger:hover, td.danger:active {
background-color: transparent;
color: #924949;
}
.user-btn {
background: transparent;
border-color: var(--edit-link-color);
color: var(--edit-link-color);
text-shadow: var(--link-shadow);
padding: 0 0.5em;
padding: 0 0.5rem;
}
.user-btn:hover, .user-btn:active {
background: transparent;
border-color: var(--edit-link-hover-color);
color: var(--edit-link-hover-color);
}
.user-btn:active {
background: var(--edit-link-hover-color);
color: #fff;
}
.full-width {
width: 100%;
}
.full-height {
max-height: none;
}
.toph {
margin-top: 0;
}
/* -----------------------------------------------------------------------------
Main Nav
------------------------------------------------------------------------------*/
#main-nav {
font-family: var(--default-font-list);
margin: 2em 0 1.6em;
margin: 2rem 0 1.6rem;
border-bottom: .1rem solid rgba(0, 0, 0, 0.2);
font-size: 3.6em;
font-size: 3.6rem;
font-style: normal;
font-weight: 500;
}
/* -----------------------------------------------------------------------------
Table sorting and form styles
------------------------------------------------------------------------------*/
.sorting,
.sorting-asc,
.sorting-desc {
vertical-align: text-bottom;
}
.sorting::before {
content: " ↕\00a0";
}
.sorting-asc::before {
content: " ↑\00a0";
}
.sorting-desc::before {
content: " ↓\00a0";
}
.form {
/* width: 100%; */
}
.form thead th, .form thead tr {
background: inherit;
border: 0;
}
.form tr > td:nth-child(odd) {
text-align: right;
min-width: 25px;
max-width: 30%;
}
.form tr > td:nth-child(even) {
text-align: left;
/* width: 70%; */
}
.invisible tbody > tr:nth-child(odd) {
background: inherit;
}
.borderless,
.borderless tr,
.borderless td,
.borderless th,
.invisible tr,
.invisible td,
.invisible th,
table.invisible {
box-shadow: none;
border: 0;
}
/* -----------------------------------------------------------------------------
Message boxes
------------------------------------------------------------------------------*/
.message, .static-message {
position: relative;
margin: 0.5em auto;
padding: 0.5em;
width: 95%;
}
.message .close {
width: 1em;
height: 1em;
position: absolute;
right: 0.5em;
top: 0.5em;
text-align: center;
vertical-align: middle;
line-height: 1em;
}
.message:hover .close:after {
content: '☒';
}
.message:hover {
cursor: pointer;
}
.message .icon {
left: 0.5em;
top: 0.5em;
margin-right: 1em;
}
.message.error, .static-message.error {
border: 1px solid #924949;
background: #f3e6e6;
}
.message.error .icon::after {
content: '✘';
}
.message.success, .static-message.success {
border: 1px solid #1f8454;
background: #70dda9;
}
.message.success .icon::after {
content: '✔'
}
.message.info, .static-message.info {
border: 1px solid #bfbe3a;
background: #FFFFCC;
}
.message.info .icon::after {
content: '⚠';
}
/* -----------------------------------------------------------------------------
Base list styles
------------------------------------------------------------------------------*/
.media, .character, .small-character {
position: relative;
vertical-align: top;
display: inline-block;
text-align: center;
width: 220px;
height: 312px;
margin: var(--normal-padding);
z-index: 0;
background: rgba(0, 0, 0, 0.15);
}
.details picture.cover,
picture.cover {
display: initial;
width: 100%;
}
.media > img,
.character > img,
.small-character > img {
width: 100%;
}
.media .edit-buttons > button {
margin: 0.5em auto;
}
.name,
.media-metadata > div,
.medium-metadata > div,
.row {
text-shadow: var(--shadow);
color: var(--text-color);
padding: var(--normal-padding);
text-align: right;
z-index: 2;
}
.media-type, .age-rating {
text-align: left;
}
.media > .media-metadata {
position: absolute;
bottom: 0;
right: 0;
}
.media > .medium-metadata {
position: absolute;
bottom: 0;
left: 0;
}
.media > .name {
position: absolute;
top: 0;
}
.media > .name a {
display: inline-block;
transition: none;
}
.media .name a::before {
/* background: var(--title-overlay-fallback);
background: var(--title-overlay); */
content: '';
display: block;
height: 312px;
left: 0;
position: absolute;
top: 0;
width: 220px;
z-index: -1; /* Put the pseudo-element behind its parent */
}
.media-list .media:hover .name a::before {
/* transition: .25s ease; */
background: rgba(0, 0, 0, 0.75);
}
.media > .name span.canonical {
font-weight: bold;
}
.media > .name small {
font-weight: normal;
}
.media:hover .name {
background: rgba(0, 0, 0, 0.75);
}
.media-list .media > .name a:hover,
.media-list .media > .name a:hover small {
color: var(--blue-link);
}
.media:hover > button[hidden],
.media:hover > .edit-buttons[hidden] {
transition: .25s ease;
display: block;
}
.media:hover {
transition: .25s ease;
}
.small-character > .name a,
.small-character > .name a small,
.character > .name a,
.character > .name a small,
.media > .name a,
.media > .name a small {
background: none;
color: #fff;
text-shadow: var(--shadow);
}
/* -----------------------------------------------------------------------------
Anime-list-specific styles
------------------------------------------------------------------------------*/
.anime .name, .manga .name {
background: var(--title-overlay-fallback);
background: var(--title-overlay);
text-align: center;
width: 100%;
padding: 0.5em 0.25em;
}
.anime .media-type,
.anime .airing-status,
.anime .user-rating,
.anime .completion,
.anime .age-rating,
.anime .edit,
.anime .delete {
background: none;
text-align: center;
}
.anime .table, .manga .table {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
}
.anime .row, .manga .row {
width: 100%;
display: inline-block;
display: flex;
align-content: space-around;
justify-content: space-around;
text-align: center;
padding: 0 inherit;
}
.anime .row > span, .manga .row > span {
text-align: left;
z-index: 2;
}
.anime .row > div, .manga .row > div {
font-size: 0.8em;
display: inline-block;
display: flex-item;
align-self: center;
text-align: center;
vertical-align: middle;
z-index: 2;
}
.anime .media > button.plus-one {
border-color: hsla(0, 0%, 100%, .65);
position: absolute;
top: 138px;
top: calc(50% - 21.2px);
left: 44px;
left: calc(50% - 57.8px);
z-index: 50;
}
/* -----------------------------------------------------------------------------
Manga-list-specific styles
------------------------------------------------------------------------------*/
.manga .row {
padding: 1px;
}
.manga .media {
/* border: 1px solid #ddd; */
height: 310px;
margin: 0.25em;
}
.manga .media > .edit-buttons {
position: absolute;
top: 86px;
/* top: calc(50% - 58.5px); */
top: calc(50% - 21.2px);
left: 43.5px;
left: calc(50% - 57.8px);
z-index: 40;
}
.manga .media > .edit-buttons button {
border-color: hsla(0, 0%, 100%, .65);
}
/* -----------------------------------------------------------------------------
Search page styles
------------------------------------------------------------------------------*/
.media.search > .name {
background-color: #555;
background-color: rgba(000, 000, 000, 0.35);
background-size: cover;
background-size: contain;
background-repeat: no-repeat;
}
/* There are two .name elements, just darken them both in this case! */
.media.search.disabled .name {
background-color: #000;
background-color: rgba(0, 0, 0, 0.75);
background-size: cover;
background-size: contain;
background-repeat: no-repeat;
}
.media.search > .row {
z-index: 6;
}
.big-check, .mal-check {
display: none;
}
.big-check:checked + label {
transition: .25s ease;
background: rgba(0, 0, 0, 0.75);
}
.big-check:checked + label:after {
content: '✓';
font-size: 15em;
font-size: 15rem;
text-align: center;
color: greenyellow;
position: absolute;
top: 147px;
left: 0;
width: 100%;
z-index: 5;
}
#series-list article.media {
position: relative;
}
#series-list .name, #series-list .name label {
position: absolute;
display: block;
top: 0;
left: 0;
height: 100%;
width: 100%;
vertical-align: middle;
line-height: 1.25em;
}
#series-list .name small {
color: #fff;
}
/* ----------------------------------------------------------------------------
Details page styles
-----------------------------------------------------------------------------*/
.details {
margin: 1.5rem auto 0 auto;
padding: 1rem;
font-size: inherit;
}
/* .description {
max-width: 80rem;
columns: 4 28rem;
columns: 4 28em;
margin-bottom: 1.6em;
margin-bottom: 1.6rem;
}
p.description br + br {
page-break-before: avoid;
page-break-after: auto;
page-break-inside: avoid;
break-inside: avoid;
break-after: auto;
break-before: avoid;
} */
.fixed {
max-width: 115em;
max-width: 115rem;
/* max-width: 80%; */
margin: 0 auto;
}
.details .cover {
display: block;
}
.details .flex > * {
margin: 1rem;
}
.details .media-details td {
padding: 0 1.5rem;
}
.details p {
text-align: justify;
}
.details .media-details td:nth-child(odd) {
width: 1%;
white-space: nowrap;
text-align: right;
}
.details .media-details td:nth-child(even) {
text-align: left;
}
.details a h1,
.details a h2 {
margin-top: 0;
}
.character,
.small-character,
.person {
/* background: rgba(0,0,0,0.5); */
width: 225px;
height: 350px;
vertical-align: middle;
white-space: nowrap;
position: relative;
}
.person {
width: 225px;
height: 338px;
}
.small-person {
width: 200px;
height: 300px;
}
.character a {
height: 350px;
}
.character:hover .name,
.small-character:hover .name {
background: rgba(0, 0, 0, 0.8);
}
.small-character a {
display: inline-block;
width: 100%;
height: 100%;
}
.small-character .name,
.character .name {
position: absolute;
bottom: 0;
left: 0;
z-index: 10;
}
.small-character img,
.character img,
.small-character picture,
.character picture,
.person img,
.person picture {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 5;
max-height: 350px;
max-width: 225px;
}
.person img,
.person picture {
max-height: 338px;
}
.small-person img,
.small-person picture {
max-height: 300px;
max-width: 200px;
}
.min-table {
min-width: 0;
margin-left: 0;
}
.max-table {
min-width: 100%;
margin: 0;
}
aside.info {
/* max-width: 390px; */
max-width: 33%;
}
.fixed aside {
max-width: 390px;
}
aside picture, aside img {
display: block;
margin: 0 auto;
}
/* ----------------------------------------------------------------------------
User page styles
-----------------------------------------------------------------------------*/
.small-character {
width: 160px;
height: 250px;
}
.small-character img,
.small-character picture {
max-height: 250px;
max-width: 160px;
}
.user-page .media-wrap {
text-align: left;
}
.media a {
display: inline-block;
width: 100%;
height: 100%;
}
/* ----------------------------------------------------------------------------
Images / Logos
-----------------------------------------------------------------------------*/
.streaming-logo {
width: 50px;
height: 50px;
vertical-align: middle;
}
.small-streaming-logo {
width: 25px;
height: 25px;
vertical-align: middle;
}
.cover-streaming-link {
display: none;
}
.media:hover .cover-streaming-link {
display: block;
}
.cover-streaming-link .streaming-logo {
width: 20px;
height: 20px;
-webkit-filter: drop-shadow(0 -1px 4px #fff);
filter: drop-shadow(0 -1px 4px #fff);
}
.history-img {
width: 110px;
height: 156px;
}
/* ----------------------------------------------------------------------------
Settings Form
-----------------------------------------------------------------------------*/
.settings.form .content article {
margin: 1em;
display: inline-block;
width: auto;
}
/* ----------------------------------------------------------------------------
iFrame container
-----------------------------------------------------------------------------*/
.responsive-iframe {
margin-top: 1em;
overflow: hidden;
padding-bottom: 56.25%;
position: relative;
height: 0;
}
.responsive-iframe iframe {
left: 0;
top: 0;
height: 100%;
width: 100%;
position: absolute;
}

View File

@ -0,0 +1,137 @@
/* ----------------------------------------------------------------------------
Viewport-based styles
-----------------------------------------------------------------------------*/
@media screen and (max-width: 1100px) {
.flex {
flex-wrap: wrap;
}
aside.info,
aside.info + article,
.fixed aside.info,
.fixed aside.info + article {
max-width: none;
width: 100%;
}
/* aside.info {
order: 1;
} */
}
@media screen and (max-width: 800px) {
* {
max-width: none;
}
table {
box-shadow: none;
}
body,
.details .flex > * {
margin: 0;
}
table,
table th,
table td,
table .align-right,
table.align-center {
border: 0;
/* display: block; */
margin-left: auto;
margin-right: auto;
text-align: left;
width: 100%;
}
table td {
display: inline-block;
}
table tbody,
table.media-details {
width: 100%;
}
table.media-details td {
display: block;
text-align: left !important;
width: 100%;
}
table thead {
display: none;
}
.details .media-details td:nth-child(2n+1) {
font-weight: bold;
width: 100%;
}
table.streaming-links tr td:not(:first-child) {
display:none;
}
}
@media screen and (max-width: 40em) {
nav a {
line-height: 4em;
line-height: 4rem;
}
img,
picture {
width: 100%;
}
main {
padding: 0 0, 5em 0.5em;
padding: 0 0.5rem 0.5rem;
}
.media {
margin: 2px 0;
}
.details {
padding: 0.5em;
padding: 0.5rem;
}
/* Expand tabs */
.tabs > [type="radio"]:checked + label {
background: #fff;
}
/* Expand vertical tabs */
.vertical-tabs .tab {
flex-wrap: wrap;
}
.tabs .content,
.tabs > [type="radio"]:checked + label + .content,
.vertical-tabs .tab .content {
display: block;
border: 0;
max-height: none;
}
.tabs > label,
.tabs > label:active,
.tabs > label:hover,
.tabs > [type="radio"]:checked + label,
.vertical-tabs .tab label,
.vertical-tabs .tab label:active,
.vertical-tabs .tab label:hover,
.vertical-tabs [type=radio]:focus + label,
.vertical-tabs [type=radio]:checked + label {
background: #fff;
border: 0;
width: 100%;
cursor: default;
color: #000;
}
}

3
frontEndSrc/cssfilter.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = function filter(filename) {
return ! String(filename).includes('min');
}

View File

@ -0,0 +1,353 @@
// -------------------------------------------------------------------------
// ! Base
// -------------------------------------------------------------------------
const matches = (elm, selector) => {
let m = (elm.document || elm.ownerDocument).querySelectorAll(selector);
let i = matches.length;
while (--i >= 0 && m.item(i) !== elm) {};
return i > -1;
}
const AnimeClient = {
/**
* Placeholder function
*/
noop: () => {},
/**
* DOM selector
*
* @param {string} selector - The dom selector string
* @param {Element} [context]
* @return array of dom elements
*/
$(selector, context = null) {
if (typeof selector !== 'string') {
return selector;
}
context = (context !== null && context.nodeType === 1)
? context
: document;
let elements = [];
if (selector.match(/^#([\w]+$)/)) {
elements.push(document.getElementById(selector.split('#')[1]));
} else {
elements = [].slice.apply(context.querySelectorAll(selector));
}
return elements;
},
/**
* Does the selector exist on the current page?
*
* @param {string} selector
* @returns {boolean}
*/
hasElement (selector) {
return AnimeClient.$(selector).length > 0;
},
/**
* Scroll to the top of the Page
*
* @return {void}
*/
scrollToTop () {
const el = AnimeClient.$('header')[0];
el.scrollIntoView(true);
},
/**
* Hide the selected element
*
* @param {string|Element|Element[]} sel - the selector of the element to hide
* @return {void}
*/
hide (sel) {
if (typeof sel === 'string') {
sel = AnimeClient.$(sel);
}
if (Array.isArray(sel)) {
sel.forEach(el => el.setAttribute('hidden', 'hidden'));
} else {
sel.setAttribute('hidden', 'hidden');
}
},
/**
* UnHide the selected element
*
* @param {string|Element|Element[]} sel - the selector of the element to hide
* @return {void}
*/
show (sel) {
if (typeof sel === 'string') {
sel = AnimeClient.$(sel);
}
if (Array.isArray(sel)) {
sel.forEach(el => el.removeAttribute('hidden'));
} else {
sel.removeAttribute('hidden');
}
},
/**
* Display a message box
*
* @param {string} type - message type: info, error, success
* @param {string} message - the message itself
* @return {void}
*/
showMessage (type, message) {
let template =
`<div class='message ${type}'>
<span class='icon'></span>
${message}
<span class='close'></span>
</div>`;
let sel = AnimeClient.$('.message');
if (sel[0] !== undefined) {
sel[0].remove();
}
AnimeClient.$('header')[0].insertAdjacentHTML('beforeend', template);
},
/**
* Finds the closest parent element matching the passed selector
*
* @param {Element} current - the current Element
* @param {string} parentSelector - selector for the parent element
* @return {Element|null} - the parent element
*/
closestParent (current, parentSelector) {
if (Element.prototype.closest !== undefined) {
return current.closest(parentSelector);
}
while (current !== document.documentElement) {
if (matches(current, parentSelector)) {
return current;
}
current = current.parentElement;
}
return null;
},
/**
* Generate a full url from a relative path
*
* @param {string} path - url path
* @return {string} - full url
*/
url (path) {
let uri = `//${document.location.host}`;
uri += (path.charAt(0) === '/') ? path : `/${path}`;
return uri;
},
/**
* Throttle execution of a function
*
* @see https://remysharp.com/2010/07/21/throttling-function-calls
* @see https://jsfiddle.net/jonathansampson/m7G64/
* @param {Number} interval - the minimum throttle time in ms
* @param {Function} fn - the function to throttle
* @param {Object} [scope] - the 'this' object for the function
* @return {Function}
*/
throttle (interval, fn, scope) {
let wait = false;
return function (...args) {
const context = scope || this;
if ( ! wait) {
fn.apply(context, args);
wait = true;
setTimeout(function() {
wait = false;
}, interval);
}
};
},
};
// -------------------------------------------------------------------------
// ! Events
// -------------------------------------------------------------------------
function addEvent(sel, event, listener) {
// Recurse!
if (! event.match(/^([\w\-]+)$/)) {
event.split(' ').forEach((evt) => {
addEvent(sel, evt, listener);
});
}
sel.addEventListener(event, listener, false);
}
function delegateEvent(sel, target, event, listener) {
// Attach the listener to the parent
addEvent(sel, event, (e) => {
// Get live version of the target selector
AnimeClient.$(target, sel).forEach((element) => {
if(e.target == element) {
listener.call(element, e);
e.stopPropagation();
}
});
});
}
/**
* Add an event listener
*
* @param {string|Element} sel - the parent selector to bind to
* @param {string} event - event name(s) to bind
* @param {string|Element|function} target - the element to directly bind the event to
* @param {function} [listener] - event listener callback
* @return {void}
*/
AnimeClient.on = (sel, event, target, listener) => {
if (listener === undefined) {
listener = target;
AnimeClient.$(sel).forEach((el) => {
addEvent(el, event, listener);
});
} else {
AnimeClient.$(sel).forEach((el) => {
delegateEvent(el, target, event, listener);
});
}
};
// -------------------------------------------------------------------------
// ! Ajax
// -------------------------------------------------------------------------
/**
* Url encoding for non-get requests
*
* @param data
* @returns {string}
* @private
*/
function ajaxSerialize(data) {
let pairs = [];
Object.keys(data).forEach((name) => {
let value = data[name].toString();
name = encodeURIComponent(name);
value = encodeURIComponent(value);
pairs.push(`${name}=${value}`);
});
return pairs.join('&');
}
/**
* Make an ajax request
*
* Config:{
* data: // data to send with the request
* type: // http verb of the request, defaults to GET
* success: // success callback
* error: // error callback
* }
*
* @param {string} url - the url to request
* @param {Object} config - the configuration object
* @return {XMLHttpRequest}
*/
AnimeClient.ajax = (url, config) => {
// Set some sane defaults
const defaultConfig = {
data: {},
type: 'GET',
dataType: '',
success: AnimeClient.noop,
mimeType: 'application/x-www-form-urlencoded',
error: AnimeClient.noop
}
config = {
...defaultConfig,
...config,
}
let request = new XMLHttpRequest();
let method = String(config.type).toUpperCase();
if (method === 'GET') {
url += (url.match(/\?/))
? ajaxSerialize(config.data)
: `?${ajaxSerialize(config.data)}`;
}
request.open(method, url);
request.onreadystatechange = () => {
if (request.readyState === 4) {
let responseText = '';
if (request.responseType === 'json') {
responseText = JSON.parse(request.responseText);
} else {
responseText = request.responseText;
}
if (request.status > 299) {
config.error.call(null, request.status, responseText, request.response);
} else {
config.success.call(null, responseText, request.status);
}
}
};
if (config.dataType === 'json') {
config.data = JSON.stringify(config.data);
config.mimeType = 'application/json';
} else {
config.data = ajaxSerialize(config.data);
}
request.setRequestHeader('Content-Type', config.mimeType);
if (method === 'GET') {
request.send(null);
} else {
request.send(config.data);
}
return request
};
/**
* Do a get request
*
* @param {string} url
* @param {object|function} data
* @param {function} [callback]
* @return {XMLHttpRequest}
*/
AnimeClient.get = (url, data, callback = null) => {
if (callback === null) {
callback = data;
data = {};
}
return AnimeClient.ajax(url, {
data,
success: callback
});
};
// -------------------------------------------------------------------------
// Export
// -------------------------------------------------------------------------
export default AnimeClient;

128
frontEndSrc/js/anime.js Normal file
View File

@ -0,0 +1,128 @@
import _ from './anime-client.js'
import { renderSearchResults } from './template-helpers.js'
import { getNestedProperty, hasNestedProperty } from "./fns";
const search = (query, isCollection = false) => {
// Show the loader
_.show('.cssload-loader');
// Do the api search
return _.get(_.url('/anime-collection/search'), { query }, (searchResults, status) => {
searchResults = JSON.parse(searchResults);
// Hide the loader
_.hide('.cssload-loader');
// Show the results
_.$('#series-list')[ 0 ].innerHTML = renderSearchResults('anime', searchResults, isCollection);
});
};
// Anime list search
if (_.hasElement('.anime #search')) {
let prevRequest = null;
_.on('#search', 'input', _.throttle(250, (e) => {
const query = encodeURIComponent(e.target.value);
if (query === '') {
return;
}
if (prevRequest !== null) {
prevRequest.abort();
}
prevRequest = search(query);
}));
}
// Anime collection search
if (_.hasElement('#search-anime-collection')) {
let prevRequest = null;
_.on('#search-anime-collection', 'input', _.throttle(250, (e) => {
const query = encodeURIComponent(e.target.value);
if (query === '') {
return;
}
if (prevRequest !== null) {
prevRequest.abort();
}
prevRequest = search(query, true);
}));
}
// Action to increment episode count
_.on('body.anime.list', 'click', '.plus-one', (e) => {
let parentSel = _.closestParent(e.target, 'article');
let watchedCount = parseInt(_.$('.completed_number', parentSel)[ 0 ].textContent, 10) || 0;
let totalCount = parseInt(_.$('.total_number', parentSel)[ 0 ].textContent, 10);
let title = _.$('.name a', parentSel)[ 0 ].textContent;
// Setup the update data
let data = {
id: parentSel.dataset.kitsuId,
anilist_id: parentSel.dataset.anilistId,
mal_id: parentSel.dataset.malId,
data: {
progress: watchedCount + 1
}
};
const displayMessage = (type, message) => {
_.hide('#loading-shadow');
_.showMessage(type, `${message} ${title}`);
_.scrollToTop();
}
const showError = () => displayMessage('error', 'Failed to update');
// If the episode count is 0, and incremented,
// change status to currently watching
if (isNaN(watchedCount) || watchedCount === 0) {
data.data.status = 'CURRENT';
}
// If you increment at the last episode, mark as completed
if ((!isNaN(watchedCount)) && (watchedCount + 1) === totalCount) {
data.data.status = 'COMPLETED';
}
_.show('#loading-shadow');
// okay, lets actually make some changes!
_.ajax(_.url('/anime/increment'), {
data,
dataType: 'json',
type: 'POST',
success: (res) => {
try {
const resData = JSON.parse(res);
// Do a rough sanity check for weird errors
let updatedProgress = getNestedProperty(resData, 'data.libraryEntry.update.libraryEntry.progress');
if (hasNestedProperty(resData, 'error') || updatedProgress !== data.data.progress) {
showError();
return;
}
// We've completed the series
if (getNestedProperty(resData, 'data.libraryEntry.update.libraryEntry.status') === 'COMPLETED') {
_.hide(parentSel);
displayMessage('success', 'Completed')
return;
}
// Just a normal update
_.$('.completed_number', parentSel)[ 0 ].textContent = ++watchedCount;
displayMessage('success', 'Updated');
} catch (_) {
showError();
}
},
error: showError,
});
});

View File

@ -0,0 +1,83 @@
const LightTableSorter = (() => {
let th = null;
let cellIndex = null;
let order = '';
const text = (row) => row.cells.item(cellIndex).textContent.toLowerCase();
const sort = (a, b) => {
let textA = text(a);
let textB = text(b);
console.log("Comparing " + textA + " and " + textB)
if(th.classList.contains("numeric")){
let arrayA = textA.replace('episodes: ','').replace('-',0).split("/");
let arrayB = textB.replace('episodes: ','').replace('-',0).split("/");
if(arrayA.length > 1) {
textA = parseInt(arrayA[0],10) / parseInt(arrayA[1],10);
textB = parseInt(arrayB[0],10) / parseInt(arrayB[1],10);
}
else{
textA = parseInt(arrayA[0],10);
textB = parseInt(arrayB[0],10);
}
}
else if (parseInt(textA, 10)) {
textA = parseInt(textA, 10);
textB = parseInt(textB, 10);
}
if (textA > textB) {
return 1;
}
if (textA < textB) {
return -1;
}
return 0;
};
const toggle = () => {
const c = order !== 'sorting-asc' ? 'sorting-asc' : 'sorting-desc';
th.className = (th.className.replace(order, '') + ' ' + c).trim();
return order = c;
};
const reset = () => {
th.classList.remove('sorting-asc', 'sorting-desc');
th.classList.add('sorting');
return order = '';
};
const onClickEvent = (e) => {
if (th && (cellIndex !== e.target.cellIndex)) {
reset();
}
th = e.target;
if (th.nodeName.toLowerCase() === 'th') {
cellIndex = th.cellIndex;
const tbody = th.offsetParent.getElementsByTagName('tbody')[0];
let rows = Array.from(tbody.rows);
if (rows) {
rows.sort(sort);
if (order === 'sorting-asc') {
rows.reverse();
}
toggle();
tbody.innerHtml = '';
rows.forEach(row => {
tbody.appendChild(row);
});
}
}
};
return {
init: () => {
let ths = document.getElementsByTagName('th');
let results = [];
for (let i = 0, len = ths.length; i < len; i++) {
let th = ths[i];
th.classList.add('sorting');
th.classList.add('testing');
results.push(th.onclick = onClickEvent);
}
return results;
}
};
})();
LightTableSorter.init();

Some files were not shown because too many files have changed in this diff Show More