diff --git a/app/application/app/Dto/Builder/Project.php b/app/application/app/Dto/Builder/Project.php index 19127b0..8d777fa 100644 --- a/app/application/app/Dto/Builder/Project.php +++ b/app/application/app/Dto/Builder/Project.php @@ -2,9 +2,16 @@ namespace App\Dto\Builder; +use App\Models\User; + final readonly class Project { public function __construct( - + private ?bool $isPublic = null, ) { } + + public function isPublic(): ?bool + { + return $this->isPublic; + } } diff --git a/app/application/app/Enums/CacheTag.php b/app/application/app/Enums/CacheTag.php new file mode 100644 index 0000000..1ff3862 --- /dev/null +++ b/app/application/app/Enums/CacheTag.php @@ -0,0 +1,16 @@ +value); + } +} diff --git a/app/application/app/Enums/Site/ProjectSection.php b/app/application/app/Enums/Site/ProjectSection.php new file mode 100644 index 0000000..e8287db --- /dev/null +++ b/app/application/app/Enums/Site/ProjectSection.php @@ -0,0 +1,31 @@ +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), + }; + } +} diff --git a/app/application/app/Http/Controllers/Site/Controller.php b/app/application/app/Http/Controllers/Site/Controller.php new file mode 100644 index 0000000..28edf22 --- /dev/null +++ b/app/application/app/Http/Controllers/Site/Controller.php @@ -0,0 +1,18 @@ + $result->getProject(), + 'language' => $result->getLanguage(), + ]); + } +} diff --git a/app/application/app/Http/Controllers/Site/ProjectsController.php b/app/application/app/Http/Controllers/Site/ProjectsController.php new file mode 100644 index 0000000..3c61c3d --- /dev/null +++ b/app/application/app/Http/Controllers/Site/ProjectsController.php @@ -0,0 +1,62 @@ +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()); + } +} diff --git a/app/application/app/Http/Middleware/AdminPanel.php b/app/application/app/Http/Middleware/AdminPanel.php index 4ff3f8b..ec9355f 100644 --- a/app/application/app/Http/Middleware/AdminPanel.php +++ b/app/application/app/Http/Middleware/AdminPanel.php @@ -6,7 +6,7 @@ use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; -class AdminPanel +final class AdminPanel { /** * Handle an incoming request. diff --git a/app/application/app/Http/Middleware/Project.php b/app/application/app/Http/Middleware/Project.php new file mode 100644 index 0000000..a374785 --- /dev/null +++ b/app/application/app/Http/Middleware/Project.php @@ -0,0 +1,50 @@ +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); + } +} diff --git a/app/application/app/Http/Middleware/ProjectDomain.php b/app/application/app/Http/Middleware/ProjectDomain.php new file mode 100644 index 0000000..be4a7a5 --- /dev/null +++ b/app/application/app/Http/Middleware/ProjectDomain.php @@ -0,0 +1,32 @@ +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); + } +} diff --git a/app/application/app/Http/Requests/Admin/Projects/StoreUpdateRequest.php b/app/application/app/Http/Requests/Admin/Projects/StoreUpdateRequest.php index cf68e57..6af4e5e 100644 --- a/app/application/app/Http/Requests/Admin/Projects/StoreUpdateRequest.php +++ b/app/application/app/Http/Requests/Admin/Projects/StoreUpdateRequest.php @@ -18,10 +18,12 @@ class StoreUpdateRequest extends FormRequest implements FormRequestDto */ public function rules(): array { + $projectId = $this->project ?? null; + return [ 'name' => ['required', 'string', 'max:255'], - 'code' => ['required', 'string', 'min:3', 'max:255', 'regex:/^[a-z0-9_-]+$/i'], - 'http_host' => ['nullable', 'string', 'max:255', new HttpHost()], + 'code' => ['required', 'string', 'min:3', 'max:255', 'regex:/^[a-z0-9_-]+$/'], + 'http_host' => ['nullable', 'string', 'max:255', new HttpHost(), 'unique:projects,http_host,' . $projectId], 'is_public' => ['required', 'boolean'], 'logo.file' => ['nullable', 'numeric', 'min:1'], @@ -29,7 +31,7 @@ public function rules(): array 'languages.items.*.id' => ['nullable', 'numeric'], '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.default' => ['required', 'numeric', function (string $attribute, mixed $value, \Closure $fail) { $languages = $this->input('languages.items', []); diff --git a/app/application/app/Providers/AppServiceProvider.php b/app/application/app/Providers/AppServiceProvider.php index cdf595d..20abed2 100644 --- a/app/application/app/Providers/AppServiceProvider.php +++ b/app/application/app/Providers/AppServiceProvider.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Contracts\Foundation\Application; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; @@ -56,6 +57,9 @@ public function boot(): void Relation::enforceMorphMap(Morph::map()); + Route::pattern('language', '[a-z_]+'); + Route::pattern('project', '[a-z0-9_-]+'); + $this->configureRateLimiting(); Gate::define('AdminPanel', [\App\Policies\AdminPanel::class, 'view']); } diff --git a/app/application/app/Repositories/ProjectLanguageRepository.php b/app/application/app/Repositories/ProjectLanguageRepository.php index eec7bb7..6b7e4b5 100644 --- a/app/application/app/Repositories/ProjectLanguageRepository.php +++ b/app/application/app/Repositories/ProjectLanguageRepository.php @@ -2,10 +2,20 @@ namespace App\Repositories; +use App\Models\Project; use App\Models\ProjectLanguage; 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 { return ProjectLanguage::query() diff --git a/app/application/app/Repositories/ProjectLinkRepository.php b/app/application/app/Repositories/ProjectLinkRepository.php index 3491e7a..6c29ba4 100644 --- a/app/application/app/Repositories/ProjectLinkRepository.php +++ b/app/application/app/Repositories/ProjectLinkRepository.php @@ -3,10 +3,13 @@ namespace App\Repositories; use App\Contracts\Search; +use App\Models\Project; use App\Services\ProjectLink\BuilderCommand; use App\Services\Search\CreateSearchInstanceCommand; use App\Dto\Builder\ProjectLink as ProjectLinkBuilderDto; use App\Models\ProjectLink; +use Illuminate\Support\Collection; +use Illuminate\Database\Eloquent\Builder; final readonly class ProjectLinkRepository { @@ -29,4 +32,11 @@ public function getLinkById(int $id): ?ProjectLink { 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(); + } } diff --git a/app/application/app/Repositories/ProjectRepository.php b/app/application/app/Repositories/ProjectRepository.php index a28e88e..59c424a 100644 --- a/app/application/app/Repositories/ProjectRepository.php +++ b/app/application/app/Repositories/ProjectRepository.php @@ -26,6 +26,11 @@ public function getProjects(ProjectBuilderDto $projectBuilderDto, array $with = 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 { return Project::query()->where('id', $id)->first(); diff --git a/app/application/app/ServiceResults/Site/PagePossibleWithoutTranslation.php b/app/application/app/ServiceResults/Site/PagePossibleWithoutTranslation.php new file mode 100644 index 0000000..af3e03d --- /dev/null +++ b/app/application/app/ServiceResults/Site/PagePossibleWithoutTranslation.php @@ -0,0 +1,37 @@ +isTranslation; + } + + public function getData(): array + { + return $this->data; + } + + public function getProject(): Project + { + return $this->project; + } + + public function getLanguage(): ProjectLanguage + { + return $this->language; + } +} diff --git a/app/application/app/Services/Admin/ProjectService.php b/app/application/app/Services/Admin/ProjectService.php index 7b6334c..ccc05dd 100644 --- a/app/application/app/Services/Admin/ProjectService.php +++ b/app/application/app/Services/Admin/ProjectService.php @@ -5,6 +5,7 @@ use App\Dto\Builder\Project as ProjectBuilderDto; use App\Dto\QuerySettingsDto; use App\Dto\Service\Admin\Project\StoreUpdate; +use App\Enums\CacheTag; use App\Enums\Morph; use App\Models\Project; use App\Models\ProjectLanguage; @@ -14,6 +15,7 @@ use App\ServiceResults\ServiceResultError; use App\ServiceResults\ServiceResultSuccess; use App\ServiceResults\StoreUpdateResult; +use App\Services\ClearCacheCommandHandler; use App\Services\Project\ProjectCommandHandler; use App\Services\ProjectLanguage\ModelSyncCommand; use App\Services\Role\CreateAdminRoleForProjectCommand; @@ -29,6 +31,7 @@ public function __construct( private readonly CreateAdminRoleForProjectCommand $createAdminRoleForProjectCommand, private readonly ModelSyncCommand $languageModelSyncCommand, private readonly StorageService $storageService, + private readonly ClearCacheCommandHandler $clearCacheCommandHandler, ) { } 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; }); + $this->clearCacheCommandHandler->byTag(CacheTag::Project); } catch (\Throwable $e) { report($e); return $this->errService(__('Server Error')); @@ -172,6 +176,7 @@ public function update(int $id, StoreUpdate $data, User $user): ServiceResultErr return $project; }); + $this->clearCacheCommandHandler->byTag(CacheTag::Project); } catch (\Throwable $e) { report($e); return $this->errService(__('Server Error')); @@ -196,6 +201,7 @@ public function destroy(int $id, User $user): ServiceResultError|ServiceResultSu DB::transaction(function () use ($project) { $this->projectCommandHandler->handleDestroy($project); }); + $this->clearCacheCommandHandler->byTag(CacheTag::Project); } catch (\Throwable $e) { report($e); return $this->errService(__('Server Error')); diff --git a/app/application/app/Services/ClearCacheCommandHandler.php b/app/application/app/Services/ClearCacheCommandHandler.php new file mode 100644 index 0000000..73c9ff9 --- /dev/null +++ b/app/application/app/Services/ClearCacheCommandHandler.php @@ -0,0 +1,13 @@ +getCache()->flush(); + } +} diff --git a/app/application/app/Services/Project/BuilderCommand.php b/app/application/app/Services/Project/BuilderCommand.php index 3bafbea..abf10df 100644 --- a/app/application/app/Services/Project/BuilderCommand.php +++ b/app/application/app/Services/Project/BuilderCommand.php @@ -10,6 +10,10 @@ { public function execute(Relation | Builder $query, ProjectBuilderDto $projectBuilderDto): Relation | Builder { + if ($projectBuilderDto->isPublic() !== null) { + $query->where('is_public', $projectBuilderDto->isPublic()); + } + return $query; } } diff --git a/app/application/app/Services/Service.php b/app/application/app/Services/Service.php index 947ec95..44adf5e 100644 --- a/app/application/app/Services/Service.php +++ b/app/application/app/Services/Service.php @@ -2,12 +2,16 @@ namespace App\Services; +use App\Models\Project; +use App\Models\ProjectLanguage; use App\ServiceResults\ServiceResultArray; use App\ServiceResults\ServiceResultError; use App\ServiceResults\ServiceResultSuccess; +use App\ServiceResults\Site\PagePossibleWithoutTranslation; use App\ServiceResults\StoreUpdateResult; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Cache; abstract class Service { @@ -56,6 +60,11 @@ final protected function result(array $data = []): ServiceResultArray 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 { return new ServiceResultError( diff --git a/app/application/app/Services/Site/ProjectService.php b/app/application/app/Services/Site/ProjectService.php new file mode 100644 index 0000000..8573a23 --- /dev/null +++ b/app/application/app/Services/Site/ProjectService.php @@ -0,0 +1,73 @@ +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'])); + } +} diff --git a/app/application/app/View/Components/HomeLayout.php b/app/application/app/View/Components/HomeLayout.php new file mode 100644 index 0000000..64c1fb4 --- /dev/null +++ b/app/application/app/View/Components/HomeLayout.php @@ -0,0 +1,14 @@ +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, + ]); + } +} diff --git a/app/application/app/View/Components/Site/Layout.php b/app/application/app/View/Components/Site/Layout.php new file mode 100644 index 0000000..a0f13f5 --- /dev/null +++ b/app/application/app/View/Components/Site/Layout.php @@ -0,0 +1,26 @@ + $this->project, + 'logo' => $this->project->getStorageOne(StorageType::Logo), + 'language' => $this->language, + ]); + } +} diff --git a/app/application/bootstrap/app.php b/app/application/bootstrap/app.php index 7b162da..f877115 100644 --- a/app/application/bootstrap/app.php +++ b/app/application/bootstrap/app.php @@ -3,12 +3,23 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Support\Facades\Route; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', 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) { // diff --git a/app/application/lang/en.json b/app/application/lang/en.json index c30cdf8..1e550c9 100644 --- a/app/application/lang/en.json +++ b/app/application/lang/en.json @@ -254,4 +254,6 @@ "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": "Language not found", + "Project not found": "Project not found" } diff --git a/app/application/lang/en/site.php b/app/application/lang/en/site.php new file mode 100644 index 0000000..7dded98 --- /dev/null +++ b/app/application/lang/en/site.php @@ -0,0 +1,10 @@ + "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', +]; diff --git a/app/application/lang/ru.json b/app/application/lang/ru.json index 9a93a3c..c513d72 100644 --- a/app/application/lang/ru.json +++ b/app/application/lang/ru.json @@ -253,5 +253,7 @@ "in all languages": "на всех языках", "The link was successfully created": "Ссылка успешно создана", "The link was successfully updated": "Ссылка успешно обновлена", - "The link has been deleted": "Ссылка удалена" + "The link has been deleted": "Ссылка удалена", + "Language not found": "Язык не найден", + "Project not found": "Проект не найден" } diff --git a/app/application/lang/ru/site.php b/app/application/lang/ru/site.php new file mode 100644 index 0000000..380df94 --- /dev/null +++ b/app/application/lang/ru/site.php @@ -0,0 +1,10 @@ + "Меню", + 'Projects' => 'Проекты', + 'Project' => 'Проект', + 'About project' => 'О проекте', + 'Page without translation' => 'Страница без перевода', + 'Powered by service' => 'Работает на движке сайта «Мои проекты»', + 'Choose language' => 'Выберите язык', +]; diff --git a/app/application/resources/home/scss/app.scss b/app/application/resources/home/scss/app.scss new file mode 100644 index 0000000..288c278 --- /dev/null +++ b/app/application/resources/home/scss/app.scss @@ -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; + } +} diff --git a/app/application/resources/home/scss/reset.scss b/app/application/resources/home/scss/reset.scss new file mode 100644 index 0000000..263554a --- /dev/null +++ b/app/application/resources/home/scss/reset.scss @@ -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; +} diff --git a/app/application/resources/site/js/_choose-language.js b/app/application/resources/site/js/_choose-language.js new file mode 100644 index 0000000..3c62e18 --- /dev/null +++ b/app/application/resources/site/js/_choose-language.js @@ -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'); + } +}); diff --git a/app/application/resources/site/js/_menu.js b/app/application/resources/site/js/_menu.js new file mode 100644 index 0000000..8ef4d10 --- /dev/null +++ b/app/application/resources/site/js/_menu.js @@ -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'); + } +}); diff --git a/app/application/resources/site/js/app.js b/app/application/resources/site/js/app.js new file mode 100644 index 0000000..ade6ee6 --- /dev/null +++ b/app/application/resources/site/js/app.js @@ -0,0 +1,2 @@ +import './_menu.js'; +import './_choose-language.js'; diff --git a/app/application/resources/site/scss/app.scss b/app/application/resources/site/scss/app.scss new file mode 100644 index 0000000..e032aa3 --- /dev/null +++ b/app/application/resources/site/scss/app.scss @@ -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; + } +} diff --git a/app/application/resources/site/scss/reset.scss b/app/application/resources/site/scss/reset.scss new file mode 100644 index 0000000..263554a --- /dev/null +++ b/app/application/resources/site/scss/reset.scss @@ -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; +} diff --git a/app/application/resources/views/components/site/choose-language.blade.php b/app/application/resources/views/components/site/choose-language.blade.php new file mode 100644 index 0000000..9c15d25 --- /dev/null +++ b/app/application/resources/views/components/site/choose-language.blade.php @@ -0,0 +1,26 @@ +
+
+ + +
+
diff --git a/app/application/resources/views/layout/home.blade.php b/app/application/resources/views/layout/home.blade.php new file mode 100644 index 0000000..5e1a6af --- /dev/null +++ b/app/application/resources/views/layout/home.blade.php @@ -0,0 +1,16 @@ + + + + + + + @yield('meta_title', '') + + + + @vite('resources/home/scss/app.scss') + + + {{ $slot }} + + diff --git a/app/application/resources/views/layout/site.blade.php b/app/application/resources/views/layout/site.blade.php new file mode 100644 index 0000000..3440be9 --- /dev/null +++ b/app/application/resources/views/layout/site.blade.php @@ -0,0 +1,55 @@ + + + + + + + @yield('meta_title', '') + + + + @vite('resources/site/scss/app.scss') + + +
+
+ + + +
+
+ +
+
+

@yield('h1', '')

+ {{ $slot }} +
+ +
+
+
+ @vite('resources/site/js/app.js') + + diff --git a/app/application/resources/views/site/page-without-translation.blade.php b/app/application/resources/views/site/page-without-translation.blade.php new file mode 100644 index 0000000..a405f3b --- /dev/null +++ b/app/application/resources/views/site/page-without-translation.blade.php @@ -0,0 +1,5 @@ +@section('h1', __('site.Page without translation')) + + + + diff --git a/app/application/resources/views/site/projects/about.blade.php b/app/application/resources/views/site/projects/about.blade.php new file mode 100644 index 0000000..ff8225f --- /dev/null +++ b/app/application/resources/views/site/projects/about.blade.php @@ -0,0 +1,6 @@ +@section('meta_title', __('site.Project') . ': ' . $content->title) +@section('h1', $content->title) + + + {!! $content->description !!} + diff --git a/app/application/resources/views/site/projects/index.blade.php b/app/application/resources/views/site/projects/index.blade.php new file mode 100644 index 0000000..386f2ba --- /dev/null +++ b/app/application/resources/views/site/projects/index.blade.php @@ -0,0 +1,18 @@ +@section('meta_title', __('site.Projects')) + + +

{{ __('site.Projects') }}

+ +
diff --git a/app/application/routes/web-project.php b/app/application/routes/web-project.php new file mode 100644 index 0000000..5692160 --- /dev/null +++ b/app/application/routes/web-project.php @@ -0,0 +1,6 @@ +name('home'); +Route::get('/language/{language}', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home-language'); diff --git a/app/application/vite.config.js b/app/application/vite.config.js index dd794b1..04dcacf 100644 --- a/app/application/vite.config.js +++ b/app/application/vite.config.js @@ -8,6 +8,11 @@ export default defineConfig({ input: [ 'resources/volt/scss/app.scss', 'resources/volt/js/app.js', + + 'resources/home/scss/app.scss', + + 'resources/site/scss/app.scss', + 'resources/site/js/app.js', ], refresh: true, }),