diff --git a/.docker/spa/Dockerfile b/.docker/spa/Dockerfile index 28aa424..be270c4 100644 --- a/.docker/spa/Dockerfile +++ b/.docker/spa/Dockerfile @@ -4,7 +4,7 @@ FROM alpine/git as git ARG REPOSITORY=https://github.com/buggregator/buggregator.dev RUN git clone $REPOSITORY /app -FROM node:21-alpine +FROM node:22-alpine ARG APP_VERSION=1.0.0 ENV APP_VERSION=$APP_VERSION diff --git a/app/app/src/Application/Bootloader/EventsBootloader.php b/app/app/src/Application/Bootloader/EventsBootloader.php new file mode 100644 index 0000000..9025fcf --- /dev/null +++ b/app/app/src/Application/Bootloader/EventsBootloader.php @@ -0,0 +1,21 @@ + static fn(EnvironmentInterface $env) => new LikeListener( + randomFactor: (int)$env->get('RANDOM_FACTOR', 20), + ), + ]; + } +} diff --git a/app/app/src/Application/Bootloader/GithubBootloader.php b/app/app/src/Application/Bootloader/GithubBootloader.php index 9e84e9c..7b83e7d 100644 --- a/app/app/src/Application/Bootloader/GithubBootloader.php +++ b/app/app/src/Application/Bootloader/GithubBootloader.php @@ -8,6 +8,7 @@ use App\Github\Client; use App\Github\ClientInterface; use App\Github\WebhookGate; +use Psr\Log\LoggerInterface; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\EnvironmentInterface; use Spiral\Cache\CacheStorageProviderInterface; @@ -19,6 +20,7 @@ public function defineSingletons(): array return [ ClientInterface::class => static fn( CacheStorageProviderInterface $cache, + LoggerInterface $logger, EnvironmentInterface $env, ) => new CacheableClient( client: new Client( @@ -29,6 +31,7 @@ public function defineSingletons(): array 'Authorization' => 'Bearer ' . $env->get('GITHUB_TOKEN'), ], ]), + logger: $logger, ), cache: $cache->storage('github'), ttl: 300, diff --git a/app/app/src/Application/Event/Liked.php b/app/app/src/Application/Event/Liked.php new file mode 100644 index 0000000..b05f88d --- /dev/null +++ b/app/app/src/Application/Event/Liked.php @@ -0,0 +1,34 @@ + $this->key, + ]; + } +} diff --git a/app/app/src/Application/Kernel.php b/app/app/src/Application/Kernel.php index b81a285..a4e4789 100644 --- a/app/app/src/Application/Kernel.php +++ b/app/app/src/Application/Kernel.php @@ -4,6 +4,7 @@ namespace App\Application; +use App\Application\Bootloader\EventsBootloader; use App\Application\Bootloader\GithubBootloader; use Spiral\Boot\Bootloader\CoreBootloader; use Spiral\DotEnv\Bootloader\DotenvBootloader; @@ -38,6 +39,7 @@ public function defineBootloaders(): array PrototypeBootloader::class, GithubBootloader::class, + EventsBootloader::class, ]; } } diff --git a/app/app/src/Endpoint/Centrifugo/RPCService.php b/app/app/src/Endpoint/Centrifugo/RPCService.php index 1e25aac..38795dd 100644 --- a/app/app/src/Endpoint/Centrifugo/RPCService.php +++ b/app/app/src/Endpoint/Centrifugo/RPCService.php @@ -57,7 +57,8 @@ public function createHttpRequest(Request\RPC $request): ServerRequestInterface [$method, $uri] = \explode(':', $request->method, 2); $method = \strtoupper($method); - $httpRequest = $this->requestFactory->createServerRequest(\strtoupper($method), \ltrim($uri, '/')) + $httpRequest = $this->requestFactory + ->createServerRequest(\strtoupper($method), \ltrim($uri, '/')) ->withHeader('Content-Type', 'application/json'); $data = $request->getData(); diff --git a/app/app/src/Endpoint/Event/LikeListener.php b/app/app/src/Endpoint/Event/LikeListener.php new file mode 100644 index 0000000..93fb3f3 --- /dev/null +++ b/app/app/src/Endpoint/Event/LikeListener.php @@ -0,0 +1,143 @@ +randomFactor) === 1; + if (!$shouldPrint) { + return; + } + + $phrase = $phrases[\array_rand($phrases)]; + dump(\sprintf($phrase, $event->key)); + } +} diff --git a/app/app/src/Endpoint/Http/Controller/LikeAction.php b/app/app/src/Endpoint/Http/Controller/LikeAction.php new file mode 100644 index 0000000..a7905cc --- /dev/null +++ b/app/app/src/Endpoint/Http/Controller/LikeAction.php @@ -0,0 +1,32 @@ +events->dispatch(new Liked(key: $request->key)); + + return $this->response->create(200); + } +} diff --git a/app/app/src/Endpoint/Http/Controller/SettingsAction.php b/app/app/src/Endpoint/Http/Controller/SettingsAction.php index d8dada5..ee394b8 100644 --- a/app/app/src/Endpoint/Http/Controller/SettingsAction.php +++ b/app/app/src/Endpoint/Http/Controller/SettingsAction.php @@ -18,11 +18,15 @@ public function __invoke(ClientInterface $client): ResourceInterface 'github' => [ 'server' => [ 'stars' => $client->getStars('buggregator/server'), - 'last_version' => $client->getLastVersion('buggregator/server'), + 'latest_release' => $client->getLatestRelease('buggregator/server'), ], 'trap' => [ 'stars' => $client->getStars('buggregator/trap'), - 'last_version' => $client->getLastVersion('buggregator/trap'), + 'latest_release' => $client->getLatestRelease('buggregator/trap'), + ], + 'phpstorm-plugin' => [ + 'stars' => $client->getStars('buggregator/phpstorm-plugin'), + 'latest_release' => $client->getLatestRelease('buggregator/phpstorm-plugin'), ], ], ]); diff --git a/app/app/src/Endpoint/Http/Controller/TeamAction.php b/app/app/src/Endpoint/Http/Controller/TeamAction.php index 3dd387e..88f7d52 100644 --- a/app/app/src/Endpoint/Http/Controller/TeamAction.php +++ b/app/app/src/Endpoint/Http/Controller/TeamAction.php @@ -33,6 +33,12 @@ public function __invoke(): ResourceInterface 'avatar' => 'https://avatars.githubusercontent.com/u/13301570?v=4', 'github' => 'https://github.com/Kreezag', ], + [ + 'name' => 'Artūrs Terehovičs', + 'role' => 'PHP developer', + 'avatar' => 'https://avatars.githubusercontent.com/u/94047334?v=4', + 'github' => 'https://github.com/lotyp', + ], ], TeamResource::class); - } +} } diff --git a/app/app/src/Endpoint/Http/Filter/LikeRequest.php b/app/app/src/Endpoint/Http/Filter/LikeRequest.php new file mode 100644 index 0000000..a76ed8e --- /dev/null +++ b/app/app/src/Endpoint/Http/Filter/LikeRequest.php @@ -0,0 +1,24 @@ + 'required|string', + ]); + } +} diff --git a/app/app/src/Github/CacheableClient.php b/app/app/src/Github/CacheableClient.php index a3f79c6..9d61b91 100644 --- a/app/app/src/Github/CacheableClient.php +++ b/app/app/src/Github/CacheableClient.php @@ -4,6 +4,7 @@ namespace App\Github; +use App\Github\Entity\Release; use Psr\SimpleCache\CacheInterface; final readonly class CacheableClient implements ClientInterface @@ -12,8 +13,7 @@ public function __construct( private ClientInterface $client, private CacheInterface $cache, private int $ttl = 300, - ) { - } + ) {} public function getStars(string $repository): int { @@ -29,18 +29,18 @@ public function getStars(string $repository): int return $stars; } - public function getLastVersion(string $repository): string + public function getLatestRelease(string $repository): Release { $cacheKey = $this->getCacheKey($repository, __METHOD__); if ($this->cache->has($cacheKey)) { return $this->cache->get($cacheKey); } - $version = $this->client->getLastVersion($repository); + $release = $this->client->getLatestRelease($repository); - $this->cache->set($cacheKey, $version, $this->ttl); + $this->cache->set($cacheKey, $release, $this->ttl); - return $version; + return $release; } public function getIssuesForContributors(): array diff --git a/app/app/src/Github/Client.php b/app/app/src/Github/Client.php index e3cfe9b..e74ec53 100644 --- a/app/app/src/Github/Client.php +++ b/app/app/src/Github/Client.php @@ -5,15 +5,17 @@ namespace App\Github; use App\Github\Entity\Issue; +use App\Github\Entity\Release; use Carbon\Carbon; use GuzzleHttp\Psr7\Request; +use Psr\Log\LoggerInterface; final readonly class Client implements ClientInterface { public function __construct( private \Psr\Http\Client\ClientInterface $client, - ) { - } + private LoggerInterface $logger, + ) {} public function getStars(string $repository): int { @@ -26,10 +28,10 @@ public function getStars(string $repository): int $data = \json_decode($response->getBody()->getContents(), true); - return $data['stargazers_count']; + return $data['stargazers_count'] ?? 0; } - public function getLastVersion(string $repository): string + public function getLatestRelease(string $repository): Release { $response = $this->client->sendRequest( new Request( @@ -40,7 +42,11 @@ public function getLastVersion(string $repository): string $data = \json_decode($response->getBody()->getContents(), true); - return $data['tag_name']; + return new Release( + repository: $repository, + version: $data['tag_name'], + createdAt: Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data['created_at']), + ); } public function getIssuesForContributors(): array @@ -50,6 +56,7 @@ public function getIssuesForContributors(): array 'buggregator/trap', 'buggregator/frontend', 'buggregator/docs', + 'buggregator/phpstorm-plugin', ]; $issues = []; @@ -70,6 +77,16 @@ private function fetchRepositoryIssues(string $repository): array ), ); + if ($response->getStatusCode() !== 200) { + $this->logger->error('Failed to fetch issues', [ + 'repository' => $repository, + 'status' => $response->getStatusCode(), + 'body' => $response->getBody()->getContents(), + ]); + + return []; + } + $data = \json_decode($response->getBody()->getContents(), true); return \array_map( diff --git a/app/app/src/Github/ClientInterface.php b/app/app/src/Github/ClientInterface.php index eefa210..6e009a5 100644 --- a/app/app/src/Github/ClientInterface.php +++ b/app/app/src/Github/ClientInterface.php @@ -5,12 +5,13 @@ namespace App\Github; use App\Github\Entity\Issue; +use App\Github\Entity\Release; interface ClientInterface { public function getStars(string $repository): int; - public function getLastVersion(string $repository): string; + public function getLatestRelease(string $repository): Release; /** * @return Issue[] diff --git a/app/app/src/Github/Entity/Release.php b/app/app/src/Github/Entity/Release.php new file mode 100644 index 0000000..dbe3f91 --- /dev/null +++ b/app/app/src/Github/Entity/Release.php @@ -0,0 +1,23 @@ + $this->repository, + 'version' => $this->version, + 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), + ]; + } +} diff --git a/spa/app/api/Api.ts b/spa/app/api/Api.ts index fdb9584..f31a73e 100644 --- a/spa/app/api/Api.ts +++ b/spa/app/api/Api.ts @@ -6,6 +6,10 @@ type SettingsApi = { get: () => SettingsResponse, } +type DataApi = { + like: () => void, +} + type ExamplesApi = { call: (action: string) => void, } @@ -29,6 +33,12 @@ export default class Api { this._examples_url = examples_url; } + get data(): DataApi { + return { + like: apiMethods.like(this.rpc), + } + } + get settings(): SettingsApi { return { get: apiMethods.settings(this.rpc), diff --git a/spa/app/api/methods.ts b/spa/app/api/methods.ts index 404195a..2b211cc 100644 --- a/spa/app/api/methods.ts +++ b/spa/app/api/methods.ts @@ -14,6 +14,8 @@ const team = (rpc: RPCClient) => () => rpc.call('get:api/team') const issuesForContributors = (rpc: RPCClient) => () => rpc.call('get:api/issues/for-contributors') .then((response: ServerResponse) => response.data.data); +const like = (rpc: RPCClient) => (key: string) => rpc.call('post:api/like', {key}); + const callExampleAction = (host: string) => (action: string) => { action = action.toLowerCase(); @@ -32,5 +34,6 @@ export default { settings, team, callExampleAction, - issuesForContributors + issuesForContributors, + like } diff --git a/spa/app/entity/GithubRepo.ts b/spa/app/entity/GithubRepo.ts new file mode 100644 index 0000000..0d1fe74 --- /dev/null +++ b/spa/app/entity/GithubRepo.ts @@ -0,0 +1,34 @@ +import moment from 'moment'; + +export class GithubRepo { + constructor( + public name: string, + public stars: number, + private latest_release: { + repository: string, + version: string, + createdAt: string + } + ) { + } + + get url(): string { + return `https://github.com/${this.latest_release.repository}` + } + + get version(): string { + return this.latest_release.version + } + + get starsFormatted(): string { + return this.stars.toLocaleString() + } + + get createdAt(): string { + return moment(this.latest_release.createdAt).fromNow() + } + + get isNew(): boolean { + return moment(this.latest_release.createdAt).isAfter(moment().subtract(1, 'week')) + } +} \ No newline at end of file diff --git a/spa/app/plugins/centrifugo.ts b/spa/app/plugins/centrifugo.ts index f451011..c9cce19 100644 --- a/spa/app/plugins/centrifugo.ts +++ b/spa/app/plugins/centrifugo.ts @@ -12,15 +12,16 @@ const guessWsConnection = (): string => { export default defineNuxtPlugin(async (nuxtApp) => { const config = useRuntimeConfig() const ws_url: string = (config.public.ws_url) || guessWsConnection() + const store = useAppStore(); const client: WsClient = new WsClient( new Centrifuge(ws_url), nuxtApp.$logger ) - await client.connect() + await store.init(client) - nuxtApp.hook('app:created', () => { + nuxtApp.hook('app:created', (): void => { const settings = useAppStore() settings.fetch() diff --git a/spa/assets/css/main.css b/spa/assets/css/main.css index 1f00fc3..0753ce4 100644 --- a/spa/assets/css/main.css +++ b/spa/assets/css/main.css @@ -6,6 +6,10 @@ body { @apply bg-gray-100 w-full; } +.modal-container{ + @apply backdrop-blur-md; +} + .section-title { @apply text-3xl lg:text-5xl leading-none font-semibold text-blue-800 tracking-tight mb-12; } @@ -19,7 +23,7 @@ a.text-link { } .read-more-link { - @apply border font-bold px-5 py-2 rounded-full; + @apply border font-bold px-5 py-2 rounded-full text-sm; &.gray { @apply bg-gray-100 hover:bg-gray-200 text-gray-800; @@ -37,13 +41,17 @@ a.text-link { @apply bg-blue-100 hover:bg-blue-200 text-blue-800; } + &.teal { + @apply bg-blue-100 hover:bg-teal-200 text-teal-800; + } + &.small { - @apply text-sm px-3 py-1; + @apply text-xs px-3 py-1; } } .feature { - @apply border p-8 rounded-lg justify-between hover:shadow-xl transition; + @apply border p-8 rounded-lg justify-between hover:shadow-xl transition bg-cover; } .feature-grid { @@ -55,5 +63,5 @@ a.text-link { } .feature-text { - @apply text-gray-400 font-semibold; + @apply text-gray-400 font-semibold pb-2; } \ No newline at end of file diff --git a/spa/assets/img/bg.jpg b/spa/assets/img/bg.jpg index feb54bf..7ee8492 100644 Binary files a/spa/assets/img/bg.jpg and b/spa/assets/img/bg.jpg differ diff --git a/spa/assets/img/drupal.svg b/spa/assets/img/drupal.svg new file mode 100644 index 0000000..ce29885 --- /dev/null +++ b/spa/assets/img/drupal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/spa/assets/img/laravel.svg b/spa/assets/img/laravel.svg new file mode 100644 index 0000000..9f5495b --- /dev/null +++ b/spa/assets/img/laravel.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/spa/assets/img/spiral.svg b/spa/assets/img/spiral.svg new file mode 100644 index 0000000..274a174 --- /dev/null +++ b/spa/assets/img/spiral.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spa/assets/img/symfony.svg b/spa/assets/img/symfony.svg new file mode 100644 index 0000000..249b6cc --- /dev/null +++ b/spa/assets/img/symfony.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/spa/assets/img/wordpress.svg b/spa/assets/img/wordpress.svg new file mode 100644 index 0000000..b251ecb --- /dev/null +++ b/spa/assets/img/wordpress.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/spa/assets/img/yii3.svg b/spa/assets/img/yii3.svg new file mode 100644 index 0000000..7153f27 --- /dev/null +++ b/spa/assets/img/yii3.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spa/components/Common/Likeable.vue b/spa/components/Common/Likeable.vue new file mode 100644 index 0000000..9edd3ac --- /dev/null +++ b/spa/components/Common/Likeable.vue @@ -0,0 +1,120 @@ + + + + + \ No newline at end of file diff --git a/spa/components/v1/Contribution.vue b/spa/components/v1/Contribution.vue index f4f607b..d57f3f0 100644 --- a/spa/components/v1/Contribution.vue +++ b/spa/components/v1/Contribution.vue @@ -2,6 +2,8 @@ import { useIssuesStore } from "~/stores/issues"; import GridRow from "~/components/v1/GridRow.vue"; +const { gtag } = useGtag() + const store = useIssuesStore(); store.fetch(); @@ -10,12 +12,16 @@ const issues = computed(() => { }); const redirectTo = (url: string) => { + gtag('event', 'open_issue', { + label: 'open_issue', + url, + }); window.open(url, "_blank"); }; diff --git a/spa/components/v1/Team.vue b/spa/components/v1/Team.vue index 77c5e08..a3e120a 100644 --- a/spa/components/v1/Team.vue +++ b/spa/components/v1/Team.vue @@ -8,27 +8,6 @@ await store.fetch(); const users = computed(() => { return store.team; }); - -// const users = [ -// { -// name: 'Pavel Buchnev', -// role: 'Creator of Buggregator', -// avatar: 'https://avatars.githubusercontent.com/u/773481?v=4', -// github: 'https://github.com/butschster' -// }, -// { -// name: 'Aleksei Gagarin', -// role: 'PHP developer', -// avatar: 'https://avatars.githubusercontent.com/u/4152481?v=4', -// github: 'https://github.com/roxblnfk' -// }, -// { -// name: 'Andrey Kuchuk', -// role: 'Frontend developer', -// avatar: 'https://avatars.githubusercontent.com/u/13301570?v=4', -// github: 'https://github.com/Kreezag' -// }, -// ]