feat: Implement individual kanji view with niceties

This commit is contained in:
Dendy 2025-09-04 17:08:37 +02:00
parent b67434abda
commit 649cecfa36
8 changed files with 243 additions and 14 deletions

View File

@ -5,6 +5,7 @@ namespace App\Controller;
use App\Entity\UnicodeNote;
use App\Service\AnkiService;
use App\Service\CharListService;
use App\Utils\Japanese;
use App\Utils\Number;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -79,10 +80,6 @@ class KanjiController extends AbstractController
string $end,
Request $request,
): Response {
$jiten = json_decode(file_get_contents(
"{$this->getParameter('kernel.project_dir')}/data/kanken-links.json",
), true);
$charInfo = $this->getCharInfo(force: $request->isNoCache());
$chars = [];
@ -115,4 +112,31 @@ class KanjiController extends AbstractController
return new Response();
}
#[Route('/{char}/view', 'view', methods: 'GET')]
public function view(string $char): Response
{
$charStr = ctype_xdigit($char) ? Number::parseCodepoint($char) : $char;
$codepoint = ctype_xdigit($char) ? $char : dechex(mb_ord($char));
$charInfo = $this->getCharInfo()[$charStr] ?? [];
//$listTitles = $this->charList->getTitles();
$jiten = json_decode(file_get_contents(
"{$this->getParameter('kernel.project_dir')}/data/kanken-links.json",
), true);
$ebookRef = require "$this->varBasepath/ebook-ref.php";
return $this->render(self::tmpl('view'), [
'char' => $charStr,
'codepoint' => $codepoint,
'info' => $charInfo,
'ref' => $ebookRef[$charStr] ?? [],
'jiten_href' => key_exists($charStr, $jiten)
? "https://kanji.jitenon.jp/kanji{$jiten[$charStr]}"
: null
]);
}
}

View File

@ -3,6 +3,8 @@
namespace App\Service;
use App\Utils\Japanese;
use DirectoryIterator;
use Symfony\Component\Filesystem\Filesystem;
class CharListService
{
@ -15,7 +17,7 @@ class CharListService
*
* @return array<string, string|array<string, 0>|array<string|array<string, 0>>>
*/
function getList(string $name): array
public function getList(string $name): array
{
if (!ctype_alnum($name)) throw new \Exception(sprintf(
'Invalid name for list "%s". Only alphanumeric characters allowed',
@ -68,6 +70,25 @@ class CharListService
return $ret;
}
/** @return array<string, string> */
public function getTitles(): array
{
$ret = [];
$dirIter = new DirectoryIterator($this->basepath);
foreach ($dirIter as $entry) {
if (
$entry->isDot() or
!str_ends_with($entry->getFilename(), ".list")
) continue;
$file = $entry->openFile();
$ret[$entry->getBasename('.list')] = $file->fgets();
}
return $ret;
}
/**
* Returns array on success, true on non-block
*

View File

@ -0,0 +1,28 @@
<?php
namespace App\Twig\Extension;
use App\Twig\Runtime\AppExtensionRuntime;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
// If your filter generates SAFE HTML, you should add a third
// parameter: ['is_safe' => ['html']]
// Reference: https://twig.symfony.com/doc/3.x/advanced.html#automatic-escaping
new TwigFilter('basename', basename(...)),
];
}
public function getFunctions(): array
{
return [
//new TwigFunction('function_name', [AppExtensionRuntime::class, 'doSomething']),
];
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Twig\Runtime;
use Twig\Extension\RuntimeExtensionInterface;
class AppExtensionRuntime implements RuntimeExtensionInterface
{
public function __construct()
{
// Inject dependencies if needed
}
public function doSomething($value)
{
// ...
}
}

View File

@ -52,4 +52,10 @@ class Number
$value = hexdec($num);
return is_int($value) ? $value : null;
}
public static function parseCodepoint(int|string $char): string
{
$charInt = is_int($char) ? $char : self::hexint($char);
return mb_chr($charInt, 'UTF-8');
}
}

View File

@ -13,5 +13,67 @@
</head>
<body>
{% block body %}{% endblock %}
<div
class="modal fade modal-xl"
id="baseModal"
tabindex="-1"
aria-labelledby="baseModalLabel"
aria-hidden="true"
hx-indicator="#baseModalIndicator"
>
<div class="modal-dialog" style="position: relative;">
<div
class="modal-content"
id="baseModalContent"
style="min-height: 400px;"
hx-swap="innerHTML"
hx-indicator="#baseModalIndicator"
>
<div class="modal-header">
<h1
class="modal-title fs-5"
id="baseModalLabel"
>
Modal
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
>
</button>
</div>
<div class="modal-body" style="min-height: 350px;"></div>
</div>
{#<div
id="baseModalIndicator"
class="display-indicator"
style="
background-color: rgba(255,255,255,0.75);
display: block !important;
position: absolute;
z-index: 999;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
"
>
<div class="d-flex flex-column align-items-center justify-content-center h-100 gap-3">
<div style="width: 3rem; height: 3rem;" class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div>Loading...</div>
</div>
</div>#}
</div>
</div> <!-- modal -->
</body>
</html>

View File

@ -45,7 +45,12 @@
<div class="example-wrapper">
<h1 class="text-center p-2 mb-2">{{ block('title') }}</h1>
<div class="d-flex flex-wrap m-2">
<div
class="d-flex flex-wrap m-2"
hx-target="#baseModalContent"
hx-swap="innerHTML"
>
{% for char in characters %}
<div style="width: calc(100% / 16);">
<div
@ -56,8 +61,15 @@
"
title="{{ char.lists|keys|join("\n") }}"
>
<div class="kanji-card border-bottom fs-4 pb-1">
{% if char.jiten_href != null %}
<div
class="kanji-card border-bottom fs-1 pb-1"
hx-get="{{ path('app_kanji_view', { char: char.codepoint }) }}"
data-bs-toggle="modal"
data-bs-target="#baseModal"
>
{#{% if char.jiten_href != null %}
<a
target="_blank"
href="{{- char.jiten_href -}}"
@ -65,14 +77,12 @@
>
{{- char.str -}}
</a>
{% else %}
{% else %}#}
{{- char.str -}}
{% endif %}
{#{% endif %}#}
</div>
<div style="
font-size: 0.5em;
padding-bottom: 2px;
">
<div style="padding-bottom: 2px;">
{% if char.lists.unicode is not defined %}
<a
target="_blank"

View File

@ -0,0 +1,60 @@
<div class="d-flex flex-column gap-2 p-3 align-items-start">
<div class="d-flex gap-2 w-100">
<div class="card">
<div class="card-body">
<div style="font-size: 6em;">{{ char }}</div>
</div>
<div class="card-footer text-center">
{{ codepoint }}
</div>
</div>
<div class="flex-grow-1">
<h2>Lists</h2>
<ul>
{% for list in info|keys %}
<li>{{ list }}</li>
{% endfor %}
</ul>
</div>
<div class="flex-grow-1">
<h2>Links</h2>
<ul>
{% if jiten_href is not null %}
<li><a class="text-primary" href="{{ jiten_href }}">漢字辞典</a></li>
{% else %}
<li class="text-muted">漢字辞典</li>
{% endif %}
<li>
<a
class="text-primary"
href="{{ 'https://en.wiktionary.org/wiki/' ~ char|url_encode }}"
>
Wiktionary EN
</a>
</li>
<li>
<a
class="text-primary"
href="{{ 'https://ja.wiktionary.org/wiki/' ~ char|url_encode }}"
>
Wiktionary JA
</a>
</li>
<li>
<a
class="text-primary"
href="{{ 'https://forvo.com/word/' ~ char|url_encode ~ '/#zh' }}"
>
Forvo
</a>
</li>
</ul>
</div>
</div>
<div class="d-flex flex-column">
<h2>Appeareances</h2>
{% for refname, refcount in ref %}
<div>{{ refname|basename }}: {{ refcount }}</div>
{% endfor %}
</div>
</div>