57 changed files with 1871 additions and 385 deletions
@ -0,0 +1,36 @@ |
|||||||
|
FROM php:8.2-cli |
||||||
|
|
||||||
|
# Install cron and Redis PHP extension dependencies |
||||||
|
RUN apt-get update && apt-get install -y \ |
||||||
|
cron \ |
||||||
|
libzip-dev \ |
||||||
|
libicu-dev \ |
||||||
|
libpq-dev \ |
||||||
|
libonig-dev |
||||||
|
|
||||||
|
# Install Redis PHP extension |
||||||
|
RUN pecl install redis \ |
||||||
|
&& docker-php-ext-enable redis |
||||||
|
|
||||||
|
RUN docker-php-ext-install pdo pdo_pgsql |
||||||
|
|
||||||
|
|
||||||
|
# Set working directory |
||||||
|
WORKDIR /var/www/html |
||||||
|
|
||||||
|
# Install Symfony CLI tools (optional) |
||||||
|
# RUN curl -sS https://get.symfony.com/cli/installer | bash |
||||||
|
|
||||||
|
# Copy cron and script |
||||||
|
COPY crontab /etc/cron.d/app-cron |
||||||
|
COPY index_articles.sh /index_articles.sh |
||||||
|
|
||||||
|
# Set permissions |
||||||
|
RUN chmod 0644 /etc/cron.d/app-cron && \ |
||||||
|
chmod +x /index_articles.sh |
||||||
|
|
||||||
|
# Apply cron job |
||||||
|
RUN crontab /etc/cron.d/app-cron |
||||||
|
|
||||||
|
# Run cron in the foreground |
||||||
|
CMD ["cron", "-f"] |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
|
||||||
|
# 🕒 Cron Job Container |
||||||
|
|
||||||
|
This folder contains the Docker configuration to run scheduled Symfony commands via cron inside a separate container. |
||||||
|
|
||||||
|
- Run Symfony console commands periodically using a cron schedule (e.g. every 6 hours) |
||||||
|
- Decouple scheduled jobs from the main PHP/FPM container |
||||||
|
- Easily manage and test cron execution in a Dockerized Symfony project |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Build & Run |
||||||
|
|
||||||
|
1. **Build the cron image** |
||||||
|
From the project root: |
||||||
|
```bash |
||||||
|
docker-compose build cron |
||||||
|
``` |
||||||
|
|
||||||
|
2. **Start the cron container** |
||||||
|
```bash |
||||||
|
docker-compose up -d cron |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Cron Schedule |
||||||
|
|
||||||
|
The default cron schedule is set to run **every 6 hours**: |
||||||
|
|
||||||
|
```cron |
||||||
|
0 */6 * * * root /run_commands.sh >> /var/log/cron.log 2>&1 |
||||||
|
``` |
||||||
|
|
||||||
|
To customize the schedule, edit the `crontab` file and rebuild the container. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Testing & Debugging |
||||||
|
|
||||||
|
### Manually test the command runner |
||||||
|
|
||||||
|
You can run the script manually to check behavior without waiting for the cron trigger: |
||||||
|
|
||||||
|
```bash |
||||||
|
docker-compose exec cron /run_commands.sh |
||||||
|
``` |
||||||
|
|
||||||
|
### Check the cron output log |
||||||
|
|
||||||
|
```bash |
||||||
|
docker-compose exec cron tail -f /var/log/cron.log |
||||||
|
``` |
||||||
|
|
||||||
|
### Shell into the cron container |
||||||
|
|
||||||
|
```bash |
||||||
|
docker-compose exec cron bash |
||||||
|
``` |
||||||
|
|
||||||
|
Once inside, you can: |
||||||
|
- Check crontab entries: `crontab -l` |
||||||
|
- Manually trigger cron: `cron` or `cron -f` (in another session) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Customization |
||||||
|
|
||||||
|
- **Add/Remove Symfony Commands:** |
||||||
|
Edit `run_commands.sh` to include the commands you want to run. |
||||||
|
|
||||||
|
- **Change Schedule:** |
||||||
|
Edit `crontab` using standard cron syntax. |
||||||
|
|
||||||
|
- **Logging:** |
||||||
|
Logs are sent to `/var/log/cron.log` inside the container. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Rebuilding After Changes |
||||||
|
|
||||||
|
If you modify the `crontab` or `run_commands.sh`, make sure to rebuild: |
||||||
|
|
||||||
|
```bash |
||||||
|
docker-compose build cron |
||||||
|
docker-compose up -d cron |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Notes |
||||||
|
|
||||||
|
- Symfony project source is mounted at `/var/www/html` via volume. |
||||||
|
- Make sure your commands do **not rely on services** (like `php-fpm`) that are not running in this container. |
||||||
@ -0,0 +1 @@ |
|||||||
|
0 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1 |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
#!/bin/bash |
||||||
|
set -e |
||||||
|
|
||||||
|
# Run Symfony commands sequentially |
||||||
|
/usr/local/bin/php /var/www/html/bin/console articles:get |
||||||
|
/usr/local/bin/php /var/www/html/bin/console articles:qa |
||||||
|
/usr/local/bin/php /var/www/html/bin/console articles:index |
||||||
|
/usr/local/bin/php /var/www/html/bin/console articles:indexed |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace DoctrineMigrations; |
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema; |
||||||
|
use Doctrine\Migrations\AbstractMigration; |
||||||
|
|
||||||
|
/** |
||||||
|
* Auto-generated Migration: Please modify to your needs! |
||||||
|
*/ |
||||||
|
final class Version20250509100039 extends AbstractMigration |
||||||
|
{ |
||||||
|
public function getDescription(): string |
||||||
|
{ |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
public function up(Schema $schema): void |
||||||
|
{ |
||||||
|
// this up() migration is auto-generated, please modify it to your needs |
||||||
|
$this->addSql(<<<'SQL' |
||||||
|
DROP TABLE sessions |
||||||
|
SQL); |
||||||
|
} |
||||||
|
|
||||||
|
public function down(Schema $schema): void |
||||||
|
{ |
||||||
|
// this down() migration is auto-generated, please modify it to your needs |
||||||
|
$this->addSql(<<<'SQL' |
||||||
|
CREATE TABLE sessions (sess_id VARCHAR(128) NOT NULL, sess_data BYTEA NOT NULL, sess_lifetime INT NOT NULL, sess_time INT NOT NULL, PRIMARY KEY(sess_id)) |
||||||
|
SQL); |
||||||
|
$this->addSql(<<<'SQL' |
||||||
|
CREATE INDEX sess_lifetime_idx ON sessions (sess_lifetime) |
||||||
|
SQL); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace DoctrineMigrations; |
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema; |
||||||
|
use Doctrine\Migrations\AbstractMigration; |
||||||
|
|
||||||
|
/** |
||||||
|
* Auto-generated Migration: Please modify to your needs! |
||||||
|
*/ |
||||||
|
final class Version20250509100408 extends AbstractMigration |
||||||
|
{ |
||||||
|
public function getDescription(): string |
||||||
|
{ |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
public function up(Schema $schema): void |
||||||
|
{ |
||||||
|
// this up() migration is auto-generated, please modify it to your needs |
||||||
|
$this->addSql(<<<'SQL' |
||||||
|
CREATE TABLE credit_transaction (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(64) NOT NULL, amount INT NOT NULL, type VARCHAR(16) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, reason VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) |
||||||
|
SQL); |
||||||
|
} |
||||||
|
|
||||||
|
public function down(Schema $schema): void |
||||||
|
{ |
||||||
|
// this down() migration is auto-generated, please modify it to your needs |
||||||
|
$this->addSql(<<<'SQL' |
||||||
|
DROP TABLE credit_transaction |
||||||
|
SQL); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Entity\Article; |
||||||
|
use App\Enum\IndexStatusEnum; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
|
||||||
|
#[AsCommand(name: 'db:cleanup', description: 'Remove articles with do_not_index rating')] |
||||||
|
class DatabaseCleanupCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct(private readonly EntityManagerInterface $entityManager) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
|
||||||
|
$repository = $this->entityManager->getRepository(Article::class); |
||||||
|
$items = $repository->findBy(['indexStatus' => IndexStatusEnum::DO_NOT_INDEX]); |
||||||
|
|
||||||
|
if (empty($items)) { |
||||||
|
$output->writeln('<info>No items found.</info>'); |
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
foreach ($items as $item) { |
||||||
|
$this->entityManager->remove($item); |
||||||
|
} |
||||||
|
|
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
$output->writeln('<comment>Deleted ' . count($items) . ' items.</comment>'); |
||||||
|
|
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,52 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Entity\User; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputArgument; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
|
||||||
|
#[AsCommand( |
||||||
|
name: 'user:elevate', |
||||||
|
description: 'Assign a role to user' |
||||||
|
)] |
||||||
|
class ElevateUserCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct(private readonly EntityManagerInterface $entityManager) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function configure(): void |
||||||
|
{ |
||||||
|
$this |
||||||
|
->addArgument('arg1', InputArgument::REQUIRED, 'User npub') |
||||||
|
->addArgument('arg2', InputArgument::REQUIRED, 'Role to set'); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$npub = $input->getArgument('arg1'); |
||||||
|
$role = $input->getArgument('arg2'); |
||||||
|
if (!str_starts_with($role, 'ROLE_')) { |
||||||
|
return Command::INVALID; |
||||||
|
} |
||||||
|
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); |
||||||
|
if (!$user) { |
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
|
||||||
|
$user->addRole($role); |
||||||
|
$this->entityManager->persist($user); |
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Service\NostrClient; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
|
||||||
|
#[AsCommand( |
||||||
|
name: 'articles:get', |
||||||
|
description: 'Pull articles from a default relay', |
||||||
|
)] |
||||||
|
class GetArticlesCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct(private readonly NostrClient $nostrClient) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
|
||||||
|
$this->nostrClient->getLongFormContent(); |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,64 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Entity\Article; |
||||||
|
use App\Enum\IndexStatusEnum; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
|
||||||
|
#[AsCommand(name: 'articles:index', description: 'Persist selected articles to Elastic')] |
||||||
|
class IndexArticlesCommand extends Command |
||||||
|
{ |
||||||
|
private const BATCH_SIZE = 100; // Define batch size |
||||||
|
|
||||||
|
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ObjectPersisterInterface $itemPersister) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
|
||||||
|
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED]); |
||||||
|
|
||||||
|
$batchCount = 0; |
||||||
|
$processedCount = 0; |
||||||
|
|
||||||
|
foreach ($articles as $item) { |
||||||
|
$batchCount++; |
||||||
|
|
||||||
|
// Collect batch of entities for indexing |
||||||
|
$batchItems[] = $item; |
||||||
|
|
||||||
|
// Process batch when limit is reached |
||||||
|
if ($batchCount >= self::BATCH_SIZE) { |
||||||
|
$this->flushAndPersistBatch($batchItems); |
||||||
|
$processedCount += $batchCount; |
||||||
|
$batchCount = 0; |
||||||
|
$batchItems = []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Process any remaining items |
||||||
|
if (!empty($batchItems)) { |
||||||
|
$this->flushAndPersistBatch($batchItems); |
||||||
|
$processedCount += count($batchItems); |
||||||
|
} |
||||||
|
|
||||||
|
$output->writeln("$processedCount items indexed in Elasticsearch."); |
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
private function flushAndPersistBatch(array $items): void |
||||||
|
{ |
||||||
|
// Persist batch to Elasticsearch |
||||||
|
$this->itemPersister->replaceMany($items); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Entity\Article; |
||||||
|
use App\Enum\IndexStatusEnum; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
|
||||||
|
#[AsCommand(name: 'articles:indexed', description: 'Mark articles as indexed after populating')] |
||||||
|
class MarkAsIndexedCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct(private readonly EntityManagerInterface $entityManager) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED]); |
||||||
|
$count = 0; |
||||||
|
foreach ($articles as $article) { |
||||||
|
if ($article instanceof Article) { |
||||||
|
$count += 1; |
||||||
|
$article->setIndexStatus(IndexStatusEnum::INDEXED); |
||||||
|
$this->entityManager->persist($article); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
$output->writeln($count . ' articles marked as indexed successfully.'); |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,86 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use swentel\nostr\Event\Event; |
||||||
|
use swentel\nostr\Sign\Sign; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputArgument; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
use Symfony\Component\Finder\Finder; |
||||||
|
use Symfony\Component\Yaml\Yaml; |
||||||
|
use Symfony\Contracts\Cache\CacheInterface; |
||||||
|
|
||||||
|
#[AsCommand(name: 'app:yaml_to_nostr', description: 'Traverses folders, converts YAML files to JSON using object mapping, and saves the result in Redis cache.')] |
||||||
|
class NostrEventFromYamlDefinitionCommand extends Command |
||||||
|
{ |
||||||
|
const private_key = 'nsec17ygfd40ckdwmrl4mzhnzzdr3c8j5kvnavgrct35hglha9ue396dslsterv'; |
||||||
|
|
||||||
|
public function __construct(private readonly CacheInterface $redisCache) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function configure(): void |
||||||
|
{ |
||||||
|
$this |
||||||
|
->addArgument('folder', InputArgument::REQUIRED, 'The folder location to start scanning from.'); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$folder = $input->getArgument('folder'); |
||||||
|
|
||||||
|
// Use Symfony Finder to locate YAML files recursively |
||||||
|
$finder = new Finder(); |
||||||
|
$finder->files() |
||||||
|
->in($folder) |
||||||
|
->name('*.yaml') |
||||||
|
->name('*.yml'); |
||||||
|
|
||||||
|
if (!$finder->hasResults()) { |
||||||
|
$output->writeln('<comment>No YAML files found in the specified directory.</comment>'); |
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
foreach ($finder as $file) { |
||||||
|
$filePath = $file->getRealPath(); |
||||||
|
$output->writeln("<info>Processing file: $filePath</info>"); |
||||||
|
$yamlContent = Yaml::parseFile($filePath); // This parses the YAML file |
||||||
|
|
||||||
|
try { |
||||||
|
// Deserialize YAML content into an Event object |
||||||
|
$event = new Event(); |
||||||
|
$event->setKind(30040); |
||||||
|
$event->setPublicKey('e00983324f38e8522ffc01d5c064727e43fe4c43d86a5c2a0e73290674e496f8'); |
||||||
|
$tags = $yamlContent['tags']; |
||||||
|
$event->setTags($tags); |
||||||
|
|
||||||
|
$signer = new Sign(); |
||||||
|
$signer->signEvent($event, NostrEventFromYamlDefinitionCommand::private_key); |
||||||
|
|
||||||
|
// Save to cache |
||||||
|
$slug = array_filter($tags, function ($tag) { |
||||||
|
return ($tag[0] === 'd'); |
||||||
|
}); |
||||||
|
// Generate a Redis key |
||||||
|
$cacheKey = 'magazine-' . $slug[0][1]; |
||||||
|
$cacheItem = $this->redisCache->getItem($cacheKey); |
||||||
|
$cacheItem->set($event); |
||||||
|
$this->redisCache->save($cacheItem); |
||||||
|
|
||||||
|
$output->writeln("<info>Saved index.</info>"); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$output->writeln("<error>Error deserializing YAML in file: $filePath. Message: {$e->getMessage()}</error>"); |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$output->writeln('<info>Conversion complete.</info>'); |
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Entity\Article; |
||||||
|
use App\Enum\IndexStatusEnum; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use FOS\ElasticaBundle\Persister\ObjectPersister; |
||||||
|
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
|
||||||
|
#[AsCommand(name: 'articles:qa', description: 'Mark articles by quality and select which to index')] |
||||||
|
class QualityCheckArticlesCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly EntityManagerInterface $entityManager |
||||||
|
) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::NOT_INDEXED]); |
||||||
|
$count = 0; |
||||||
|
foreach ($articles as $article) { |
||||||
|
if ($this->meetsCriteria($article)) { |
||||||
|
$count += 1; |
||||||
|
$article->setIndexStatus(IndexStatusEnum::TO_BE_INDEXED); |
||||||
|
} else { |
||||||
|
$article->setIndexStatus(IndexStatusEnum::DO_NOT_INDEX); |
||||||
|
} |
||||||
|
$this->entityManager->persist($article); |
||||||
|
} |
||||||
|
|
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
$output->writeln($count . ' articles marked for indexing successfully.'); |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
private function meetsCriteria(Article $article): bool |
||||||
|
{ |
||||||
|
$content = $article->getContent(); |
||||||
|
|
||||||
|
// No empty title |
||||||
|
if (empty($article->getTitle()) || strtolower($article->getTitle()) === 'test') { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// Do not index stacker news reposts |
||||||
|
if (str_contains($content, 'originally posted at https://stacker.news')) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// Slug must not contain '/' and should not be empty |
||||||
|
if (empty($article->getSlug()) || str_contains($article->getSlug(), '/')) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// Only index articles with more than 12 words |
||||||
|
return str_word_count($article->getContent()) > 12; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Controller\Administration; |
||||||
|
|
||||||
|
use App\Credits\Entity\CreditTransaction; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
class CreditTransactionController extends AbstractController |
||||||
|
{ |
||||||
|
#[Route('/admin/transactions', name: 'admin_credit_transactions')] |
||||||
|
public function index(EntityManagerInterface $em): Response |
||||||
|
{ |
||||||
|
$transactions = $em->getRepository(CreditTransaction::class)->findBy([], ['createdAt' => 'DESC']); |
||||||
|
|
||||||
|
return $this->render('admin/transactions.html.twig', [ |
||||||
|
'transactions' => $transactions, |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,100 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Credits\Entity; |
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM; |
||||||
|
|
||||||
|
#[ORM\Entity] |
||||||
|
class CreditTransaction |
||||||
|
{ |
||||||
|
#[ORM\Id] |
||||||
|
#[ORM\GeneratedValue] |
||||||
|
#[ORM\Column(type: 'integer')] |
||||||
|
private int $id; |
||||||
|
|
||||||
|
#[ORM\Column(length: 64)] |
||||||
|
private string $npub; |
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')] |
||||||
|
private int $amount; |
||||||
|
|
||||||
|
#[ORM\Column(length: 16)] |
||||||
|
private string $type; // 'credit' or 'debit' |
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime')] |
||||||
|
private \DateTime $createdAt; |
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)] |
||||||
|
private ?string $reason = null; |
||||||
|
|
||||||
|
public function __construct(string $npub, int $amount, string $type, ?string $reason = null) |
||||||
|
{ |
||||||
|
$this->npub = $npub; |
||||||
|
$this->amount = $amount; |
||||||
|
$this->type = $type; |
||||||
|
$this->createdAt = new \DateTime(); |
||||||
|
$this->reason = $reason; |
||||||
|
} |
||||||
|
|
||||||
|
public function getId(): int |
||||||
|
{ |
||||||
|
return $this->id; |
||||||
|
} |
||||||
|
|
||||||
|
public function setId(int $id): void |
||||||
|
{ |
||||||
|
$this->id = $id; |
||||||
|
} |
||||||
|
|
||||||
|
public function getNpub(): string |
||||||
|
{ |
||||||
|
return $this->npub; |
||||||
|
} |
||||||
|
|
||||||
|
public function setNpub(string $npub): void |
||||||
|
{ |
||||||
|
$this->npub = $npub; |
||||||
|
} |
||||||
|
|
||||||
|
public function getAmount(): int |
||||||
|
{ |
||||||
|
return $this->amount; |
||||||
|
} |
||||||
|
|
||||||
|
public function setAmount(int $amount): void |
||||||
|
{ |
||||||
|
$this->amount = $amount; |
||||||
|
} |
||||||
|
|
||||||
|
public function getType(): string |
||||||
|
{ |
||||||
|
return $this->type; |
||||||
|
} |
||||||
|
|
||||||
|
public function setType(string $type): void |
||||||
|
{ |
||||||
|
$this->type = $type; |
||||||
|
} |
||||||
|
|
||||||
|
public function getCreatedAt(): \DateTime |
||||||
|
{ |
||||||
|
return $this->createdAt; |
||||||
|
} |
||||||
|
|
||||||
|
public function setCreatedAt(\DateTime $createdAt): void |
||||||
|
{ |
||||||
|
$this->createdAt = $createdAt; |
||||||
|
} |
||||||
|
|
||||||
|
public function getReason(): ?string |
||||||
|
{ |
||||||
|
return $this->reason; |
||||||
|
} |
||||||
|
|
||||||
|
public function setReason(?string $reason): void |
||||||
|
{ |
||||||
|
$this->reason = $reason; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,68 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Credits\Service; |
||||||
|
|
||||||
|
use App\Credits\Entity\CreditTransaction; |
||||||
|
use App\Credits\Util\RedisCreditStore; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Psr\Cache\InvalidArgumentException; |
||||||
|
|
||||||
|
readonly class CreditsManager |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private RedisCreditStore $redisStore, |
||||||
|
private EntityManagerInterface $em |
||||||
|
) {} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function getBalance(string $npub): int |
||||||
|
{ |
||||||
|
return $this->redisStore->getBalance($npub); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function resetBalance(string $npub): int |
||||||
|
{ |
||||||
|
return $this->redisStore->resetBalance($npub); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function addCredits(string $npub, int $amount, ?string $reason = null): void |
||||||
|
{ |
||||||
|
$this->redisStore->addCredits($npub, $amount); |
||||||
|
|
||||||
|
$tx = new CreditTransaction($npub, $amount, 'credit', $reason); |
||||||
|
$this->em->persist($tx); |
||||||
|
$this->em->flush(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function canAfford(string $npub, int $cost): bool |
||||||
|
{ |
||||||
|
return $this->getBalance($npub) >= $cost; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function spendCredits(string $npub, int $cost, ?string $reason = null): void |
||||||
|
{ |
||||||
|
if (!$this->canAfford($npub, $cost)) { |
||||||
|
throw new \RuntimeException("Insufficient credits for $npub"); |
||||||
|
} |
||||||
|
|
||||||
|
$this->redisStore->spendCredits($npub, $cost); |
||||||
|
|
||||||
|
$tx = new CreditTransaction($npub, $cost, 'debit', $reason); |
||||||
|
$this->em->persist($tx); |
||||||
|
$this->em->flush(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Credits\Util; |
||||||
|
|
||||||
|
use App\Credits\Entity\CreditTransaction; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Psr\Cache\InvalidArgumentException; |
||||||
|
use Symfony\Contracts\Cache\CacheInterface; |
||||||
|
|
||||||
|
readonly class RedisCreditStore |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private CacheInterface $creditsCache, |
||||||
|
private EntityManagerInterface $em |
||||||
|
) {} |
||||||
|
|
||||||
|
private function key(string $npub): string |
||||||
|
{ |
||||||
|
return 'credits_' . $npub; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function resetBalance(string $npub): int |
||||||
|
{ |
||||||
|
$this->creditsCache->delete($this->key($npub)); |
||||||
|
|
||||||
|
// Fetch all transactions for the given npub |
||||||
|
$transactions = $this->em->getRepository(CreditTransaction::class) |
||||||
|
->findBy(['npub' => $npub]); |
||||||
|
|
||||||
|
// Initialize the balance |
||||||
|
$balance = 0; |
||||||
|
|
||||||
|
// Calculate the final balance based on the transactions |
||||||
|
foreach ($transactions as $tx) { |
||||||
|
if ($tx->getType() === 'credit') { |
||||||
|
$balance += $tx->getAmount(); |
||||||
|
} elseif ($tx->getType() === 'debit') { |
||||||
|
$balance -= $tx->getAmount(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Write the calculated balance into the Redis cache |
||||||
|
$item = $this->creditsCache->getItem($this->key($npub)); |
||||||
|
$item->set($balance); |
||||||
|
$this->creditsCache->save($item); |
||||||
|
|
||||||
|
return $balance; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function getBalance(string $npub): int |
||||||
|
{ |
||||||
|
// Use cache pool to fetch the credit balance |
||||||
|
return $this->creditsCache->get($this->key($npub), function () use ($npub) { |
||||||
|
return $this->resetBalance($npub); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function addCredits(string $npub, int $amount): void |
||||||
|
{ |
||||||
|
$currentBalance = $this->getBalance($npub); |
||||||
|
$item = $this->creditsCache->getItem($this->key($npub)); |
||||||
|
$balance = $currentBalance + $amount; |
||||||
|
$item->set($balance); |
||||||
|
$this->creditsCache->save($item); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function spendCredits(string $npub, int $amount): void |
||||||
|
{ |
||||||
|
$currentBalance = $this->getBalance($npub); |
||||||
|
$item = $this->creditsCache->getItem($this->key($npub)); |
||||||
|
if ($currentBalance < $amount) { |
||||||
|
throw new \RuntimeException('Insufficient credits'); |
||||||
|
} |
||||||
|
$balance = $currentBalance - $amount; |
||||||
|
$item->set($balance); |
||||||
|
$this->creditsCache->save($item); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components; |
||||||
|
|
||||||
|
use App\Credits\Service\CreditsManager; |
||||||
|
use Psr\Cache\InvalidArgumentException; |
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveAction; |
||||||
|
use Symfony\UX\LiveComponent\ComponentToolsTrait; |
||||||
|
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||||
|
|
||||||
|
#[AsLiveComponent] |
||||||
|
final class GetCreditsComponent |
||||||
|
{ |
||||||
|
use DefaultActionTrait; |
||||||
|
use ComponentToolsTrait; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly CreditsManager $creditsManager, |
||||||
|
private readonly TokenStorageInterface $tokenStorage) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
#[LiveAction] |
||||||
|
public function grantVoucher(): void |
||||||
|
{ |
||||||
|
$npub = $this->tokenStorage->getToken()?->getUserIdentifier(); |
||||||
|
if ($npub) { |
||||||
|
$this->creditsManager->addCredits($npub, 5, 'voucher'); |
||||||
|
} |
||||||
|
|
||||||
|
// Dispatch event to notify parent |
||||||
|
$this->emit('creditsAdded', [ |
||||||
|
'credits' => 5, |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,40 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components\Molecules; |
||||||
|
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface; |
||||||
|
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||||
|
|
||||||
|
#[AsTwigComponent] |
||||||
|
final class CategoryLink |
||||||
|
{ |
||||||
|
public string $title; |
||||||
|
public string $slug; |
||||||
|
|
||||||
|
public function __construct(private CacheInterface $redisCache) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public function mount($coordinate): void |
||||||
|
{ |
||||||
|
if (key_exists(1, $coordinate)) { |
||||||
|
$parts = explode(':', $coordinate[1]); |
||||||
|
$this->slug = $parts[2]; |
||||||
|
$cat = $this->redisCache->get('magazine-' . $parts[2], function (){ |
||||||
|
return null; |
||||||
|
}); |
||||||
|
|
||||||
|
$tags = $cat->getTags(); |
||||||
|
|
||||||
|
$title = array_filter($tags, function($tag) { |
||||||
|
return ($tag[0] === 'title'); |
||||||
|
}); |
||||||
|
|
||||||
|
$this->title = $title[array_key_first($title)][1]; |
||||||
|
} else { |
||||||
|
dump($coordinate);die(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components\Organisms; |
||||||
|
|
||||||
|
use App\Service\NostrClient; |
||||||
|
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||||
|
|
||||||
|
#[AsTwigComponent] |
||||||
|
final class Comments |
||||||
|
{ |
||||||
|
public array $list = []; |
||||||
|
|
||||||
|
public function __construct(private readonly NostrClient $nostrClient) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws \Exception |
||||||
|
*/ |
||||||
|
public function mount($current): void |
||||||
|
{ |
||||||
|
// fetch comments, kind 1111 |
||||||
|
$this->list = $this->nostrClient->getComments($current); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,52 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components\Organisms; |
||||||
|
|
||||||
|
use Elastica\Query\MatchQuery; |
||||||
|
use FOS\ElasticaBundle\Finder\FinderInterface; |
||||||
|
use Psr\Cache\InvalidArgumentException; |
||||||
|
use swentel\nostr\Event\Event; |
||||||
|
use Symfony\Contracts\Cache\CacheInterface; |
||||||
|
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||||
|
|
||||||
|
#[AsTwigComponent] |
||||||
|
final class FeaturedList |
||||||
|
{ |
||||||
|
public $category; |
||||||
|
public string $title; |
||||||
|
public array $list = []; |
||||||
|
|
||||||
|
public function __construct(private readonly CacheInterface $redisCache, private readonly FinderInterface $finder) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws InvalidArgumentException |
||||||
|
*/ |
||||||
|
public function mount($category): void |
||||||
|
{ |
||||||
|
$parts = explode(':', $category[1]); |
||||||
|
/** @var Event $catIndex */ |
||||||
|
$catIndex = $this->redisCache->get('magazine-' . $parts[2], function (){ |
||||||
|
throw new \Exception('Not found'); |
||||||
|
}); |
||||||
|
|
||||||
|
foreach ($catIndex->getTags() as $tag) { |
||||||
|
if ($tag[0] === 'title') { |
||||||
|
$this->title = $tag[1]; |
||||||
|
} |
||||||
|
if ($tag[0] === 'a') { |
||||||
|
$parts = explode(':', $tag[1]); |
||||||
|
if (count($parts) === 3) { |
||||||
|
$fieldQuery = new MatchQuery(); |
||||||
|
$fieldQuery->setFieldQuery('slug', $parts[2]); |
||||||
|
$res = $this->finder->find($fieldQuery); |
||||||
|
$this->list[] = $res[0]; |
||||||
|
} |
||||||
|
} |
||||||
|
if (count($this->list) > 3) { |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Twig; |
||||||
|
|
||||||
|
use Twig\Extension\AbstractExtension; |
||||||
|
use Twig\TwigFunction; |
||||||
|
use Twig\TwigFilter; |
||||||
|
|
||||||
|
class Filters extends AbstractExtension |
||||||
|
{ |
||||||
|
public function getFilters(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
new TwigFilter('shortenNpub', [$this, 'shortenNpub']), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
public function shortenNpub(string $npub): string |
||||||
|
{ |
||||||
|
return substr($npub, 0, 8) . '…' . substr($npub, -4); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\ImagesExtension; |
||||||
|
|
||||||
|
use League\CommonMark\Environment\EnvironmentBuilderInterface; |
||||||
|
use League\CommonMark\Extension\ExtensionInterface; |
||||||
|
|
||||||
|
class RawImageLinkExtension implements ExtensionInterface |
||||||
|
{ |
||||||
|
public function register(EnvironmentBuilderInterface $environment): void |
||||||
|
{ |
||||||
|
$environment->addInlineParser(new RawImageLinkParser()); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Util\CommonMark\ImagesExtension; |
||||||
|
|
||||||
|
use League\CommonMark\Node\Block\Paragraph; |
||||||
|
use League\CommonMark\Parser\Inline\InlineParserInterface; |
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\Image; |
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\SoftBreak; |
||||||
|
use League\CommonMark\Parser\Inline\InlineParserMatch; |
||||||
|
use League\CommonMark\Parser\InlineParserContext; |
||||||
|
|
||||||
|
class RawImageLinkParser implements InlineParserInterface |
||||||
|
{ |
||||||
|
public function getMatchDefinition(): InlineParserMatch |
||||||
|
{ |
||||||
|
// Match URLs ending with an image extension |
||||||
|
return InlineParserMatch::regex('https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp)(?=\s|$)'); |
||||||
|
} |
||||||
|
|
||||||
|
public function parse(InlineParserContext $inlineContext): bool |
||||||
|
{ |
||||||
|
$cursor = $inlineContext->getCursor(); |
||||||
|
$match = $inlineContext->getFullMatch(); |
||||||
|
// Create an <img> element instead of a text link |
||||||
|
$image = new Image($match, ''); |
||||||
|
$paragraph = new Paragraph(); |
||||||
|
$paragraph->appendChild($image); |
||||||
|
$inlineContext->getContainer()->appendChild($paragraph); |
||||||
|
|
||||||
|
// Advance the cursor to consume the matched part (important!) |
||||||
|
$cursor->advanceBy(strlen($match)); |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block title %}Credit Transactions{% endblock %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<h1>Credit Transactions</h1> |
||||||
|
|
||||||
|
<table> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>ID</th> |
||||||
|
<th>NPub</th> |
||||||
|
<th>Type</th> |
||||||
|
<th>Amount</th> |
||||||
|
<th>Reason</th> |
||||||
|
<th>Timestamp</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for tx in transactions %} |
||||||
|
<tr> |
||||||
|
<td>{{ tx.id }}</td> |
||||||
|
<td><span title="{{ tx.npub }}">{{ tx.npub|shortenNpub }}</span></td> |
||||||
|
<td>{{ tx.type }}</td> |
||||||
|
<td>{{ tx.amount }}</td> |
||||||
|
<td>{{ tx.reason ?: '—' }}</td> |
||||||
|
<td>{{ tx.createdAt|date('Y-m-d H:i:s') }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No transactions found.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
<div {{ attributes }}> |
||||||
|
<button data-action="live#action" |
||||||
|
data-live-action-param="grantVoucher"> |
||||||
|
Get 5 Free Credits |
||||||
|
</button> |
||||||
|
</div> |
||||||
@ -1,28 +1,27 @@ |
|||||||
{% if article is defined %} |
{% if article is defined %} |
||||||
<{{ tag }} {{ attributes }}> |
<div class="card"> |
||||||
<div class="card-header"> |
<div class="metadata"> |
||||||
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %} |
{% if category %} |
||||||
{% if article.image %} |
<small>{{ category }}</small> |
||||||
<img src="{{ article.image }}" alt=""> |
{% else %} |
||||||
{% endif %} |
<p>by <twig:Molecules:UserFromNpub pubkey="{{ article.pubkey }}" /></p> |
||||||
|
<small>{{ article.createdAt|date('F j Y') }}</small> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
<a href="{{ path('article-slug', {slug: article.slug}) }}"> |
||||||
|
<div class="card-header"> |
||||||
|
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %} |
||||||
|
{% if article.image %} |
||||||
|
<img src="{{ article.image }}" alt="Cover image for {{ article.title }}" onerror="this.style.display='none';" > |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<h2 class="card-title">{{ article.title }}</h2> |
||||||
|
<p class="lede"> |
||||||
|
{{ article.summary }} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
<div class="card-footer"></div> |
||||||
</div> |
</div> |
||||||
<div class="card-body"> |
|
||||||
{# <small>{{ article.createdAt|date('F j') }}</small>#} |
|
||||||
<h2 class="card-title">{{ article.title }}</h2> |
|
||||||
<p class="lede"> |
|
||||||
{{ article.summary }} |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
</{{ tag }}> |
|
||||||
{#<div class="card card-footer">#} |
|
||||||
{# <a href="{{ path('author-profile', { npub: article.pubkey })}}"><twig:Molecules:UserFromNpub pubkey="{{ article.pubkey }}" /></a>#} |
|
||||||
{#</div>#} |
|
||||||
{% endif %} |
|
||||||
{% if user is defined %} |
|
||||||
<{{ tag }} {{ attributes }}> |
|
||||||
<div class="card-body"> |
|
||||||
<h3 class="card-title">{{ user.name }}</h3> |
|
||||||
<p>{{ user.about }}</p> |
|
||||||
</div> |
|
||||||
</{{ tag }}> |
|
||||||
{% endif %} |
{% endif %} |
||||||
|
|||||||
@ -0,0 +1,4 @@ |
|||||||
|
<a {% if path('magazine-category', { 'slug' : slug }) in app.request.uri %}class="active"{% endif %} |
||||||
|
href="{{ path('magazine-category', { 'slug' : slug }) }}"> |
||||||
|
{{ title }} |
||||||
|
</a> |
||||||
@ -1,5 +1,5 @@ |
|||||||
{% if user %} |
{% if user %} |
||||||
<twig:Atoms:NameOrNpub :author="user" /> |
<a href="{{ path('author-profile', { npub: npub })}}"><twig:Atoms:NameOrNpub :author="user" /></a> |
||||||
{% else %} |
{% else %} |
||||||
<span>{{ npub }}</span> |
<a href="{{ path('author-profile', { npub: npub })}}"><span>{{ npub|shortenNpub }}</span></a> |
||||||
{% endif %} |
{% endif %} |
||||||
|
|||||||
@ -1,7 +1,7 @@ |
|||||||
<div {{ attributes }}> |
<div {{ attributes }}> |
||||||
{% for item in list %} |
{% for item in list %} |
||||||
{% if item.slug is not empty %} |
{% if item.slug is not empty %} |
||||||
<twig:Molecules:Card class="card" :article="item" tag="a" href="{{ path('article-slug', {slug: item.slug}) }}" ></twig:Molecules:Card> |
<twig:Molecules:Card :article="item"></twig:Molecules:Card> |
||||||
{% endif %} |
{% endif %} |
||||||
{% endfor %} |
{% endfor %} |
||||||
</div> |
</div> |
||||||
|
|||||||
@ -0,0 +1,7 @@ |
|||||||
|
<div class="comments"> |
||||||
|
{% for item in list %} |
||||||
|
<div class="comment"> |
||||||
|
<span>{{ item.content }}</span> |
||||||
|
</div> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
<div> |
||||||
|
{% if list %} |
||||||
|
<div class="featured-cat hidden"> |
||||||
|
<small><b>{{ title }}</b></small> |
||||||
|
</div> |
||||||
|
<div {{ attributes }}> |
||||||
|
<div> |
||||||
|
{% set feature = list[0] %} |
||||||
|
<div class="card"> |
||||||
|
<a href="{{ path('article-slug', {slug: feature.slug}) }}"> |
||||||
|
<div class="card-header"> |
||||||
|
{% if feature.image %} |
||||||
|
<img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}"> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<h2 class="card-title">{{ feature.title }}</h2> |
||||||
|
<p class="lede truncate"> |
||||||
|
{{ feature.summary }} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
{% for item in list %} |
||||||
|
{% if item != feature %} |
||||||
|
<div class="card"> |
||||||
|
<a href="{{ path('article-slug', {slug: item.slug}) }}"> |
||||||
|
<div class="card-body"> |
||||||
|
<h2 class="card-title">{{ item.title }}</h2> |
||||||
|
<p class="lede truncate"> |
||||||
|
{{ item.summary }} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
@ -1,28 +1,40 @@ |
|||||||
<div {{ attributes }}> |
<div {{ attributes }}> |
||||||
{% if interactive %} |
{% if interactive %} |
||||||
<label class="search"> |
<form data-live-action-param="search" |
||||||
<input type="search" |
data-action="live#action:prevent"> |
||||||
placeholder="{{ 'text.search'|trans }}" |
<label class="search"> |
||||||
data-model="norender|query" |
<input type="search" |
||||||
/> |
placeholder="{{ 'text.search'|trans }}" |
||||||
<button type="submit" data-action="live#$render"><twig:ux:icon name="iconoir:search" class="icon" /></button> |
data-model="norender|query" |
||||||
</label> |
/> |
||||||
<!-- |
<button type="submit"><twig:ux:icon name="iconoir:search" class="icon" /></button> |
||||||
<div style="text-align: right"> |
</label> |
||||||
<small class="help-text"><em>Powered by Silk</em></small> |
<div style="text-align: right"> |
||||||
</div> --> |
<small class="help-text"> |
||||||
|
<em>{{ 'credit.balance'|trans({'%count%': credits, 'count': credits}) }}</em> |
||||||
|
</small> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
<!-- Loading Indicator --> |
<!-- Loading Indicator --> |
||||||
<div style="text-align: center"> |
<div style="text-align: center"> |
||||||
<span data-loading>{{ 'text.searching'|trans }}</span> |
<div class="spinner" data-loading> |
||||||
</div> |
<div class="lds-dual-ring"></div> |
||||||
{% endif %} |
</div> |
||||||
|
<span data-loading>{{ 'text.searching'|trans }}</span> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
<!-- Results --> |
<!-- Results --> |
||||||
{% if this.results is not empty %} |
{% if this.results is not empty %} |
||||||
<twig:Organisms:CardList :list="this.results" class="article-list" /> |
<twig:Organisms:CardList :list="this.results" class="article-list" /> |
||||||
{% elseif this.query is not empty %} |
{% elseif this.query is not empty %} |
||||||
<p><small>{{ 'text.noResults'|trans }}</small></p> |
<p><small>{{ 'text.noResults'|trans }}</small></p> |
||||||
{% endif %} |
{% endif %} |
||||||
</div> |
|
||||||
|
|
||||||
|
{% block aside %} |
||||||
|
{% if credits == 0 %} |
||||||
|
<twig:GetCreditsComponent /> |
||||||
|
{% endif %} |
||||||
|
{% endblock %} |
||||||
|
</div> |
||||||
|
|||||||
Loading…
Reference in new issue