Версия 0.1.0 #1

Merged
kor-elf merged 20 commits from develop into main 2024-04-24 21:56:47 +05:00
42 changed files with 1126 additions and 6 deletions
Showing only changes of commit 24e0cf0eea - Show all commits

View File

@ -2,9 +2,16 @@
namespace App\Dto\Builder; namespace App\Dto\Builder;
use App\Models\User;
final readonly class Project final readonly class Project
{ {
public function __construct( public function __construct(
private ?bool $isPublic = null,
) { } ) { }
public function isPublic(): ?bool
{
return $this->isPublic;
}
} }

View File

@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace App\Enums;
use Illuminate\Cache\TaggedCache;
use Illuminate\Support\Facades\Cache;
enum CacheTag: string
{
case Project = 'project';
public function getCache(): TaggedCache
{
return Cache::tags($this->value);
}
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace App\Enums\Site;
use App\Models\Project;
use App\Models\ProjectLanguage;
enum ProjectSection
{
case Home;
public function url(Project $project, ?ProjectLanguage $language = null): string
{
$parameters = [];
$prefixProject = '';
if ($project->http_host === null) {
$prefixProject = 'project.';
$parameters['project'] = $project->code;
}
$prefixLanguage = '';
if ($language?->is_default === false) {
$parameters['language'] = $language->code;
$prefixLanguage = '-language';
}
return match ($this) {
self::Home => \route($prefixProject . 'home' . $prefixLanguage, $parameters),
};
}
}

View File

@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use App\Http\Controllers\Controller as BaseController;
use app\ServiceResults\Site\PagePossibleWithoutTranslation;
use Illuminate\View\View;
abstract class Controller extends BaseController
{
protected function viewPageWithoutTranslation(PagePossibleWithoutTranslation $result): View
{
return \view('site.page-without-translation', [
'project' => $result->getProject(),
'language' => $result->getLanguage(),
]);
}
}

View File

@ -0,0 +1,62 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use App\Enums\Site\ProjectSection;
use App\Services\Site\ProjectService;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class ProjectsController extends Controller
{
public function __construct(
private readonly ProjectService $projectService,
) { }
public function index(Request $request, ?string $language = null): View
{
$user = $request->user();
if (\is_null($request->project)) {
if (!\is_null($language)) {
abort(404);
}
$with = ['storage'];
$result = $this->projectService->getProjects($user, $with);
if ($result->isError()) {
$this->errors($result);
}
return \view('site.projects.index', $result->getData());
}
$result = $this->projectService->getAboutByProject($request->project, $language, $request->user());
if ($result->isError()) {
$this->errors($result);
}
if ($result->isTranslation()) {
return $this->viewPageWithoutTranslation($result);
}
return \view('site.projects.about', $result->getData());
}
public function about(string $project, Request $request, ?string $language = null)
{
$result = $this->projectService->getAbout($project, $language, $request->user());
if ($result->isError()) {
$this->errors($result);
}
if ($result->isTranslation()) {
return $this->viewPageWithoutTranslation($result);
}
if ($result->getProject()->http_href !== null) {
$link = ProjectSection::Home->url($result->getProject(), $result->getLanguage());
return \redirect($result->getProject()->http_href . $link, 302);
}
return \view('site.projects.about', $result->getData());
}
}

View File

@ -6,7 +6,7 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class AdminPanel final class AdminPanel
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.

View File

@ -0,0 +1,50 @@
<?php declare(strict_types=1);
namespace App\Http\Middleware;
use App\Enums\CacheTag;
use App\Repositories\ProjectRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use Closure;
class Project
{
public function __construct(
private readonly ProjectRepository $projectRepository,
) { }
public function handle(Request $request, Closure $next): Response
{
$projectCode = $request->route()?->parameter('project');
if ($projectCode === null) {
abort(404);
}
$seconds = 3600;
$project = CacheTag::Project->getCache()->remember(self::class . $projectCode, $seconds, function () use ($projectCode) {
return $this->projectRepository->getProjectByCode($projectCode) ?? false;
});
if ($project === false) {
abort(404);
}
unset($request->route()->parameters['project']);
$routeName = $request->route()->getName();
if (
$routeName !== null
&& $project->http_host !== null
&& $project->http_host !== $request->getSchemeAndHttpHost()
) {
$routeName = Str::of($routeName)->replaceStart('project.', '')->toString();
$route = \route($routeName, $request->route()->parameters(), false);
return redirect($project->http_host . $route, 302);
}
$request->merge(['project' => $project]);
return $next($request);
}
}

View File

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
namespace App\Http\Middleware;
use App\Enums\CacheTag;
use App\Repositories\ProjectRepository;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class ProjectDomain
{
public function __construct(
private readonly ProjectRepository $projectRepository,
) { }
public function handle(Request $request, Closure $next): Response
{
$httpHost = $request->getSchemeAndHttpHost();
$seconds = 3600;
$project = CacheTag::Project->getCache()->remember(self::class . $httpHost, $seconds, function () use ($httpHost) {
return $this->projectRepository->getProjectByHttpHost($httpHost) ?? false;
});
if ($project === false) {
$project = null;
}
$request->merge(['project' => $project]);
return $next($request);
}
}

View File

@ -18,10 +18,12 @@ class StoreUpdateRequest extends FormRequest implements FormRequestDto
*/ */
public function rules(): array public function rules(): array
{ {
$projectId = $this->project ?? null;
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'code' => ['required', 'string', 'min:3', 'max:255', 'regex:/^[a-z0-9_-]+$/i'], 'code' => ['required', 'string', 'min:3', 'max:255', 'regex:/^[a-z0-9_-]+$/'],
'http_host' => ['nullable', 'string', 'max:255', new HttpHost()], 'http_host' => ['nullable', 'string', 'max:255', new HttpHost(), 'unique:projects,http_host,' . $projectId],
'is_public' => ['required', 'boolean'], 'is_public' => ['required', 'boolean'],
'logo.file' => ['nullable', 'numeric', 'min:1'], 'logo.file' => ['nullable', 'numeric', 'min:1'],
@ -29,7 +31,7 @@ public function rules(): array
'languages.items.*.id' => ['nullable', 'numeric'], 'languages.items.*.id' => ['nullable', 'numeric'],
'languages.items.*.title' => ['required', 'string', 'max:255'], 'languages.items.*.title' => ['required', 'string', 'max:255'],
'languages.items.*.code' => ['required', 'string', 'min:2', 'max:30', 'regex:/^[a-zA-Z_]+$/i'], 'languages.items.*.code' => ['required', 'string', 'min:2', 'max:30', 'regex:/^[a-z_]+$/'],
'languages.items.*.sort' => ['required', 'numeric', 'min:-1000', 'max:1000'], 'languages.items.*.sort' => ['required', 'numeric', 'min:-1000', 'max:1000'],
'languages.default' => ['required', 'numeric', function (string $attribute, mixed $value, \Closure $fail) { 'languages.default' => ['required', 'numeric', function (string $attribute, mixed $value, \Closure $fail) {
$languages = $this->input('languages.items', []); $languages = $this->input('languages.items', []);

View File

@ -12,6 +12,7 @@
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -56,6 +57,9 @@ public function boot(): void
Relation::enforceMorphMap(Morph::map()); Relation::enforceMorphMap(Morph::map());
Route::pattern('language', '[a-z_]+');
Route::pattern('project', '[a-z0-9_-]+');
$this->configureRateLimiting(); $this->configureRateLimiting();
Gate::define('AdminPanel', [\App\Policies\AdminPanel::class, 'view']); Gate::define('AdminPanel', [\App\Policies\AdminPanel::class, 'view']);
} }

View File

@ -2,10 +2,20 @@
namespace App\Repositories; namespace App\Repositories;
use App\Models\Project;
use App\Models\ProjectLanguage; use App\Models\ProjectLanguage;
final class ProjectLanguageRepository final class ProjectLanguageRepository
{ {
public function getProjectLanguageByCodeOrDefault(Project $project, ?string $code): ?ProjectLanguage
{
if (\is_null($code)) {
return $project->languages()->where('is_default', true)->first();
}
return $project->languages()->where('code', $code)->first();
}
public function isExistsLanguageById(int $projectId, int $languageId): bool public function isExistsLanguageById(int $projectId, int $languageId): bool
{ {
return ProjectLanguage::query() return ProjectLanguage::query()

View File

@ -3,10 +3,13 @@
namespace App\Repositories; namespace App\Repositories;
use App\Contracts\Search; use App\Contracts\Search;
use App\Models\Project;
use App\Services\ProjectLink\BuilderCommand; use App\Services\ProjectLink\BuilderCommand;
use App\Services\Search\CreateSearchInstanceCommand; use App\Services\Search\CreateSearchInstanceCommand;
use App\Dto\Builder\ProjectLink as ProjectLinkBuilderDto; use App\Dto\Builder\ProjectLink as ProjectLinkBuilderDto;
use App\Models\ProjectLink; use App\Models\ProjectLink;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Builder;
final readonly class ProjectLinkRepository final readonly class ProjectLinkRepository
{ {
@ -29,4 +32,11 @@ public function getLinkById(int $id): ?ProjectLink
{ {
return ProjectLink::query()->where('id', $id)->first(); return ProjectLink::query()->where('id', $id)->first();
} }
public function getLinksByProject(Project $project, int $languageId): Collection
{
return $project->links()->where(function (Builder $query) use ($languageId) {
$query->whereNull('language_id')->orWhere('language_id', $languageId);
})->get();
}
} }

View File

@ -26,6 +26,11 @@ public function getProjects(ProjectBuilderDto $projectBuilderDto, array $with =
return $this->createSearchInstanceCommand->execute($query); return $this->createSearchInstanceCommand->execute($query);
} }
public function getProjectByHttpHost(string $httpHost): ?Project
{
return Project::query()->where('http_host', $httpHost)->first();
}
public function getProjectById(int $id): ?Project public function getProjectById(int $id): ?Project
{ {
return Project::query()->where('id', $id)->first(); return Project::query()->where('id', $id)->first();

View File

@ -0,0 +1,37 @@
<?php declare(strict_types=1);
namespace app\ServiceResults\Site;
use App\Models\Project;
use App\Models\ProjectLanguage;
use App\ServiceResults\ServiceResult;
final class PagePossibleWithoutTranslation extends ServiceResult
{
public function __construct(
private readonly Project $project,
private readonly ProjectLanguage $language,
private readonly array $data,
private readonly bool $isTranslation,
) { }
public function isTranslation(): bool
{
return $this->isTranslation;
}
public function getData(): array
{
return $this->data;
}
public function getProject(): Project
{
return $this->project;
}
public function getLanguage(): ProjectLanguage
{
return $this->language;
}
}

View File

@ -5,6 +5,7 @@
use App\Dto\Builder\Project as ProjectBuilderDto; use App\Dto\Builder\Project as ProjectBuilderDto;
use App\Dto\QuerySettingsDto; use App\Dto\QuerySettingsDto;
use App\Dto\Service\Admin\Project\StoreUpdate; use App\Dto\Service\Admin\Project\StoreUpdate;
use App\Enums\CacheTag;
use App\Enums\Morph; use App\Enums\Morph;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectLanguage; use App\Models\ProjectLanguage;
@ -14,6 +15,7 @@
use App\ServiceResults\ServiceResultError; use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess; use App\ServiceResults\ServiceResultSuccess;
use App\ServiceResults\StoreUpdateResult; use App\ServiceResults\StoreUpdateResult;
use App\Services\ClearCacheCommandHandler;
use App\Services\Project\ProjectCommandHandler; use App\Services\Project\ProjectCommandHandler;
use App\Services\ProjectLanguage\ModelSyncCommand; use App\Services\ProjectLanguage\ModelSyncCommand;
use App\Services\Role\CreateAdminRoleForProjectCommand; use App\Services\Role\CreateAdminRoleForProjectCommand;
@ -29,6 +31,7 @@ public function __construct(
private readonly CreateAdminRoleForProjectCommand $createAdminRoleForProjectCommand, private readonly CreateAdminRoleForProjectCommand $createAdminRoleForProjectCommand,
private readonly ModelSyncCommand $languageModelSyncCommand, private readonly ModelSyncCommand $languageModelSyncCommand,
private readonly StorageService $storageService, private readonly StorageService $storageService,
private readonly ClearCacheCommandHandler $clearCacheCommandHandler,
) { } ) { }
public function index(ProjectBuilderDto $projectBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray public function index(ProjectBuilderDto $projectBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray
@ -130,6 +133,7 @@ public function store(StoreUpdate $data, User $user): ServiceResultError | Store
return $project; return $project;
}); });
$this->clearCacheCommandHandler->byTag(CacheTag::Project);
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
return $this->errService(__('Server Error')); return $this->errService(__('Server Error'));
@ -172,6 +176,7 @@ public function update(int $id, StoreUpdate $data, User $user): ServiceResultErr
return $project; return $project;
}); });
$this->clearCacheCommandHandler->byTag(CacheTag::Project);
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
return $this->errService(__('Server Error')); return $this->errService(__('Server Error'));
@ -196,6 +201,7 @@ public function destroy(int $id, User $user): ServiceResultError|ServiceResultSu
DB::transaction(function () use ($project) { DB::transaction(function () use ($project) {
$this->projectCommandHandler->handleDestroy($project); $this->projectCommandHandler->handleDestroy($project);
}); });
$this->clearCacheCommandHandler->byTag(CacheTag::Project);
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
return $this->errService(__('Server Error')); return $this->errService(__('Server Error'));

View File

@ -0,0 +1,13 @@
<?php declare(strict_types=1);
namespace App\Services;
use App\Enums\CacheTag;
final readonly class ClearCacheCommandHandler
{
public function byTag(CacheTag $tag)
{
$tag->getCache()->flush();
}
}

View File

@ -10,6 +10,10 @@
{ {
public function execute(Relation | Builder $query, ProjectBuilderDto $projectBuilderDto): Relation | Builder public function execute(Relation | Builder $query, ProjectBuilderDto $projectBuilderDto): Relation | Builder
{ {
if ($projectBuilderDto->isPublic() !== null) {
$query->where('is_public', $projectBuilderDto->isPublic());
}
return $query; return $query;
} }
} }

View File

@ -2,12 +2,16 @@
namespace App\Services; namespace App\Services;
use App\Models\Project;
use App\Models\ProjectLanguage;
use App\ServiceResults\ServiceResultArray; use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError; use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess; use App\ServiceResults\ServiceResultSuccess;
use App\ServiceResults\Site\PagePossibleWithoutTranslation;
use App\ServiceResults\StoreUpdateResult; use App\ServiceResults\StoreUpdateResult;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
abstract class Service abstract class Service
{ {
@ -56,6 +60,11 @@ final protected function result(array $data = []): ServiceResultArray
return new ServiceResultArray(data: $data); return new ServiceResultArray(data: $data);
} }
final protected function resultSitePage(Project $project, ProjectLanguage $language, array $data, bool $isTranslation): PagePossibleWithoutTranslation
{
return new PagePossibleWithoutTranslation($project, $language, $data, $isTranslation);
}
final protected function error(int $code, string $message, array $errors = []): ServiceResultError final protected function error(int $code, string $message, array $errors = []): ServiceResultError
{ {
return new ServiceResultError( return new ServiceResultError(

View File

@ -0,0 +1,73 @@
<?php declare(strict_types=1);
namespace App\Services\Site;
use App\Models\Project;
use App\Models\User;
use App\Dto\Builder\Project as ProjectBuilderDto;
use App\Repositories\ProjectContentRepository;
use App\Repositories\ProjectLanguageRepository;
use App\Repositories\ProjectLinkRepository;
use App\Repositories\ProjectRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\Site\PagePossibleWithoutTranslation;
use App\Services\Service;
final class ProjectService extends Service
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly ProjectLanguageRepository $projectLanguageRepository,
private readonly ProjectContentRepository $projectContentRepository,
private readonly ProjectLinkRepository $projectLinkRepository,
) { }
public function getProjects(?User $user, array $with): ServiceResultError | ServiceResultArray
{
$isPublic = null;
if (\is_null($user) || $user->cannot('viewAny', Project::class)) {
$isPublic = true;
}
$projectBuilderDto = new ProjectBuilderDto(
isPublic: $isPublic,
);
$projects = $this->projectRepository->getProjects($projectBuilderDto, $with);
return $this->result([
'projects' => $projects->all(),
]);
}
public function getAbout(string $projectCode, ?string $languageCode, ?User $user = null): ServiceResultError | PagePossibleWithoutTranslation
{
$project = $this->projectRepository->getProjectByCode($projectCode);
if (\is_null($project)) {
return $this->errNotFound('Project not found');
}
return $this->getAboutByProject($project, $languageCode, $user);
}
public function getAboutByProject(Project $project, ?string $languageCode, ?User $user = null): ServiceResultError | PagePossibleWithoutTranslation
{
if (
$project->is_public === false
&& ( $user === null || $user->cannot('view', $project) )
) {
return $this->errFobidden(__('Access is denied'));
}
$language = $this->projectLanguageRepository->getProjectLanguageByCodeOrDefault($project, $languageCode);
if (!$language) {
return $this->errNotFound(__('Language not found'));
}
$data = [
'project' => $project,
'language' => $language,
'content' => $this->projectContentRepository->getContentByLanguageId($project->id, $language->id),
'links' => $this->projectLinkRepository->getLinksByProject($project, $language->id),
];
return $this->resultSitePage($project, $language, $data, \is_null($data['content']));
}
}

View File

@ -0,0 +1,14 @@
<?php declare(strict_types=1);
namespace app\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
final class HomeLayout extends Component
{
public function render(): View
{
return view('layout.home');
}
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace app\View\Components\Site;
use App\Models\ProjectLanguage;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\View\Component;
use Illuminate\View\View;
final class ChooseLanguage extends Component
{
public function __construct(
private readonly ProjectLanguage $language,
private readonly Collection $languages,
) { }
public function render(): View
{
$link = Str::of( request()->getRequestUri() )->rtrim('/');
if ($link->endsWith('/language/' . $this->language->code)) {
$link = $link->replace('/language/' . $this->language->code, '', false);
}
return view('components.site.choose-language', [
'language' => $this->language,
'languages' => $this->languages,
'link' => (string) $link,
]);
}
}

View File

@ -0,0 +1,26 @@
<?php declare(strict_types=1);
namespace app\View\Components\Site;
use App\Enums\StorageType;
use App\Models\Project;
use App\Models\ProjectLanguage;
use Illuminate\View\Component;
use Illuminate\View\View;
final class Layout extends Component
{
public function __construct(
private readonly Project $project,
private readonly ProjectLanguage $language,
) { }
public function render(): View
{
return view('layout.site', [
'project' => $this->project,
'logo' => $this->project->getStorageOne(StorageType::Logo),
'language' => $this->language,
]);
}
}

View File

@ -3,12 +3,23 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
then: function () {
Route::middleware([\App\Http\Middleware\ProjectDomain::class])->group(base_path('routes/web-project.php'));
Route::middleware([
\App\Http\Middleware\ProjectDomain::class,
\App\Http\Middleware\Project::class,
])
->prefix('project/{project}')
->as('project.')
->group(base_path('routes/web-project.php'));
},
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
// //

View File

@ -254,4 +254,6 @@
"The link was successfully created": "The link was successfully created", "The link was successfully created": "The link was successfully created",
"The link was successfully updated": "The link was successfully updated", "The link was successfully updated": "The link was successfully updated",
"The link has been deleted": "The link has been deleted", "The link has been deleted": "The link has been deleted",
"Language not found": "Language not found",
"Project not found": "Project not found"
} }

View File

@ -0,0 +1,10 @@
<?php
return [
"Menu" => "Menu",
'Projects' => 'Projects',
'Project' => 'Project',
'About project' => 'About the project',
'Page without translation' => 'Page without translation',
'Powered by service' => 'Powered by the My Projects website engine',
'Choose language' => 'Choose language',
];

View File

@ -253,5 +253,7 @@
"in all languages": "на всех языках", "in all languages": "на всех языках",
"The link was successfully created": "Ссылка успешно создана", "The link was successfully created": "Ссылка успешно создана",
"The link was successfully updated": "Ссылка успешно обновлена", "The link was successfully updated": "Ссылка успешно обновлена",
"The link has been deleted": "Ссылка удалена" "The link has been deleted": "Ссылка удалена",
"Language not found": "Язык не найден",
"Project not found": "Проект не найден"
} }

View File

@ -0,0 +1,10 @@
<?php
return [
"Menu" => "Меню",
'Projects' => 'Проекты',
'Project' => 'Проект',
'About project' => 'О проекте',
'Page without translation' => 'Страница без перевода',
'Powered by service' => 'Работает на движке сайта «Мои проекты»',
'Choose language' => 'Выберите язык',
];

View File

@ -0,0 +1,72 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
@import "reset";
body {
margin: 0 20px;
font-family: "Roboto", sans-serif;
}
.projects-list {
padding: 0;
list-style: none;
a {
display: block;
text-decoration: none;
}
a:hover .projects-list__title {
box-shadow: 0px -1px 4px rgba(0, 0, 0, 0.7);
}
li {
padding-bottom: 20px;
}
}
.projects-list__img {
display: block;
background: #ddd;
height: 150px;
img {
object-fit: contain;
max-width: 100%;
max-height: 100%;
margin: auto;
}
}
.projects-list__title {
background: #ccc;
font-size: 18px;
font-weight: bold;
display: block;
padding: 10px;
box-shadow: 0px -1px 4px rgba(0, 0, 0, 0.3);
transition: box-shadow 0.3s ease-out;
}
@media (min-width: 600px) {
.projects-list {
display: grid;
margin-left: -20px;
li {
margin-left: 20px;
}
}
}
@media (min-width: 600px) and (max-width: 899px) {
.projects-list {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 900px) and (max-width: 1100px) {
.projects-list {
grid-template-columns: 1fr 1fr 1fr;
}
}
@media (min-width: 1001px) {
body {
max-width: 1200px;
margin: 0 auto;
}
.projects-list {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
}

View File

@ -0,0 +1,73 @@
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Prevent font size inflation */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
/* Remove default margin in favour of better control in authored CSS */
body, h1, h2, h3, h4, p,
figure, blockquote, dl, dd {
margin-block-end: 0;
}
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role='list'],
ol[role='list'] {
list-style: none;
}
/* Set core body defaults */
body {
min-height: 100vh;
line-height: 1.5;
}
/* Set shorter line heights on headings and interactive elements */
h1, h2, h3, h4,
button, input, label {
line-height: 1.1;
}
/* Balance text wrapping on headings */
h1, h2,
h3, h4 {
text-wrap: balance;
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
color: currentColor;
}
/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}
/* Inherit fonts for inputs and buttons */
input, button,
textarea, select {
font-family: inherit;
font-size: inherit;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block: 5ex;
}

View File

@ -0,0 +1,8 @@
let blockLanguage = document.querySelector('#language');
blockLanguage.querySelector('.language__button').addEventListener('click', (e) => {
if (blockLanguage.classList.contains('active')) {
blockLanguage.classList.remove('active');
} else {
blockLanguage.classList.add('active');
}
});

View File

@ -0,0 +1,8 @@
let body = document.querySelector('body');
document.querySelector('#mobile-menu').addEventListener('click', (e) => {
if (body.classList.contains('mobile-menu-open')) {
body.classList.remove('mobile-menu-open');
} else {
body.classList.add('mobile-menu-open');
}
});

View File

@ -0,0 +1,2 @@
import './_menu.js';
import './_choose-language.js';

View File

@ -0,0 +1,252 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@import "reset";
html,
body {
height: 100%;
}
body {
font-family: "Roboto", sans-serif;
margin: 0;
}
.wrapper {
display: flex;
flex-direction: column;
min-height: 100%;
.main-container {
flex: 1 0 auto;
}
.section-container {
display: flex;
flex: 1 0 auto;
min-height: 100%;
flex-direction: column;
.content {
flex: 1 0 auto;
}
.footer {
flex: 0 0 auto;
}
}
}
.header {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr 1fr;
grid-template-areas:
"logo menu"
"language language";
padding: 8px;
box-shadow: 0 3px 9px rgba(0,0,0,0.48);
background: #eee;
}
.header_logo {
grid-area: logo;
max-height: 50px;
a {
text-decoration: none;
}
img {
max-height: 100%;
}
strong {
color: #0a0e17;
font-size: 20px;
display: inline-block;
vertical-align: middle;
padding: 5px 10px;
border: 1px solid #0a0e17;
border-radius: 4px;
}
}
#mobile-menu {
grid-area: menu;
border: 0;
background: none;
color: #FF2D20;
margin-left: auto;
cursor: pointer;
position: relative;
z-index: 10;
.open {
display: block;
}
.close {
display: none;
margin-top: -5px;
}
}
#menu {
display: none;
ul {
padding: 10px 0 0 20px;
margin: 0;
list-style: none;
}
a {
display: block;
text-decoration: none;
font-size: 16px;
padding-top: 10px;
padding-bottom: 10px;
color: #0a0e17;
}
a.active {
font-weight: bold;
}
}
.menu__title {
background: #ccc;
font-size: 24px;
font-weight: bold;
padding: 12px 0 12px 20px;
box-shadow: 0 3px 9px rgba(0,0,0,0.48);
}
body.mobile-menu-open {
#mobile-menu {
.open {
display: none;
}
.close {
display: block;
}
}
#menu {
background: #ddd;
display: block;
position: fixed;
left: 0;
top: 0;
overflow: auto;
width: 100%;
height: 100%;
}
}
#language {
grid-area: language;
margin-top: 10px;
}
.language__block {
position: relative;
}
.language__button {
border: 0;
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.48);
background: #ccc;
width: 100%;
cursor: pointer;
font-size: 18px;
position: relative;
padding: 10px;
border-radius: 5px;
color: #000;
}
.language__button__str {
position: absolute;
top: calc(50% - 8px);
right: 10px;
}
.language__list {
background: #ddd;
display: none;
position: absolute;
top: 100%;
left: 0;
width: 100%;
list-style: none;
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.48);
padding: 10px 0;
margin: 0;
border-radius: 0 0 5px 5px;
a {
text-decoration: none;
color: #000;
font-size: 16px;
display: block;
padding: 10px 20px;
}
}
#language.active {
.language__list {
display: block;
}
.language__button {
border-radius: 5px 5px 0 0;
}
}
.main-container {
display: flex;
flex-direction: column;
}
.content {
padding-left: 20px;
padding-right: 20px;
margin-bottom: 20px;
h1 {
font-size: 24px;
font-weight: bold;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid #eee;
}
}
.footer {
background-color: #eee;
padding: 15px 0 15px 15px;
box-shadow: 5px 0px 5px rgba(0,0,0,0.48);
a {
text-decoration: none;
color: #0a0e17;
font-size: 18px;
font-weight: bold;
}
}
@media (min-width: 1000px) {
body {
max-width: 1400px;
margin: 0 auto;
}
.header {
grid-template-areas: "logo language";
grid-template-columns: 1fr 200px;
}
#mobile-menu {
display: none;
}
.main-container {
flex-direction: row;
}
.language__button {
padding: 7px 10px 7px 0;
}
#menu {
background: #eee;
display: block;
width: 175px;
box-shadow: 0px 7px 8px rgba(0,0,0,0.48);
}
.menu__title {
display: none;
}
}

View File

@ -0,0 +1,73 @@
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Prevent font size inflation */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
/* Remove default margin in favour of better control in authored CSS */
body, h1, h2, h3, h4, p,
figure, blockquote, dl, dd {
margin-block-end: 0;
}
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role='list'],
ol[role='list'] {
list-style: none;
}
/* Set core body defaults */
body {
min-height: 100vh;
line-height: 1.5;
}
/* Set shorter line heights on headings and interactive elements */
h1, h2, h3, h4,
button, input, label {
line-height: 1.1;
}
/* Balance text wrapping on headings */
h1, h2,
h3, h4 {
text-wrap: balance;
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
color: currentColor;
}
/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}
/* Inherit fonts for inputs and buttons */
input, button,
textarea, select {
font-family: inherit;
font-size: inherit;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block: 5ex;
}

View File

@ -0,0 +1,26 @@
<div id="language">
<div class="language__block">
<button class="language__button" type="button" aria-label="{{ __('site.Choose language') }}">
<svg width="16" height="16" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802"></path>
</svg>
{{ $language->title }}
<svg width="16" height="16" class="language__button__str" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path>
</svg>
</button>
<ul class="language__list">
@foreach($languages as $lang)
@php
$code = '';
if ($lang->is_default !== true) {
$code = '/language/' . $lang->code;
} else if($link === '') {
$code = '/';
}
@endphp
<li><a href="{{ $link . $code }}">{{ $lang->title }}</a></li>
@endforeach
</ul>
</div>
</div>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>@yield('meta_title', '')</title>
<meta name="keywords" content="@yield('meta_keywords', '')" />
<meta name="description" content="@yield('meta_description', '')" />
@vite('resources/home/scss/app.scss')
</head>
<body>
{{ $slot }}
</body>
</html>

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="{{ $language->code }}">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>@yield('meta_title', '')</title>
<meta name="keywords" content="@yield('meta_keywords', '')" />
<meta name="description" content="@yield('meta_description', '')" />
@vite('resources/site/scss/app.scss')
</head>
<body>
<div class="wrapper">
<header class="header">
<div class="header_logo">
<a href="{{ \App\Enums\Site\ProjectSection::Home->url($project, $language) }}">
@if($logo)
<img src="{{ $logo->url }}" alt="{{ $project->name }}">
@else
<strong>{{ $project->name }}</strong>
@endif
</a>
</div>
<button id="mobile-menu" type="button" aria-label="{{ __('site.Menu') }}">
<svg class="open" width="45" height="45" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"></path>
</svg>
<svg class="close" width="45" height="45" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"></path>
</svg>
</button>
<x-site.choose-language :language="$language" :languages="$project->languages" />
</header>
<div class="main-container">
<nav id="menu">
<div class="menu__title">{{ __('site.Menu') }}</div>
<ul>
<li><a href="{{ \App\Enums\Site\ProjectSection::Home->url($project, $language) }}" @class(['active' => request()->route()->named(['home', 'language.home'])])>{{ __('site.About project') }}</a></li>
</ul>
</nav>
<div class="section-container">
<div class="content">
<h1>@yield('h1', '')</h1>
{{ $slot }}
</div>
<footer class="footer">
<a href="https://git.kor-elf.net/kor-elf/my-projects-website" target="_blank">{{ __('site.Powered by service') }}</a>
</footer>
</div>
</div>
</div>
@vite('resources/site/js/app.js')
</body>
</html>

View File

@ -0,0 +1,5 @@
@section('h1', __('site.Page without translation'))
<x-site.layout :project="$project" :language="$language">
</x-site.layout>

View File

@ -0,0 +1,6 @@
@section('meta_title', __('site.Project') . ': ' . $content->title)
@section('h1', $content->title)
<x-site.layout :project="$project" :language="$language">
{!! $content->description !!}
</x-site.layout>

View File

@ -0,0 +1,18 @@
@section('meta_title', __('site.Projects'))
<x-home-layout>
<h1>{{ __('site.Projects') }}</h1>
<ul class="projects-list">
@foreach($projects as $project)
@php
$logo = $project->getStorageOne(\App\Enums\StorageType::Logo);
@endphp
<li>
<a href="{{ \App\Enums\Site\ProjectSection::Home->url($project) }}">
<span class="projects-list__img">@if($logo)<img src="{{ $logo->url }}" alt="{{ $project->name }}">@endif</span>
<span class="projects-list__title">{{ $project->name }}</span>
</a>
</li>
@endforeach
</ul>
</x-home-layout>

View File

@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home');
Route::get('/language/{language}', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home-language');

View File

@ -8,6 +8,11 @@ export default defineConfig({
input: [ input: [
'resources/volt/scss/app.scss', 'resources/volt/scss/app.scss',
'resources/volt/js/app.js', 'resources/volt/js/app.js',
'resources/home/scss/app.scss',
'resources/site/scss/app.scss',
'resources/site/js/app.js',
], ],
refresh: true, refresh: true,
}), }),