├── public ├── favicon.ico ├── robots.txt ├── images │ └── icon │ │ ├── search-by-algolia-dark-background.png │ │ ├── search-by-algolia-light-background.png │ │ ├── dark-logo.svg │ │ └── logo.svg ├── index.php └── .htaccess ├── database ├── .gitignore ├── factories │ ├── TagFactory.php │ ├── CategoryFactory.php │ ├── CommentFactory.php │ ├── PasskeyFactory.php │ ├── PostFactory.php │ └── UserFactory.php ├── seeders │ ├── DatabaseSeeder.php │ ├── UserSeeder.php │ ├── TagSeeder.php │ ├── CommentSeeder.php │ └── PostSeeder.php └── migrations │ ├── 2020_08_12_000312_create_tags_table.php │ ├── 2021_07_08_091741_add_soft_delete_to_posts_table.php │ ├── 2022_12_30_170001_insert_default_register_setting.php │ ├── 2025_08_06_234839_rename_column_in_links.php │ ├── 2020_06_15_220259_add_introduction_to_users_table.php │ ├── 2023_07_23_121944_add_is_private_to_posts_table.php │ ├── 2024_01_05_141709_remove_notification_count_from_users.php │ ├── 2025_08_07_004904_rename_index_in_links.php │ ├── 2020_06_25_140342_add_notification_count_to_users_table.php │ ├── 2020_07_30_220101_create_links_table.php │ ├── 2022_07_23_162737_create_settings_table.php │ ├── 2022_07_24_190555_add_preview_url_to_posts_table.php │ ├── 2020_06_25_140225_create_notifications_table.php │ ├── 2024_10_26_180514_add_parent_id_to_comments.php │ ├── 2025_08_11_172159_create_admins_table.php │ ├── 2020_06_16_221810_create_categories_table.php │ ├── 2020_08_12_001956_create_post_tag_table.php │ ├── 2020_06_25_072401_create_comments_table.php │ ├── 2025_03_16_213156_create_passkeys_table.php │ ├── 2020_06_16_222625_seed_categories_data.php │ ├── 0001_01_01_000001_create_cache_table.php │ ├── 2020_06_16_223257_create_posts_table.php │ ├── 0001_01_01_000000_create_users_table.php │ ├── 2020_07_30_222225_seed_links_data.php │ ├── 2025_09_01_110048_convert_passkeys_to_polymorphic_relationship.php │ └── 0001_01_01_000002_create_jobs_table.php ├── bootstrap ├── cache │ └── .gitignore ├── providers.php └── app.php ├── storage ├── logs │ └── .gitignore ├── pail │ └── .gitignore ├── app │ ├── public │ │ └── .gitignore │ └── .gitignore ├── debugbar │ └── .gitignore └── framework │ ├── testing │ └── .gitignore │ ├── views │ └── .gitignore │ ├── cache │ ├── data │ │ └── .gitignore │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── resources ├── ts │ ├── app.ts │ ├── scroll-to-anchor.ts │ ├── webauthn.ts │ ├── debounce.ts │ ├── tagify.ts │ ├── markdown-helper.ts │ ├── scroll-to-top-btn.ts │ ├── progress-bar.ts │ └── oembed │ │ └── embed-youtube-oembed.ts └── views │ ├── components │ ├── card.blade.php │ ├── dashed-card.blade.php │ ├── dropdown │ │ ├── link.blade.php │ │ ├── menu.blade.php │ │ └── button.blade.php │ ├── icons │ │ ├── person.blade.php │ │ ├── x.blade.php │ │ ├── clock.blade.php │ │ ├── caret-down-fill.blade.php │ │ ├── caret-right.blade.php │ │ ├── lock.blade.php │ │ ├── three-dots-vertical.blade.php │ │ ├── unlock.blade.php │ │ ├── bell.blade.php │ │ ├── reply-fill.blade.php │ │ ├── twitter-x.blade.php │ │ ├── usb-drive-fill.blade.php │ │ ├── arrow-left-circle.blade.php │ │ ├── arrow-up.blade.php │ │ ├── exclamation-circle.blade.php │ │ ├── search.blade.php │ │ ├── arrow-right.blade.php │ │ ├── x-circle.blade.php │ │ ├── filter-left.blade.php │ │ ├── save.blade.php │ │ ├── list.blade.php │ │ ├── door-open.blade.php │ │ ├── arrow-counterclockwise.blade.php │ │ ├── person-plus.blade.php │ │ ├── rss.blade.php │ │ ├── exclamation-triangle.blade.php │ │ ├── home.blade.php │ │ ├── person-check.blade.php │ │ ├── person-lines.blade.php │ │ ├── upload.blade.php │ │ ├── easel.blade.php │ │ ├── chat-square-text.blade.php │ │ ├── info-circle.blade.php │ │ ├── chat-dots.blade.php │ │ ├── tags.blade.php │ │ ├── facebook.blade.php │ │ ├── person-x.blade.php │ │ ├── music-note-beamed.blade.php │ │ ├── file-earmark-x.blade.php │ │ ├── terminal.blade.php │ │ ├── file-earmark-code.blade.php │ │ ├── wrench.blade.php │ │ ├── animate-spin.blade.php │ │ ├── link-45deg.blade.php │ │ ├── lightbulb.blade.php │ │ ├── file-earmark-richtext.blade.php │ │ ├── pencil.blade.php │ │ ├── trash.blade.php │ │ ├── box-arrow-left.blade.php │ │ ├── cloud.blade.php │ │ ├── pencil-square.blade.php │ │ ├── folder-open.blade.php │ │ ├── book-half.blade.php │ │ ├── floppy.blade.php │ │ ├── question-circle-fill.blade.php │ │ ├── chat-heart.blade.php │ │ ├── file-earmark-lock.blade.php │ │ ├── clipboard-check.blade.php │ │ ├── github.blade.php │ │ ├── globe-americas.blade.php │ │ ├── question-circle.blade.php │ │ ├── person-walking.blade.php │ │ ├── sun.blade.php │ │ ├── battery-charging.blade.php │ │ ├── bug.blade.php │ │ ├── geer-fill.blade.php │ │ ├── cpu.blade.php │ │ ├── moon-stars.blade.php │ │ ├── stars.blade.php │ │ ├── hand-thumbs-up.blade.php │ │ ├── rocket-takeoff.blade.php │ │ ├── controller.blade.php │ │ ├── php.blade.php │ │ └── typescript.blade.php │ ├── tabs │ │ ├── tab-marker.blade.php │ │ ├── nav.blade.php │ │ └── button.blade.php │ ├── layouts │ │ ├── auth.blade.php │ │ ├── main.blade.php │ │ └── sharing-meta-tags.blade.php │ ├── tag.blade.php │ ├── quotes │ │ ├── danger.blade.php │ │ └── success.blade.php │ ├── input.blade.php │ ├── posts │ │ ├── progress-bar.blade.php │ │ ├── scroll-to-top-button.blade.php │ │ ├── editor-desktop-side-menu.blade.php │ │ └── ⚡mobile-menu.blade.php │ ├── auth-session-status.blade.php │ ├── auth-validation-errors.blade.php │ ├── button.blade.php │ ├── toggle-switch.blade.php │ ├── skew-underline-link.blade.php │ ├── select.blade.php │ ├── floating-label-textarea.blade.php │ ├── floating-label-input.blade.php │ ├── checkbox.blade.php │ └── users │ │ └── member-center-side-menu.blade.php │ ├── emails │ ├── create-passkey.blade.php │ └── destroy-user.blade.php │ ├── pages │ ├── posts │ │ └── ⚡index.blade.php │ ├── tags │ │ └── ⚡show.blade.php │ └── categories │ │ └── ⚡show.blade.php │ └── layouts │ └── app.blade.php ├── routes ├── console.php ├── auth.php └── api.php ├── lang ├── zh_TW │ ├── pagination.php │ ├── auth.php │ └── passwords.php └── en │ ├── pagination.php │ ├── auth.php │ └── passwords.php ├── tests ├── Browser │ ├── SmokeTest.php │ └── PostTest.php ├── Datasets │ └── DefaultCategoryIds.php ├── TestCase.php ├── Feature │ ├── Models │ │ ├── PostTest.php │ │ ├── UserTest.php │ │ └── CategoryTest.php │ ├── TagTest.php │ ├── Api │ │ ├── PostApiTest.php │ │ ├── ImageUploadApiTest.php │ │ ├── WebauthnApiTest.php │ │ └── OembedApiTest.php │ ├── ArchTest.php │ ├── FeedTest.php │ ├── CategoryTest.php │ └── Comments │ │ └── DeleteCommentTest.php ├── Unit │ ├── Factory │ │ └── UserTest.php │ ├── Services │ │ └── FormatTransferServiceTest.php │ └── Trait │ │ └── MarkdownConverterTest.php └── Pest.php ├── app ├── Http │ ├── Controllers │ │ ├── Controller.php │ │ ├── Api │ │ │ ├── ShowLatestPostController.php │ │ │ ├── ShowAllTagsController.php │ │ │ ├── TwitterOembedController.php │ │ │ ├── GeneratePasskeyAuthenticationOptionsController.php │ │ │ └── UploadImageController.php │ │ ├── Auth │ │ │ └── VerifyEmailController.php │ │ └── User │ │ │ └── DestroyUserController.php │ ├── Middleware │ │ └── CheckRegistrationIsValid.php │ └── Resources │ │ ├── TagResource.php │ │ └── PostResource.php ├── Interfaces │ └── OptionsInterface.php ├── Models │ ├── Link.php │ ├── Setting.php │ ├── Notification.php │ ├── Tag.php │ ├── Passkey.php │ └── Category.php ├── Policies │ ├── UserPolicy.php │ ├── PostPolicy.php │ ├── CommentPolicy.php │ └── PasskeyPolicy.php ├── Services │ ├── SettingService.php │ ├── FileService.php │ ├── FormatTransferService.php │ ├── CustomCounterChecker.php │ └── Serializer.php ├── Livewire │ ├── Actions │ │ └── Logout.php │ └── Forms │ │ └── CommentForm.php ├── Enums │ ├── CommentOrderOptions.php │ ├── PostOrderOptions.php │ └── UserInfoOptions.php ├── Rules │ ├── MatchOldPassword.php │ └── Captcha.php ├── Providers │ └── AppServiceProvider.php ├── Mail │ ├── CreatePasskeyMail.php │ └── DestroyUserMail.php ├── Notifications │ └── NewComment.php └── Console │ └── Commands │ ├── ChangeRegisterSettingCommand.php │ ├── DeleteTagCommand.php │ ├── DeleteLinkCommand.php │ └── CreateTagCommand.php ├── .gitattributes ├── rector.php ├── phpstan.neon ├── pint.json ├── .editorconfig ├── artisan ├── .gitignore ├── .prettierrc ├── package.json ├── tsconfig.json ├── config ├── services.php └── feed.php ├── vite.config.js ├── phpunit.xml └── .env.example /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite* 2 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/pail/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /resources/ts/app.ts: -------------------------------------------------------------------------------- 1 | // global frontend logic here 2 | -------------------------------------------------------------------------------- /storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/debugbar/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !public/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | '下一頁 »', 7 | 'previous' => '« 上一頁', 8 | ]; 9 | -------------------------------------------------------------------------------- /public/images/icon/search-by-algolia-dark-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilanboy/docfunc/main/public/images/icon/search-by-algolia-dark-background.png -------------------------------------------------------------------------------- /public/images/icon/search-by-algolia-light-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilanboy/docfunc/main/public/images/icon/search-by-algolia-light-background.png -------------------------------------------------------------------------------- /resources/views/components/card.blade.php: -------------------------------------------------------------------------------- 1 |
merge(['class' => 'rounded-xl bg-zinc-50 p-4 dark:bg-zinc-800']) }}> 2 | {{ $slot }} 3 |
4 | -------------------------------------------------------------------------------- /lang/en/pagination.php: -------------------------------------------------------------------------------- 1 | 'Next »', 7 | 'previous' => '« Previous', 8 | ]; 9 | -------------------------------------------------------------------------------- /tests/Browser/SmokeTest.php: -------------------------------------------------------------------------------- 1 | assertSee(config('app.name')); 7 | }); 8 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | '使用者名稱或密碼錯誤。', 7 | 'password' => '密碼錯誤', 8 | 'throttle' => '嘗試登入太多次,請在 :seconds 秒後再試。', 9 | ]; 10 | -------------------------------------------------------------------------------- /resources/views/emails/create-passkey.blade.php: -------------------------------------------------------------------------------- 1 | @component('mail::message') 2 | # 成功建立新的密碼金鑰 3 | 4 | 你的帳號已成功建立一個新的密碼金鑰「{{ $passkeyName }}」。 5 | 6 | 謝謝,
7 | {{ config('app.name') }} 8 | @endcomponent 9 | -------------------------------------------------------------------------------- /tests/Datasets/DefaultCategoryIds.php: -------------------------------------------------------------------------------- 1 | 1, 6 | 'categoryTwo' => 2, 7 | 'categoryThree' => 3, 8 | ]; 9 | }); 10 | -------------------------------------------------------------------------------- /app/Models/Link.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'rounded-xl border-2 border-dashed border-emerald-500/50 bg-zinc-50 p-5 dark:border-lividus-600/50 dark:bg-zinc-800']) }} 3 | > 4 | {{ $slot }} 5 | 6 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withSetProviders(LaravelSetProvider::class) 8 | ->withComposerBased(laravel: true/** other options */); 9 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/larastan/larastan/extension.neon 3 | - vendor/nesbot/carbon/extension.neon 4 | 5 | parameters: 6 | 7 | paths: 8 | - app/ 9 | 10 | excludePaths: 11 | - app/Traits/ 12 | 13 | level: 5 14 | -------------------------------------------------------------------------------- /resources/views/components/dropdown/link.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'flex items-center rounded-md px-4 py-2 hover:bg-zinc-200 dark:hover:bg-zinc-700 cursor-pointer', 'role' => 'menuitem', 'wire:navigate' => '']) }}> 3 | {{ $slot }} 4 | 5 | -------------------------------------------------------------------------------- /resources/views/components/dropdown/menu.blade.php: -------------------------------------------------------------------------------- 1 |
merge(['class' => 'mt-2 w-48 rounded-md bg-zinc-50 p-2 ring-1 ring-black/20 dark:bg-zinc-800 dark:text-zinc-50 dark:ring-zinc-400/40', 'role' => 'menu']) }} 3 | > 4 | {{ $slot }} 5 |
6 | -------------------------------------------------------------------------------- /resources/views/components/icons/person.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /resources/views/components/tabs/tab-marker.blade.php: -------------------------------------------------------------------------------- 1 |
merge(['class' => 'absolute left-0 z-10 h-full w-fit duration-300 ease-out']) }}> 2 |
3 |
4 | -------------------------------------------------------------------------------- /lang/zh_TW/passwords.php: -------------------------------------------------------------------------------- 1 | '密碼已成功重設!', 7 | 'sent' => '密碼重設郵件已發送!', 8 | 'throttled' => '請稍候再試。', 9 | 'token' => '密碼重設碼無效。', 10 | 'user' => '找不到該 E-mail 對應的使用者。', 11 | ]; 12 | -------------------------------------------------------------------------------- /resources/views/components/dropdown/button.blade.php: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | {{ $slot }} 8 | 9 | -------------------------------------------------------------------------------- /app/Models/Setting.php: -------------------------------------------------------------------------------- 1 | 'json']; 14 | } 15 | -------------------------------------------------------------------------------- /lang/en/auth.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 7 | 'password' => 'The provided password is incorrect.', 8 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 9 | ]; 10 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "blank_line_between_import_groups": true, 5 | "combine_consecutive_issets": true, 6 | "line_ending": true, 7 | "binary_operator_spaces": false 8 | }, 9 | "exclude": [ 10 | "tests" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /resources/views/components/icons/x.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 13 | 14 | -------------------------------------------------------------------------------- /app/Policies/UserPolicy.php: -------------------------------------------------------------------------------- 1 | id === $user->id; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /resources/views/components/tabs/nav.blade.php: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /resources/views/components/icons/clock.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/tabs/button.blade.php: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /resources/views/emails/destroy-user.blade.php: -------------------------------------------------------------------------------- 1 | 2 | # 帳號刪除確認 3 | 4 | 如果您確定要刪除帳號,請點選下方的按鈕連結 (連結將在 5 分鐘後失效)。 5 | 6 | 10 | 確認刪除帳號 11 | 12 | 13 | 謝謝,
14 | {{ config('app.name') }} 15 |
16 | -------------------------------------------------------------------------------- /database/factories/TagFactory.php: -------------------------------------------------------------------------------- 1 | fake()->word(), 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /resources/views/components/icons/caret-down-fill.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/caret-right.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/lock.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/tag.blade.php: -------------------------------------------------------------------------------- 1 | @props(['href']) 2 | 3 | 8 | {{ $slot }} 9 | 10 | -------------------------------------------------------------------------------- /resources/views/components/icons/three-dots-vertical.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/unlock.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/bell.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/reply-fill.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/quotes/danger.blade.php: -------------------------------------------------------------------------------- 1 |
merge(['class' => 'relative ml-4 rounded-md border-none bg-red-300/20 px-4 py-2 text-red-500 before:absolute before:-left-4 before:top-0 before:h-full before:w-1.5 before:rounded-sm before:bg-red-500 before:contain-none dark:text-red-400 dark:before:bg-red-400']) }} 3 | > 4 | {{ $slot }} 5 |
6 | -------------------------------------------------------------------------------- /resources/views/components/icons/twitter-x.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/usb-drive-fill.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /tests/Feature/Models/PostTest.php: -------------------------------------------------------------------------------- 1 | has(Comment::factory()->count(3)) 10 | ->create(); 11 | 12 | expect($post->comments) 13 | ->toHaveCount(3) 14 | ->each->toBeInstanceOf(Comment::class); 15 | }); 16 | -------------------------------------------------------------------------------- /resources/views/components/icons/arrow-left-circle.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | 17 | [docker-compose.yml] 18 | indent_size = 4 19 | 20 | [*.blade.php] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /resources/views/components/icons/arrow-up.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /resources/views/components/icons/exclamation-circle.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/search.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/quotes/success.blade.php: -------------------------------------------------------------------------------- 1 |
merge(['class' => 'relative ml-4 rounded-md border-none bg-emerald-300/20 px-4 py-2 text-emerald-500 before:absolute before:-left-4 before:top-0 before:h-full before:w-1.5 before:rounded-sm before:bg-emerald-500 before:contain-none dark:text-emerald-400 dark:before:bg-emerald-400']) }} 3 | > 4 | {{ $slot }} 5 |
6 | -------------------------------------------------------------------------------- /resources/views/components/icons/arrow-right.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /lang/en/passwords.php: -------------------------------------------------------------------------------- 1 | 'Your password has been reset.', 7 | 'sent' => 'We have emailed your password reset link.', 8 | 'throttled' => 'Please wait before retrying.', 9 | 'token' => 'This password reset token is invalid.', 10 | 'user' => 'We can\'t find a user with that email address.', 11 | ]; 12 | -------------------------------------------------------------------------------- /resources/views/components/icons/x-circle.blade.php: -------------------------------------------------------------------------------- 1 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /resources/views/components/icons/filter-left.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | handleCommand(new ArgvInput); 14 | 15 | exit($status); 16 | -------------------------------------------------------------------------------- /resources/views/components/icons/save.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/layouts/main.blade.php: -------------------------------------------------------------------------------- 1 |
5 | 6 | 7 |
8 | 9 | 10 | {{ $slot }} 11 |
12 | 13 | 14 |
15 | -------------------------------------------------------------------------------- /resources/views/components/icons/list.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /resources/views/components/input.blade.php: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /tests/Feature/TagTest.php: -------------------------------------------------------------------------------- 1 | random(); 9 | 10 | get(route('tags.show', ['id' => $tag->id])) 11 | ->assertSuccessful() 12 | ->assertSee($tag->name); 13 | }); 14 | 15 | it('can get all tags') 16 | ->getJson('api/tags') 17 | ->assertStatus(200); 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.phpunit.cache 3 | /node_modules 4 | /public/build 5 | /public/hot 6 | /public/storage 7 | /storage/*.key 8 | /vendor 9 | .env 10 | .env.backup 11 | .env.production 12 | .phpunit.result.cache 13 | Homestead.json 14 | Homestead.yaml 15 | auth.json 16 | npm-debug.log 17 | yarn-error.log 18 | /.fleet 19 | /.idea 20 | /.vscode 21 | _ide_helper.php 22 | _ide_helper_models.php 23 | /docker 24 | docker-compose.yml 25 | -------------------------------------------------------------------------------- /resources/views/components/posts/progress-bar.blade.php: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /resources/views/components/icons/door-open.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/arrow-counterclockwise.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/Policies/PostPolicy.php: -------------------------------------------------------------------------------- 1 | isAuthorOf($post); 15 | } 16 | 17 | public function destroy(User $user, Post $post): bool 18 | { 19 | return $user->isAuthorOf($post); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /resources/views/components/icons/person-plus.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /resources/views/components/icons/rss.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/exclamation-triangle.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/home.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/views/components/icons/person-check.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /resources/views/components/icons/person-lines.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/upload.blade.php: -------------------------------------------------------------------------------- 1 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /tests/Feature/Api/PostApiTest.php: -------------------------------------------------------------------------------- 1 | create(); 9 | 10 | get(route('api.posts')) 11 | ->assertJsonStructure([ 12 | 'data' => [ 13 | '*' => ['id', 'title', 'excerpt', 'created_at', 'updated_at', 'url'], 14 | ], 15 | ]) 16 | ->assertSuccessful(); 17 | }); 18 | -------------------------------------------------------------------------------- /resources/views/components/icons/easel.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/Services/SettingService.php: -------------------------------------------------------------------------------- 1 | where('key', 'allow_register') 15 | ->first() 16 | ->value; 17 | 18 | return filter_var($isRegisterAllow, FILTER_VALIDATE_BOOLEAN); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/views/components/icons/chat-square-text.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /tests/Unit/Factory/UserTest.php: -------------------------------------------------------------------------------- 1 | create(); 13 | Post::factory()->userId($user->id)->create(); 14 | 15 | expect(Post::latest()->first())->user_id->toBe($user->id); 16 | }); 17 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/ShowLatestPostController.php: -------------------------------------------------------------------------------- 1 | take(6)->get(); 16 | 17 | return PostResource::collection($posts); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /resources/views/components/auth-session-status.blade.php: -------------------------------------------------------------------------------- 1 | @props(['status']) 2 | 3 | @if ($status) 4 |
merge(['class' => 'relative ml-4 rounded-md border-none bg-emerald-300/20 px-4 py-2 text-emerald-500 before:absolute before:-left-4 before:top-0 before:h-full before:w-1.5 before:rounded-sm before:bg-emerald-500 before:contain-none dark:text-emerald-400 dark:before:bg-emerald-400']) }} 7 | > 8 | {{ $status }} 9 |
10 | @endif 11 | -------------------------------------------------------------------------------- /resources/views/components/auth-validation-errors.blade.php: -------------------------------------------------------------------------------- 1 | @props(['errors']) 2 | 3 | @if ($errors->any()) 4 |
merge(['class' => 'relative ml-4 rounded-md border-none bg-red-300/20 px-4 py-2 text-red-500 before:absolute before:-left-4 before:top-0 before:h-full before:w-1.5 before:rounded-sm before:bg-red-500 before:contain-none dark:text-red-400 dark:before:bg-red-400']) }} 7 | > 8 | {{ $errors->first() }} 9 |
10 | @endif 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/info-circle.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/chat-dots.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/tags.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/Livewire/Actions/Logout.php: -------------------------------------------------------------------------------- 1 | logout(); 18 | 19 | Session::invalidate(); 20 | Session::regenerateToken(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /resources/views/components/icons/facebook.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/person-x.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/Policies/CommentPolicy.php: -------------------------------------------------------------------------------- 1 | isAuthorOf($comment); 15 | } 16 | 17 | public function destroy(User $user, Comment $comment): bool 18 | { 19 | return $user->isAuthorOf($comment) || $user->isAuthorOf($comment->post); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/Http/Middleware/CheckRegistrationIsValid.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/Models/Notification.php: -------------------------------------------------------------------------------- 1 | whereNotNull('read_at') 19 | ->where('read_at', '<=', now()->subWeek()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /resources/views/components/icons/file-earmark-x.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/posts/scroll-to-top-button.blade.php: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /resources/ts/debounce.ts: -------------------------------------------------------------------------------- 1 | export default function debounce void>( 2 | callback: T, 3 | delay: number, 4 | ): (...args: Parameters) => void { 5 | let timeoutId: ReturnType; 6 | 7 | return function (this: ThisParameterType, ...args: Parameters): void { 8 | if (timeoutId) { 9 | clearTimeout(timeoutId); 10 | } 11 | 12 | timeoutId = setTimeout(() => { 13 | callback.apply(this, args); 14 | }, delay); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /resources/views/components/icons/terminal.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handleRequest(Request::capture()); 18 | -------------------------------------------------------------------------------- /database/factories/CategoryFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name(), 16 | 'icon' => $this->faker->bothify('##### #####'), 17 | 'description' => $this->faker->sentence(), 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | call([ 16 | UserSeeder::class, 17 | PostSeeder::class, 18 | TagSeeder::class, 19 | CommentSeeder::class, 20 | ]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Models/Tag.php: -------------------------------------------------------------------------------- 1 | belongsToMany(Post::class, 'post_tag', 'tag_id', 'post_id'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /resources/views/components/icons/file-earmark-code.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": true, 4 | "singleQuote": true, 5 | "wrapAttributes": "force-expand-multiline", 6 | "sortHtmlAttributes": "code-guide", 7 | "sortTailwindcssClasses": true, 8 | "plugins": [ 9 | "@shufo/prettier-plugin-blade" 10 | ], 11 | "overrides": [ 12 | { 13 | "files": [ 14 | "*.blade.php" 15 | ], 16 | "options": { 17 | "parser": "blade", 18 | "tabWidth": 2 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tests/Unit/Services/FormatTransferServiceTest.php: -------------------------------------------------------------------------------- 1 | formatTransferService = $this->app->make(FormatTransferService::class); 12 | }); 13 | 14 | it('will return empty array, if not pass the tag json string', function () { 15 | expect($this->formatTransferService->tagsJsonToTagIdsArray()) 16 | ->toBeEmpty(); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/Feature/ArchTest.php: -------------------------------------------------------------------------------- 1 | preset()->laravel(); 4 | 5 | arch()->preset()->security(); 6 | 7 | arch() 8 | ->expect('App\Enums') 9 | ->toBeEnums(); 10 | 11 | arch('livewire full-page component must have a \'Page\' suffix') 12 | ->expect('App\Livewire\Pages') 13 | ->toHaveSuffix('Page'); 14 | 15 | arch('livewire shared component must have a \'Part\' suffix') 16 | ->expect('App\Livewire\Shared') 17 | ->toHaveSuffix('Part'); 18 | 19 | arch('Application uses strict typing') 20 | ->expect('App') 21 | ->toUseStrictTypes(); 22 | -------------------------------------------------------------------------------- /app/Services/FileService.php: -------------------------------------------------------------------------------- 1 | '熱門留言', 21 | self::LATEST => '由新到舊', 22 | self::OLDEST => '由舊到新', 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /database/seeders/UserSeeder.php: -------------------------------------------------------------------------------- 1 | has(Passkey::factory(3)) 14 | ->count(10) 15 | ->create(); 16 | 17 | // 單獨處理第一個會員的數據 18 | $user = User::query()->find(1); 19 | $user->update([ 20 | 'name' => 'Allen', 21 | 'email' => 'allen@email.com', 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /resources/views/components/icons/wrench.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/animate-spin.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'animate-spin']) }} 3 | xmlns="http://www.w3.org/2000/svg" 4 | fill="none" 5 | viewBox="0 0 24 24" 6 | > 7 | 15 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /resources/views/components/icons/link-45deg.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /resources/views/components/icons/lightbulb.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/Http/Resources/TagResource.php: -------------------------------------------------------------------------------- 1 | $this->id, 21 | 'value' => $this->name, 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/ShowAllTagsController.php: -------------------------------------------------------------------------------- 1 | addDay(), 19 | fn () => Tag::all() 20 | ); 21 | 22 | return TagResource::collection($tags); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /resources/views/components/icons/file-earmark-richtext.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/pencil.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /database/migrations/2020_08_12_000312_create_tags_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name')->index(); 14 | $table->timestamps(); 15 | }); 16 | } 17 | 18 | public function down(): void 19 | { 20 | Schema::dropIfExists('tags'); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /resources/views/components/icons/trash.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /database/migrations/2021_07_08_091741_add_soft_delete_to_posts_table.php: -------------------------------------------------------------------------------- 1 | softDeletes(); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('posts', function (Blueprint $table) { 19 | $table->dropSoftDeletes(); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /database/migrations/2022_12_30_170001_insert_default_register_setting.php: -------------------------------------------------------------------------------- 1 | insert([ 11 | 'name' => '開放註冊', 12 | 'key' => 'allow_register', 13 | 'value' => 'false', 14 | 'created_at' => now(), 15 | 'updated_at' => now(), 16 | ]); 17 | } 18 | 19 | public function down(): void 20 | { 21 | DB::table('settings')->truncate(); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /resources/views/components/button.blade.php: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /resources/views/components/icons/box-arrow-left.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 15 | 16 | -------------------------------------------------------------------------------- /database/migrations/2025_08_06_234839_rename_column_in_links.php: -------------------------------------------------------------------------------- 1 | renameColumn('link', 'url'); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('links', function (Blueprint $table) { 19 | $table->renameColumn('url', 'link'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /resources/views/components/icons/cloud.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /database/migrations/2020_06_15_220259_add_introduction_to_users_table.php: -------------------------------------------------------------------------------- 1 | string('introduction')->nullable(); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('users', function (Blueprint $table) { 19 | $table->dropColumn('introduction'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /resources/views/components/icons/pencil-square.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /tests/Feature/Models/UserTest.php: -------------------------------------------------------------------------------- 1 | has(Post::factory()->count(3)) 10 | ->create(); 11 | 12 | expect($user->posts) 13 | ->toHaveCount(3) 14 | ->each->toBeInstanceOf(Post::class); 15 | }); 16 | 17 | // it has comments 18 | it('has comments', function () { 19 | $user = User::factory() 20 | ->has(Comment::factory()->count(3)) 21 | ->create(); 22 | 23 | expect($user->comments) 24 | ->toHaveCount(3) 25 | ->each->toBeInstanceOf(Comment::class); 26 | }); 27 | -------------------------------------------------------------------------------- /app/Services/FormatTransferService.php: -------------------------------------------------------------------------------- 1 | map(fn ($tag) => $tag->id) 25 | ->all(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /database/migrations/2023_07_23_121944_add_is_private_to_posts_table.php: -------------------------------------------------------------------------------- 1 | boolean('is_private')->default(false)->after('body'); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('posts', function (Blueprint $table) { 19 | $table->dropColumn('is_private'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /resources/views/components/icons/folder-open.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /database/migrations/2024_01_05_141709_remove_notification_count_from_users.php: -------------------------------------------------------------------------------- 1 | dropColumn('notification_count'); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('users', function (Blueprint $table) { 19 | $table->integer('notification_count')->unsigned()->default(0); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /database/migrations/2025_08_07_004904_rename_index_in_links.php: -------------------------------------------------------------------------------- 1 | renameIndex('links_link_index', 'links_url_index'); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('links', function (Blueprint $table) { 19 | $table->renameIndex('links_url_index', 'links_link_index'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /database/migrations/2020_06_25_140342_add_notification_count_to_users_table.php: -------------------------------------------------------------------------------- 1 | integer('notification_count')->unsigned()->default(0); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('users', function (Blueprint $table) { 19 | $table->dropColumn('notification_count'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /resources/views/components/icons/book-half.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /tests/Feature/Models/CategoryTest.php: -------------------------------------------------------------------------------- 1 | has(Post::factory()->count(3)) 9 | ->create(); 10 | 11 | expect($category->posts) 12 | ->toHaveCount(3) 13 | ->each->toBeInstanceOf(Post::class); 14 | }); 15 | 16 | it('has link with name', function () { 17 | $category = Category::factory()->create(); 18 | 19 | expect($category->link_with_name) 20 | ->toBe(route('categories.show', [ 21 | 'id' => $category->id, 22 | 'name' => $category->name, 23 | ])); 24 | })->group('has link with name'); 25 | -------------------------------------------------------------------------------- /app/Rules/MatchOldPassword.php: -------------------------------------------------------------------------------- 1 | user()->password)) { 22 | $fail('舊密碼錯誤'); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /database/migrations/2020_07_30_220101_create_links_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('title')->comment('資源的描述')->index(); 14 | $table->string('link')->comment('資源的連結')->index(); 15 | $table->timestamps(); 16 | }); 17 | } 18 | 19 | public function down(): void 20 | { 21 | Schema::dropIfExists('links'); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /resources/views/components/icons/floppy.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class CommentFactory extends Factory 13 | { 14 | public function definition(): array 15 | { 16 | return [ 17 | 'user_id' => User::factory(), 18 | 'post_id' => Post::factory(), 19 | 'parent_id' => null, 20 | 'body' => fake()->sentence, 21 | 'created_at' => fake()->dateTimeThisMonth(now()), 22 | 'updated_at' => now(), 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/Http/Resources/PostResource.php: -------------------------------------------------------------------------------- 1 | $this->id, 20 | 'title' => $this->title, 21 | 'excerpt' => $this->excerpt, 22 | 'created_at' => $this->created_at, 23 | 'updated_at' => $this->updated_at, 24 | 'url' => $this->link_with_slug, 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/views/components/icons/question-circle-fill.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /database/seeders/TagSeeder.php: -------------------------------------------------------------------------------- 1 | each(function ($post) use ($tags) { 24 | $post->tags()->attach( 25 | // 隨機取 0 ~ 5 Tag 的 ID 26 | $tags->random(random_int(0, 5))->pluck('id')->toArray() 27 | ); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /resources/views/components/icons/chat-heart.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /resources/views/components/icons/file-earmark-lock.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /database/migrations/2022_07_23_162737_create_settings_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name')->comment('設定名稱'); 14 | $table->string('key')->unique()->comment('設定鍵值'); 15 | $table->json('value')->comment('設定值'); 16 | $table->timestamps(); 17 | }); 18 | } 19 | 20 | public function down(): void 21 | { 22 | Schema::dropIfExists('settings'); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /tests/Feature/FeedTest.php: -------------------------------------------------------------------------------- 1 | assertSuccessful(); 10 | }); 11 | 12 | test('we can see the latest posts in the feed xml', function () { 13 | $posts = Post::factory()->count(3)->create(); 14 | 15 | get('/post/feed') 16 | ->assertSuccessful() 17 | ->assertSee(...$posts->map(fn ($post) => $post->title)->all()); 18 | }); 19 | 20 | test('we can only see latest 10 posts in the feed xml', function () { 21 | Post::factory()->count(11)->create(); 22 | 23 | get('/post/feed') 24 | ->assertSuccessful() 25 | ->assertDontSee(Post::oldest()->first()->title); 26 | }); 27 | -------------------------------------------------------------------------------- /resources/views/components/icons/clipboard-check.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 17 | 18 | -------------------------------------------------------------------------------- /tests/Feature/CategoryTest.php: -------------------------------------------------------------------------------- 1 | $category->id, 'name' => $category->name])) 11 | ->assertStatus(200); 12 | }); 13 | 14 | test('visit category show page and url don\'t include slug', function () { 15 | $category = Category::find(rand(1, 3)); 16 | 17 | get(route('categories.show', ['id' => $category->id])) 18 | ->assertRedirect( 19 | route('categories.show', [ 20 | 'id' => $category->id, 21 | 'name' => $category->name, 22 | ]) 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /database/migrations/2022_07_24_190555_add_preview_url_to_posts_table.php: -------------------------------------------------------------------------------- 1 | string('preview_url', 2048) 13 | ->after('slug') 14 | ->nullable() 15 | ->comment('文章預覽圖'); 16 | }); 17 | } 18 | 19 | public function down(): void 20 | { 21 | Schema::table('posts', function (Blueprint $table) { 22 | $table->dropColumn('preview_url'); 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /resources/views/components/icons/github.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/Models/Passkey.php: -------------------------------------------------------------------------------- 1 | */ 14 | use HasFactory; 15 | 16 | protected $fillable = [ 17 | 'name', 18 | 'credential_id', 19 | 'data', 20 | 'last_used_at', 21 | ]; 22 | 23 | protected $casts = [ 24 | 'data' => 'json', 25 | 'last_used_at' => 'datetime', 26 | ]; 27 | 28 | public function owner(): MorphTo 29 | { 30 | return $this->morphTo(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build" 6 | }, 7 | "devDependencies": { 8 | "@shufo/prettier-plugin-blade": "^1.14.1", 9 | "@simplewebauthn/browser": "^13.1.0", 10 | "@tailwindcss/typography": "^0.5.15", 11 | "@tailwindcss/vite": "^4.0.0", 12 | "@types/yaireo__tagify": "^4.27.0", 13 | "@yaireo/tagify": "^4.32.1", 14 | "ckeditor5": "^46.0.0", 15 | "laravel-vite-plugin": "^2.0.0", 16 | "playwright": "^1.57.0", 17 | "prettier": "^3.4.1", 18 | "shiki": "^3.17.1", 19 | "tailwindcss": "^4.0.0", 20 | "typescript": "^5.7.2", 21 | "vite": "^7.0.0" 22 | }, 23 | "type": "module" 24 | } 25 | -------------------------------------------------------------------------------- /resources/views/components/icons/globe-americas.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/question-circle.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /resources/views/components/icons/person-walking.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /database/migrations/2020_06_25_140225_create_notifications_table.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 13 | $table->string('type'); 14 | $table->morphs('notifiable'); 15 | $table->text('data'); 16 | $table->timestamp('read_at')->nullable(); 17 | $table->timestamps(); 18 | }); 19 | } 20 | 21 | public function down(): void 22 | { 23 | Schema::dropIfExists('notifications'); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /app/Services/CustomCounterChecker.php: -------------------------------------------------------------------------------- 1 | = $publicKeyCredentialSource->counter) { 19 | return; 20 | } 21 | 22 | throw CounterException::create( 23 | $currentCounter, 24 | $publicKeyCredentialSource->counter, 25 | 'Invalid counter.' 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /database/migrations/2024_10_26_180514_add_parent_id_to_comments.php: -------------------------------------------------------------------------------- 1 | foreignId('parent_id') 13 | ->index() 14 | ->nullable() 15 | ->constrained('comments') 16 | ->onDelete('cascade'); 17 | }); 18 | } 19 | 20 | public function down(): void 21 | { 22 | Schema::table('comments', function (Blueprint $table) { 23 | $table->dropForeign(['parent_id']); 24 | }); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /database/factories/PasskeyFactory.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class PasskeyFactory extends Factory 13 | { 14 | /** 15 | * Define the model's default state. 16 | * 17 | * @return array 18 | */ 19 | public function definition(): array 20 | { 21 | return [ 22 | 'owner_id' => User::factory(), 23 | 'owner_type' => User::class, 24 | 'name' => fake()->word, 25 | 'credential_id' => Str::random(), 26 | 'data' => ['transports' => ['internal']], 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /database/migrations/2025_08_11_172159_create_admins_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->string('email')->unique(); 15 | $table->timestamp('email_verified_at')->nullable(); 16 | $table->string('password'); 17 | $table->rememberToken(); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | public function down(): void 23 | { 24 | Schema::dropIfExists('admins'); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /database/migrations/2020_06_16_221810_create_categories_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name')->index()->comment('名稱'); 14 | $table->string('icon')->nullable()->comment('圖示'); 15 | $table->text('description')->nullable()->comment('描述'); 16 | $table->integer('post_count')->default(0)->comment('文章數'); 17 | }); 18 | } 19 | 20 | public function down(): void 21 | { 22 | Schema::dropIfExists('categories'); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /resources/views/components/icons/sun.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "allowJs": true, 8 | "resolveJsonModule": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | // "verbatimModuleSyntax": true, 12 | 13 | /* Strictness */ 14 | "strict": true, 15 | // "noUncheckedIndexedAccess": true, 16 | "noImplicitOverride": true, 17 | 18 | /* If transpiling with TypeScript: */ 19 | "module": "NodeNext", 20 | "outDir": "dist", 21 | "sourceMap": true, 22 | 23 | /* If your code runs in the DOM: */ 24 | "lib": ["es2022", "dom", "dom.iterable"] 25 | }, 26 | "include": ["resources/ts/**/*"] 27 | } 28 | -------------------------------------------------------------------------------- /app/Enums/PostOrderOptions.php: -------------------------------------------------------------------------------- 1 | '最新文章', 19 | self::RECENT => '最近更新', 20 | self::COMMENT => '最多留言', 21 | }; 22 | } 23 | 24 | public function iconComponentName(): string 25 | { 26 | return match ($this) { 27 | self::LATEST => 'icons.stars', 28 | self::RECENT => 'icons.wrench', 29 | self::COMMENT => 'icons.chat-square-text', 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /resources/views/components/icons/battery-charging.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | 14 | 17 | 18 | -------------------------------------------------------------------------------- /resources/views/components/icons/bug.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/icons/geer-fill.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/pages/posts/⚡index.blade.php: -------------------------------------------------------------------------------- 1 | view()->title($title); 13 | } 14 | }; 15 | ?> 16 | 17 | 18 | {{-- 文章列表 --}} 19 |
20 |
21 |
22 | {{-- 文章列表 --}} 23 | 24 |
25 | 26 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/VerifyEmailController.php: -------------------------------------------------------------------------------- 1 | user()->hasVerifiedEmail()) { 20 | return redirect()->intended('/?verified=1'); 21 | } 22 | 23 | if ($request->user()->markEmailAsVerified()) { 24 | event(new Verified($request->user())); 25 | } 26 | 27 | return redirect()->intended('/?verified=1'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /database/migrations/2020_08_12_001956_create_post_tag_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignId('post_id')->index()->constrained()->onDelete('cascade'); 14 | $table->foreignId('tag_id')->index()->constrained()->onDelete('cascade'); 15 | }); 16 | } 17 | 18 | public function down(): void 19 | { 20 | Schema::table('post_tag', function (Blueprint $table) { 21 | $table->dropForeign(['post_id']); 22 | $table->dropForeign(['tag_id']); 23 | }); 24 | 25 | Schema::dropIfExists('post_tag'); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /resources/views/components/icons/cpu.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/TwitterOembedController.php: -------------------------------------------------------------------------------- 1 | url; 21 | $apiUrl .= '&theme='.$request->theme; 22 | $apiUrl .= '&omit_script=true'; 23 | 24 | $response = Http::get($apiUrl); 25 | 26 | return $response->successful() 27 | ? $response 28 | : response()->json(['html' => '

Twitter 連結發生錯誤... 🥲

'], 400); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /resources/views/components/toggle-switch.blade.php: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /tests/Browser/PostTest.php: -------------------------------------------------------------------------------- 1 | This is post-title 1 8 |

This is post-body 1

9 |

This is post-title 2

10 |

This is post-body 2

11 | HTML; 12 | 13 | 14 | $post = Post::factory()->create([ 15 | 'body' => $body, 16 | ]); 17 | 18 | $page = $this->visit($post->link_with_slug); 19 | 20 | $page->assertSee('目錄') 21 | ->assertSeeLink('This is post-title 1') 22 | ->assertSeeLink('This is post-title 2'); 23 | }); 24 | 25 | test('user cannot see post outline, if there is no heading', function () { 26 | $body = <<<'HTML' 27 |

This is a post-body

28 | HTML; 29 | 30 | $post = Post::factory()->create([ 31 | 'body' => $body, 32 | ]); 33 | 34 | $page = $this->visit($post->link_with_slug); 35 | 36 | $page->assertDontSee('目錄'); 37 | }); 38 | -------------------------------------------------------------------------------- /app/Http/Controllers/User/DestroyUserController.php: -------------------------------------------------------------------------------- 1 | hasValidSignature(), 401); 24 | 25 | Auth::guard('web')->logout(); 26 | 27 | $request->session()->invalidate(); 28 | 29 | $request->session()->regenerateToken(); 30 | 31 | $user->delete(); 32 | 33 | return to_route('posts.index') 34 | ->with('alert', ['status' => 'success', 'message' => '帳號已刪除!']); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/images/icon/dark-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/images/icon/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /database/migrations/2020_06_25_072401_create_comments_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->text('body'); 14 | $table->timestamps(); 15 | $table->foreignId('user_id')->index()->nullable()->constrained()->nullOnDelete(); 16 | $table->foreignId('post_id')->index()->constrained()->onDelete('cascade'); 17 | }); 18 | } 19 | 20 | public function down(): void 21 | { 22 | Schema::table('comments', function (Blueprint $table) { 23 | $table->dropForeign(['user_id']); 24 | $table->dropForeign(['post_id']); 25 | }); 26 | 27 | Schema::drop('comments'); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /app/Mail/CreatePasskeyMail.php: -------------------------------------------------------------------------------- 1 | id(); 17 | 18 | $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); 19 | $table->text('name'); 20 | $table->text('credential_id'); 21 | $table->json('data'); 22 | 23 | $table->timestamp('last_used_at')->nullable(); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | */ 31 | public function down(): void 32 | { 33 | Schema::dropIfExists('passkeys'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /database/migrations/2020_06_16_222625_seed_categories_data.php: -------------------------------------------------------------------------------- 1 | '日常分享', 13 | 'icon' => 'bi bi-chat-dots-fill', 14 | 'description' => '想聊啥就聊啥', 15 | ], 16 | [ 17 | 'name' => '程式技術', 18 | 'icon' => 'bi bi-terminal-fill', 19 | 'description' => '程式技術交流與分享', 20 | ], 21 | [ 22 | 'name' => '電玩遊戲', 23 | 'icon' => 'bi bi-dpad-fill', 24 | 'description' => '電玩遊戲話題與心得', 25 | ], 26 | ]; 27 | 28 | DB::table('categories')->insert($categories); 29 | } 30 | 31 | public function down(): void 32 | { 33 | DB::table('categories')->truncate(); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /app/Notifications/NewComment.php: -------------------------------------------------------------------------------- 1 | comment->post; 27 | $link = route('comments.show', ['id' => $this->comment->id]); 28 | 29 | // 存入資料庫裡的數據 30 | return [ 31 | 'comment_id' => $this->comment->id, 32 | 'post_link' => $link, 33 | 'post_id' => $post->id, 34 | 'post_title' => $post->title, 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Rules/Captcha.php: -------------------------------------------------------------------------------- 1 | post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ 25 | 'secret' => config('services.captcha.secret_key'), 26 | 'response' => $value, 27 | ]); 28 | 29 | if (! ($response->successful() && $response->json('success'))) { 30 | $fail('驗證失敗'); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Models/Category.php: -------------------------------------------------------------------------------- 1 | hasMany(Post::class); 28 | } 29 | 30 | // 將連結加上分類名稱 31 | public function linkWithName(): Attribute 32 | { 33 | return new Attribute( 34 | get: fn ($value) => route('categories.show', [ 35 | 'id' => $this->id, 36 | 'name' => $this->name, 37 | ]) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /routes/auth.php: -------------------------------------------------------------------------------- 1 | middleware('guest') 9 | ->name('login'); 10 | 11 | Route::livewire('/verify-email', 'pages::auth.verify-email') 12 | ->middleware('auth') 13 | ->name('verification.notice'); 14 | 15 | Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class) 16 | ->middleware(['auth', 'signed', 'throttle:6,1']) 17 | ->name('verification.verify'); 18 | 19 | Route::livewire('/register', 'pages::auth.register') 20 | ->middleware(['guest', CheckRegistrationIsValid::class]) 21 | ->name('register'); 22 | 23 | Route::livewire('/forgot-password', 'pages::auth.forgot-password') 24 | ->middleware('guest') 25 | ->name('password.request'); 26 | 27 | Route::livewire('/reset-password/{token}', 'pages::auth.reset-password') 28 | ->middleware('guest') 29 | ->name('password.reset'); 30 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000001_create_cache_table.php: -------------------------------------------------------------------------------- 1 | string('key')->primary(); 16 | $table->mediumText('value'); 17 | $table->integer('expiration'); 18 | }); 19 | 20 | Schema::create('cache_locks', function (Blueprint $table) { 21 | $table->string('key')->primary(); 22 | $table->string('owner'); 23 | $table->integer('expiration'); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | */ 30 | public function down(): void 31 | { 32 | Schema::dropIfExists('cache'); 33 | Schema::dropIfExists('cache_locks'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /resources/views/components/icons/moon-stars.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /resources/views/components/icons/stars.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/pages/tags/⚡show.blade.php: -------------------------------------------------------------------------------- 1 | tag = Tag::findOrFail($id); 14 | } 15 | 16 | public function render() 17 | { 18 | return $this->view()->title($this->tag->name); 19 | } 20 | }; 21 | ?> 22 | 23 | {{-- 文章列表 --}} 24 | 25 |
26 |
27 |
28 | {{-- 文章列表 --}} 29 | 33 |
34 | 35 | 39 |
40 |
41 |
42 | -------------------------------------------------------------------------------- /resources/views/components/skew-underline-link.blade.php: -------------------------------------------------------------------------------- 1 | {{-- prettier-ignore-start --}} 2 | @props([ 3 | 'link', 4 | 'icon' => '', 5 | 'selected' => false 6 | ]) 7 | {{-- prettier-ignore-end --}} 8 | 9 | 15 | 16 | @if (empty($icon)) 17 | 18 | @else 19 |
{!! $icon !!}
20 | @endif 21 | 22 | {{ $slot }} 23 |
24 | !$selected, 27 | 'w-full' => $selected, 28 | ])> 29 |
30 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/GeneratePasskeyAuthenticationOptionsController.php: -------------------------------------------------------------------------------- 1 | host(), 27 | allowCredentials: [], 28 | ); 29 | 30 | $options = Serializer::make()->toJson($options); 31 | 32 | Session::flash('passkey-authentication-options', $options); 33 | 34 | return $options; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/Enums/UserInfoOptions.php: -------------------------------------------------------------------------------- 1 | '個人資訊', 19 | self::POSTS => '發布文章', 20 | self::COMMENTS => '留言紀錄', 21 | }; 22 | } 23 | 24 | public function iconComponentName(): string 25 | { 26 | return match ($this) { 27 | self::INFORMATION => 'icons.info-circle', 28 | self::POSTS => 'icons.file-earmark-richtext', 29 | self::COMMENTS => 'icons.chat-square-text', 30 | }; 31 | } 32 | 33 | public function livewireComponentName(): string 34 | { 35 | return match ($this) { 36 | self::INFORMATION => 'users.info-cards', 37 | self::POSTS => 'users.posts', 38 | self::COMMENTS => 'users.comments', 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/views/components/select.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 6 | 7 |
8 | 14 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /database/factories/PostFactory.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function definition(): array 15 | { 16 | return [ 17 | 'title' => fake()->text(30), 18 | 'body' => fake()->paragraph(50), 19 | 'is_private' => false, 20 | 'preview_url' => fake()->imageUrl(), 21 | 'slug' => fake()->word(), 22 | 'excerpt' => fake()->sentence, 23 | 'category_id' => fake()->numberBetween(1, 3), 24 | 'user_id' => User::factory(), 25 | // 隨機取一個月以內,但早於現在的時間 26 | 'created_at' => fake()->dateTimeThisMonth(now()), 27 | 'updated_at' => now(), 28 | ]; 29 | } 30 | 31 | public function userId(int $userId): static 32 | { 33 | return $this->state(function (array $attributes) use ($userId) { 34 | return [ 35 | 'user_id' => $userId, 36 | ]; 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /resources/views/components/layouts/sharing-meta-tags.blade.php: -------------------------------------------------------------------------------- 1 | @props(['title']) 2 | 3 | @php 4 | $defaultPreviewUrl = 'https://blobs.docfunc.com/share.jpg'; 5 | @endphp 6 | 7 | {{-- Open Graph / Facebook --}} 8 | 12 | 16 | 20 | 24 | 28 | 29 | {{-- Twitter --}} 30 | 34 | 38 | 42 | 46 | 50 | -------------------------------------------------------------------------------- /resources/views/components/floating-label-textarea.blade.php: -------------------------------------------------------------------------------- 1 |
filter(fn(string $value, string $key) => $key === 'class')->merge(['class' => 'relative']) }}> 2 | 6 | 7 | 13 |
14 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 12 | web: __DIR__.'/../routes/web.php', 13 | api: __DIR__.'/../routes/api.php', 14 | commands: __DIR__.'/../routes/console.php', 15 | ) 16 | ->withMiddleware(function (Middleware $middleware) { 17 | $middleware->statefulApi(); 18 | }) 19 | ->withExceptions(function (Exceptions $exceptions) { 20 | // Symfony serializer error 21 | $exceptions->report(function (ExceptionInterface $e) { 22 | Log::error('A Symfony serializer happened: '.$e->getMessage()); 23 | }); 24 | 25 | // Passkey InvalidDataException 26 | $exceptions->report(function (InvalidDataException $e) { 27 | Log::error('Invalid data for generating passkey options: '.$e->getMessage()); 28 | }); 29 | })->create(); 30 | -------------------------------------------------------------------------------- /app/Mail/DestroyUserMail.php: -------------------------------------------------------------------------------- 1 | 45 | */ 46 | public function attachments(): array 47 | { 48 | return []; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserFactory extends Factory 12 | { 13 | /** 14 | * Define the model's default state. 15 | * 16 | * @return array 17 | */ 18 | public function definition(): array 19 | { 20 | return [ 21 | 'name' => fake()->name(), 22 | 'email' => fake()->unique()->safeEmail(), 23 | 'email_verified_at' => now(), 24 | 'password' => bcrypt('Password101'), 25 | 'remember_token' => Str::random(10), 26 | 'introduction' => fake()->sentence, 27 | 'created_at' => fake()->dateTimeThisMonth(now()), 28 | 'updated_at' => now(), 29 | ]; 30 | } 31 | 32 | /** 33 | * Indicate that the model's email address should be unverified. 34 | */ 35 | public function unverified(): static 36 | { 37 | return $this->state(fn (array $attributes) => [ 38 | 'email_verified_at' => null, 39 | ]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Feature/Api/ImageUploadApiTest.php: -------------------------------------------------------------------------------- 1 | Storage::fake()); 9 | 10 | test('users who are not logged in cannot upload images', function () { 11 | post(route('images.store'), [ 12 | 'upload' => UploadedFile::fake()->image('photo.jpg')->size(100), 13 | ]) 14 | ->assertStatus(302) 15 | ->assertRedirect(route('login')); 16 | 17 | Storage::disk()->assertDirectoryEmpty('images'); 18 | }); 19 | 20 | test('logged-in users can upload images', function () { 21 | loginAsUser(); 22 | 23 | $file = UploadedFile::fake()->image('photo.jpg')->size(100); 24 | 25 | post(route('images.store'), [ 26 | 'upload' => $file, 27 | ])->assertStatus(200); 28 | 29 | expect(Storage::disk()->allFiles())->not->toBeEmpty(); 30 | }); 31 | 32 | test('the size of the uploaded image must be less than 1024 kb', function () { 33 | loginAsUser(); 34 | 35 | post(route('images.store'), [ 36 | 'upload' => UploadedFile::fake()->image('photo.jpg')->size(1025), 37 | ])->assertStatus(413); 38 | 39 | Storage::disk()->assertDirectoryEmpty('images'); 40 | }); 41 | -------------------------------------------------------------------------------- /app/Console/Commands/ChangeRegisterSettingCommand.php: -------------------------------------------------------------------------------- 1 | update(['value' => 'true']); 40 | $this->info('Guests can register'); 41 | } else { 42 | Setting::where('key', 'allow_register')->update(['value' => 'false']); 43 | $this->info('Guests cannot register'); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'token' => env('POSTMARK_TOKEN'), 19 | ], 20 | 21 | 'ses' => [ 22 | 'key' => env('AWS_ACCESS_KEY_ID'), 23 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 24 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 25 | ], 26 | 27 | 'slack' => [ 28 | 'notifications' => [ 29 | 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 30 | 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), 31 | ], 32 | ], 33 | 34 | 'captcha' => [ 35 | 'site_key' => env('CAPTCHA_SITE_KEY'), 36 | 'secret_key' => env('CAPTCHA_SECRET_KEY'), 37 | ], 38 | ]; 39 | -------------------------------------------------------------------------------- /resources/ts/tagify.ts: -------------------------------------------------------------------------------- 1 | import Tagify, { ChangeEventData } from '@yaireo/tagify'; 2 | 3 | declare global { 4 | interface Window { 5 | createTagify: ( 6 | element: HTMLInputElement, 7 | whitelist: Tagify.TagData[], 8 | callbackOnChange: (event: CustomEvent) => void, 9 | ) => Tagify; 10 | } 11 | } 12 | 13 | window.createTagify = function ( 14 | element: HTMLInputElement, 15 | whitelist: Tagify.TagData[], 16 | callbackOnChange: (event: CustomEvent) => void, 17 | ) { 18 | return new Tagify(element, { 19 | whitelist: whitelist, 20 | enforceWhitelist: true, 21 | maxTags: 5, 22 | dropdown: { 23 | // show the dropdown immediately on focus 24 | enabled: 0, 25 | maxItems: 5, 26 | // place the dropdown near the typed text 27 | position: 'text', 28 | // keep the dropdown open after selecting a suggestion 29 | closeOnSelect: false, 30 | highlightFirst: true, 31 | }, 32 | callbacks: { 33 | // binding the value of the tag input to the livewire attribute 'tags' 34 | change: callbackOnChange, 35 | }, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /resources/views/components/floating-label-input.blade.php: -------------------------------------------------------------------------------- 1 |
filter(fn(string $value, string $key) => $key === 'class')->merge(['class' => 'relative']) }}> 2 | filter(fn(string $value, string $key) => $key !== 'class') }} 5 | > 6 | 7 | 13 |
14 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import laravel from 'laravel-vite-plugin'; 3 | import tailwindcss from '@tailwindcss/vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | laravel({ 8 | input: [ 9 | // typescript 10 | 'resources/ts/app.ts', 11 | 'resources/ts/ckeditor/ckeditor.ts', 12 | 'resources/ts/sharer.ts', 13 | 'resources/ts/shiki.ts', 14 | 'resources/ts/tagify.ts', 15 | 'resources/ts/scroll-to-top-btn.ts', 16 | 'resources/ts/reader-helpers/code-block-helper.ts', 17 | 'resources/ts/reader-helpers/image-block-helper.ts', 18 | 'resources/ts/oembed/embed-youtube-oembed.ts', 19 | 'resources/ts/oembed/embed-twitter-oembed.ts', 20 | 'resources/ts/progress-bar.ts', 21 | 'resources/ts/scroll-to-anchor.ts', 22 | 'resources/ts/post-outline.ts', 23 | 'resources/ts/webauthn.ts', 24 | 'resources/ts/markdown-helper.ts', 25 | // css 26 | 'resources/css/app.css', 27 | 'node_modules/@yaireo/tagify/dist/tagify.css', 28 | ], 29 | refresh: true, 30 | }), 31 | tailwindcss(), 32 | ], 33 | }); 34 | -------------------------------------------------------------------------------- /resources/views/pages/categories/⚡show.blade.php: -------------------------------------------------------------------------------- 1 | category = Category::findOrFail($id); 14 | } 15 | 16 | public function render() 17 | { 18 | // because name is optional, we can't use route parameters 19 | if (!empty($this->category->name) && $this->category->name !== request()->name) { 20 | redirect()->to($this->category->link_with_name); 21 | } 22 | 23 | return $this->view()->title($this->category->name); 24 | } 25 | }; 26 | ?> 27 | 28 | {{-- 文章列表 --}} 29 | 30 |
31 |
32 |
33 | {{-- 文章列表 --}} 34 | 38 |
39 | 40 | 44 |
45 |
46 |
47 | -------------------------------------------------------------------------------- /database/seeders/CommentSeeder.php: -------------------------------------------------------------------------------- 1 | random_int(1, $userCount), 26 | 'post_id' => random_int(1, $postCount), 27 | 'body' => fake()->sentence, 28 | 'created_at' => now(), 29 | 'updated_at' => now(), 30 | ]; 31 | 32 | if ($i % self::CHUNK === 0) { 33 | yield $data; 34 | 35 | $data = []; 36 | } 37 | } 38 | } 39 | 40 | public function run(): void 41 | { 42 | $userCount = User::query()->count(); 43 | $postCount = Post::query()->count(); 44 | 45 | foreach ($this->commentGenerator($userCount, $postCount) as $data) { 46 | Comment::query()->insert($data); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /resources/views/components/posts/editor-desktop-side-menu.blade.php: -------------------------------------------------------------------------------- 1 | @props(['formId']) 2 | 3 | 36 | -------------------------------------------------------------------------------- /database/migrations/2020_06_16_223257_create_posts_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('title')->index(); 14 | $table->mediumText('body'); 15 | $table->integer('category_id')->unsigned()->index(); 16 | $table->text('excerpt')->nullable(); 17 | $table->string('slug')->nullable(); 18 | $table->timestamps(); 19 | 20 | $table->foreignId('user_id')->index()->constrained()->onDelete('cascade'); 21 | // This line is equivalent to the following two lines in MySQL. 22 | // $table->bigInteger('user_id')->unsigned()->index(); 23 | // $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 24 | // In Postgresql, foreign key won't create index by default. 25 | }); 26 | } 27 | 28 | public function down(): void 29 | { 30 | Schema::table('posts', function (Blueprint $table) { 31 | $table->dropForeign(['user_id']); 32 | }); 33 | 34 | Schema::drop('posts'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests/Unit 10 | 11 | 12 | tests/Feature 13 | 14 | 15 | tests/Browser 16 | 17 | 18 | 19 | 20 | app 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/Livewire/Forms/CommentForm.php: -------------------------------------------------------------------------------- 1 | '請填寫留言內容', 26 | 'min' => '留言內容至少 5 個字元', 27 | 'max' => '留言內容最多 2000 個字元', 28 | ], 29 | onUpdate: false, 30 | )] 31 | public string $body = ''; 32 | 33 | public function store(): Comment 34 | { 35 | $this->validate(); 36 | 37 | $comment = Comment::create($this->all()); 38 | 39 | $this->reset('body', 'parent_id'); 40 | 41 | return $comment; 42 | } 43 | 44 | public function update(Comment $comment): void 45 | { 46 | $this->post_id = $comment->post_id; 47 | $this->user_id = $comment->user_id; 48 | $this->parent_id = $comment->parent_id; 49 | 50 | $this->validate(); 51 | 52 | $comment->update($this->all()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Feature/Api/WebauthnApiTest.php: -------------------------------------------------------------------------------- 1 | assertStatus(302); 9 | }); 10 | 11 | test('you must log in to get webauthn options', function () { 12 | loginAsUser(); 13 | 14 | get(route('passkeys.register-options')) 15 | ->assertStatus(200) 16 | ->assertJsonStructure([ 17 | 'authenticatorSelection' => [ 18 | 'userVerification', 19 | 'residentKey', 20 | ], 21 | 'challenge', 22 | 'excludeCredentials' => [], 23 | 'pubKeyCredParams' => [], 24 | 'rp' => [ 25 | 'id', 26 | 'name', 27 | ], 28 | 'user' => [ 29 | 'id', 30 | 'name', 31 | 'displayName', 32 | ], 33 | ]); 34 | }); 35 | 36 | it('can get webauthn authentication options', function () { 37 | get(route('passkeys.authentication-options')) 38 | ->assertStatus(200) 39 | ->assertJsonStructure([ 40 | 'allowCredentials' => [], 41 | 'challenge', 42 | 'rpId', 43 | ]); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /resources/ts/markdown-helper.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | tabToFourSpaces: (event: Event) => void; 4 | } 5 | } 6 | 7 | function tabToFourSpaces(event: Event): void { 8 | const markdownEditor = event.target; 9 | 10 | if (!(markdownEditor instanceof HTMLTextAreaElement)) { 11 | return; 12 | } 13 | 14 | const TAB_SPACE = ' '; 15 | const start = markdownEditor.selectionStart; 16 | const end = markdownEditor.selectionEnd; 17 | const value = markdownEditor.value; 18 | 19 | // Find line boundaries for the selection 20 | // If lastIndexOf finds a newline: returns the index of \n, then +1 gives us the character right after it (start of next line) 21 | // If lastIndexOf doesn't find a newline: returns -1, then -1 + 1 = 0 (start of the string, which is the first line) 22 | let lineStart = value.lastIndexOf('\n', start - 1) + 1; 23 | let lineEnd = value.indexOf('\n', end); 24 | if (lineEnd === -1) { 25 | lineEnd = value.length; 26 | } 27 | 28 | const lines = value.substring(lineStart, lineEnd).split('\n'); 29 | const indentedLines = lines.map((line) => TAB_SPACE + line); 30 | 31 | markdownEditor.value = 32 | value.substring(0, lineStart) + 33 | indentedLines.join('\n') + 34 | value.substring(lineEnd); 35 | markdownEditor.selectionStart = start + TAB_SPACE.length; 36 | markdownEditor.selectionEnd = end + TAB_SPACE.length * lines.length; 37 | } 38 | 39 | window.tabToFourSpaces = tabToFourSpaces; 40 | -------------------------------------------------------------------------------- /app/Console/Commands/DeleteTagCommand.php: -------------------------------------------------------------------------------- 1 | $value !== '' 37 | ? Tag::where('name', 'like', "%{$value}%")->pluck('name', 'id')->all() 38 | : [] 39 | ); 40 | 41 | $tag = Tag::find($id, ['id', 'name']); 42 | 43 | $confirmed = confirm( 44 | label: 'Do you want to delete the tag "'.$tag->name.'"?', 45 | ); 46 | 47 | if ($confirmed) { 48 | Tag::destroy($id); 49 | 50 | $this->info('Tag deleted successfully'); 51 | 52 | return self::SUCCESS; 53 | } 54 | 55 | $this->error('Canceled deleting tag'); 56 | 57 | return self::SUCCESS; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /resources/views/components/icons/hand-thumbs-up.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/Console/Commands/DeleteLinkCommand.php: -------------------------------------------------------------------------------- 1 | $value !== '' 37 | ? Link::where('title', 'like', "%{$value}%") 38 | ->orWhere('link', 'like', "%{$value}%") 39 | ->pluck('title', 'id')->all() 40 | : [] 41 | ); 42 | 43 | $link = Link::find($id, ['id', 'title', 'link']); 44 | 45 | $confirmed = confirm( 46 | label: 'Do you want to delete the link "'.$link->title.'"?', 47 | ); 48 | 49 | if ($confirmed) { 50 | Link::destroy($id); 51 | 52 | $this->info('Link deleted successfully'); 53 | 54 | return self::SUCCESS; 55 | } 56 | 57 | $this->error('Canceled deleting link'); 58 | 59 | return self::SUCCESS; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/Policies/PasskeyPolicy.php: -------------------------------------------------------------------------------- 1 | id === $passkey->owner_id && $passkey->owner_type === User::class; 50 | } 51 | 52 | /** 53 | * Determine whether the user can restore the model. 54 | */ 55 | public function restore(User $user, Passkey $passkey): bool 56 | { 57 | return false; 58 | } 59 | 60 | /** 61 | * Determine whether the user can permanently delete the model. 62 | */ 63 | public function forceDelete(User $user, Passkey $passkey): bool 64 | { 65 | return false; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /resources/views/components/icons/rocket-takeoff.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 16 | 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Laravel 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | APP_TIMEZONE=UTC 7 | 8 | APP_LOCALE=en 9 | APP_FALLBACK_LOCALE=en 10 | APP_FAKER_LOCALE=en_US 11 | 12 | APP_MAINTENANCE_DRIVER=file 13 | # APP_MAINTENANCE_STORE=database 14 | 15 | PHP_CLI_SERVER_WORKERS=4 16 | 17 | BCRYPT_ROUNDS=12 18 | 19 | LOG_CHANNEL=stack 20 | LOG_STACK=single 21 | LOG_DEPRECATIONS_CHANNEL=null 22 | LOG_LEVEL=debug 23 | 24 | DB_CONNECTION=sqlite 25 | # DB_HOST=127.0.0.1 26 | # DB_PORT=3306 27 | # DB_DATABASE=laravel 28 | # DB_USERNAME=root 29 | # DB_PASSWORD= 30 | # DB_SSLMODE= 31 | 32 | SESSION_DRIVER=database 33 | SESSION_LIFETIME=120 34 | SESSION_ENCRYPT=false 35 | SESSION_PATH=/ 36 | SESSION_DOMAIN=null 37 | 38 | BROADCAST_CONNECTION=log 39 | FILESYSTEM_DISK=local 40 | QUEUE_CONNECTION=database 41 | 42 | CACHE_STORE=database 43 | # CACHE_PREFIX= 44 | 45 | MEMCACHED_HOST=127.0.0.1 46 | 47 | REDIS_CLIENT=phpredis 48 | REDIS_HOST=127.0.0.1 49 | REDIS_PASSWORD=null 50 | REDIS_PORT=6379 51 | 52 | MAIL_MAILER=log 53 | MAIL_SCHEME=null 54 | MAIL_HOST=127.0.0.1 55 | MAIL_PORT=2525 56 | MAIL_USERNAME=null 57 | MAIL_PASSWORD=null 58 | MAIL_FROM_ADDRESS="hello@example.com" 59 | MAIL_FROM_NAME="${APP_NAME}" 60 | 61 | AWS_ACCESS_KEY_ID= 62 | AWS_SECRET_ACCESS_KEY= 63 | AWS_DEFAULT_REGION=us-east-1 64 | AWS_BUCKET= 65 | AWS_USE_PATH_STYLE_ENDPOINT=false 66 | 67 | VITE_APP_NAME="${APP_NAME}" 68 | 69 | SAIL_XDEBUG_MODE=coverage,develop,debug 70 | 71 | OCTANE_SERVER=swoole 72 | OCTANE_HTTPS=false 73 | 74 | CAPTCHA_SITE_KEY=1x00000000000000000000AA 75 | CAPTCHA_SECRET_KEY=1x0000000000000000000000000000000AA 76 | 77 | SCOUT_QUEUE=false 78 | SCOUT_PREFIX= 79 | ALGOLIA_APP_ID= 80 | ALGOLIA_SECRET= 81 | -------------------------------------------------------------------------------- /resources/views/components/posts/⚡mobile-menu.blade.php: -------------------------------------------------------------------------------- 1 | authorize('destroy', $post); 16 | 17 | $post->withoutTimestamps(fn() => $post->delete()); 18 | 19 | $this->dispatch('toast', status: 'success', message: '成功刪除文章!'); 20 | 21 | $this->redirect( 22 | route('users.show', [ 23 | 'id' => auth()->id(), 24 | 'tab' => 'posts', 25 | 'current-posts-year' => $post->created_at->format('Y'), 26 | ]), 27 | ); 28 | } 29 | }; 30 | ?> 31 | 32 |
33 | 37 | 38 | 編輯 39 | 40 | 41 | 50 |
51 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('name'); 17 | $table->string('email')->unique(); 18 | $table->timestamp('email_verified_at')->nullable(); 19 | $table->string('password'); 20 | $table->rememberToken(); 21 | $table->timestamps(); 22 | }); 23 | 24 | Schema::create('password_reset_tokens', function (Blueprint $table) { 25 | $table->string('email')->primary(); 26 | $table->string('token'); 27 | $table->timestamp('created_at')->nullable(); 28 | }); 29 | 30 | Schema::create('sessions', function (Blueprint $table) { 31 | $table->string('id')->primary(); 32 | $table->foreignId('user_id')->nullable()->index(); 33 | $table->string('ip_address', 45)->nullable(); 34 | $table->text('user_agent')->nullable(); 35 | $table->longText('payload'); 36 | $table->integer('last_activity')->index(); 37 | }); 38 | } 39 | 40 | /** 41 | * Reverse the migrations. 42 | */ 43 | public function down(): void 44 | { 45 | Schema::dropIfExists('users'); 46 | Schema::dropIfExists('password_reset_tokens'); 47 | Schema::dropIfExists('sessions'); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /resources/views/components/icons/controller.blade.php: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | get('user', function (Request $request) { 24 | return $request->user(); 25 | }); 26 | 27 | Route::get('/passkeys/register-options', GeneratePasskeyRegisterOptionsController::class) 28 | ->name('passkeys.register-options') 29 | ->middleware('auth:sanctum'); 30 | 31 | Route::get('/passkeys/authentication-options', GeneratePasskeyAuthenticationOptionsController::class) 32 | ->name('passkeys.authentication-options'); 33 | 34 | // Upload the image to S3 35 | Route::middleware('auth:sanctum') 36 | ->post('images/upload', UploadImageController::class) 37 | ->name('images.store'); 38 | 39 | Route::get('posts', ShowLatestPostController::class)->name('api.posts'); 40 | 41 | Route::get('tags', ShowAllTagsController::class)->name('api.tags'); 42 | 43 | Route::post('oembed/twitter', TwitterOembedController::class); 44 | -------------------------------------------------------------------------------- /resources/views/components/checkbox.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 9 | 14 | 21 | 28 | 29 |
30 |
31 |
32 | 36 |
37 |
38 | -------------------------------------------------------------------------------- /resources/views/layouts/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | {{-- prettier-ignore-start --}} 9 | 10 | 11 | 12 | {{-- CSRF Token --}} 13 | 14 | 15 | {{ $title ?? config('app.name') }} 16 | 17 | {{-- Primary Meta Tags --}} 18 | 19 | 20 | 21 | 22 | {{-- Web Feed --}} 23 | @include('feed::links') 24 | 25 | {{-- Favicon --}} 26 | 27 | 28 | @vite(['resources/ts/app.ts','resources/css/app.css']) 29 | 30 | {{-- Cloudflare Turnstile --}} 31 | 32 | {{-- prettier-ignore-end --}} 33 | 34 | 35 | 36 | {{-- Set theme --}} 37 | 47 | 48 | {{ $slot }} 49 | 50 | @persist('toast') 51 | 52 | @endpersist 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/UploadImageController.php: -------------------------------------------------------------------------------- 1 | all(), [ 33 | 'upload' => [ 34 | 'required', 35 | File::image()->max(1024), 36 | Rule::dimensions()->maxWidth(1200)->maxHeight(1200), 37 | ], 38 | ]); 39 | 40 | if ($validator->fails()) { 41 | return response()->json( 42 | data: [ 43 | 'error' => ['message' => $validator->errors()->first()], 44 | ], 45 | status: 413 46 | ); 47 | } 48 | 49 | $file = $request->file('upload'); 50 | $imageName = $this->fileService->generateFileName($file->getClientOriginalExtension()); 51 | Storage::disk()->put('images/'.$imageName, $file->getContent()); 52 | $url = Storage::disk()->url('images/'.$imageName); 53 | 54 | return response()->json(['url' => $url]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /resources/views/components/icons/php.blade.php: -------------------------------------------------------------------------------- 1 | @props(['pathClassName' => 'fill-black dark:fill-white']) 2 | 3 | 8 | 12 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /resources/ts/scroll-to-top-btn.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | setupScrollToTopButton: Function; 4 | } 5 | } 6 | 7 | // 滾動至網頁最頂部 8 | function scrollToTop(): void { 9 | window.scrollTo({ top: 0, behavior: 'smooth' }); 10 | } 11 | 12 | window.setupScrollToTopButton = function ( 13 | scrollToTopButton: HTMLButtonElement, 14 | ): void { 15 | scrollToTopButton.addEventListener('click', scrollToTop); 16 | 17 | let header = document.getElementById('header'); 18 | let footer = document.getElementById('footer'); 19 | 20 | // 根據 header 是否出現在畫面上調整按鈕的樣式 21 | let headerObserver = new IntersectionObserver( 22 | function (entries) { 23 | if (entries[0].isIntersecting) { 24 | // header 在畫面上 25 | scrollToTopButton.classList.remove('xl:flex'); 26 | } else { 27 | // header 不在畫面上 28 | scrollToTopButton.classList.add('xl:flex'); 29 | } 30 | }, 31 | { threshold: [0] }, 32 | ); 33 | 34 | // 根據 footer 是否出現在畫面上調整按鈕的樣式 35 | let footerObserver = new IntersectionObserver( 36 | function (entries) { 37 | if (entries[0].isIntersecting) { 38 | // footer 在畫面上 39 | scrollToTopButton.classList.remove('fixed', 'bottom-7'); 40 | scrollToTopButton.classList.add('absolute', 'bottom-1'); 41 | } else { 42 | // footer 不在畫面上 43 | scrollToTopButton.classList.add('fixed', 'bottom-7'); 44 | scrollToTopButton.classList.remove('absolute', 'bottom-1'); 45 | } 46 | }, 47 | { threshold: [0] }, 48 | ); 49 | 50 | headerObserver.observe(header); 51 | footerObserver.observe(footer); 52 | }; 53 | -------------------------------------------------------------------------------- /resources/views/components/users/member-center-side-menu.blade.php: -------------------------------------------------------------------------------- 1 | {{-- user edit side men --}} 2 | 3 | 44 | 45 | -------------------------------------------------------------------------------- /database/migrations/2020_07_30_222225_seed_links_data.php: -------------------------------------------------------------------------------- 1 | 'Laravel China 社區', 13 | 'link' => 'https://learnku.com/laravel', 14 | 'created_at' => date('Y-m-d H:i:s'), 15 | 'updated_at' => date('Y-m-d H:i:s'), 16 | ], 17 | [ 18 | 'title' => 'Laravel 6.0 初體驗!', 19 | 'link' => 'https://ithelp.ithome.com.tw/users/20120550/ironman/2575', 20 | 'created_at' => date('Y-m-d H:i:s'), 21 | 'updated_at' => date('Y-m-d H:i:s'), 22 | ], 23 | [ 24 | 'title' => 'Laracasts', 25 | 'link' => 'https://laracasts.com/', 26 | 'created_at' => date('Y-m-d H:i:s'), 27 | 'updated_at' => date('Y-m-d H:i:s'), 28 | ], 29 | [ 30 | 'title' => 'Laravel Business', 31 | 'link' => 'https://www.youtube.com/channel/UCTuplgOBi6tJIlesIboymGA', 32 | 'created_at' => date('Y-m-d H:i:s'), 33 | 'updated_at' => date('Y-m-d H:i:s'), 34 | ], 35 | [ 36 | 'title' => 'Andre Madarang', 37 | 'link' => 'https://www.youtube.com/channel/UCtb40EQj2inp8zuaQlLx3iQ', 38 | 'created_at' => date('Y-m-d H:i:s'), 39 | 'updated_at' => date('Y-m-d H:i:s'), 40 | ], 41 | ]; 42 | 43 | DB::table('links')->insert($links); 44 | } 45 | 46 | public function down(): void 47 | { 48 | DB::table('links')->truncate(); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /resources/ts/progress-bar.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | setupProgressBar: Function; 4 | } 5 | } 6 | 7 | function progressBarAnimation( 8 | section: HTMLElement, 9 | progressBar: HTMLElement, 10 | ): void { 11 | let scrollDistance: number = -section.getBoundingClientRect().top; 12 | let progressWidth: number = 13 | (scrollDistance / 14 | (section.getBoundingClientRect().height - 15 | document.documentElement.clientHeight)) * 16 | 100; 17 | 18 | let value: number = Math.floor(progressWidth); 19 | 20 | progressBar.style.width = value + '%'; 21 | progressBar.ariaValueNow = value.toString(); 22 | 23 | if (value < 0) { 24 | progressBar.style.width = '0%'; 25 | progressBar.ariaValueNow = '0'; 26 | } 27 | 28 | if (value > 100) { 29 | progressBar.style.width = '100%'; 30 | progressBar.ariaValueNow = '100'; 31 | } 32 | } 33 | 34 | window.setupProgressBar = function ( 35 | section: HTMLElement, 36 | progressBar: HTMLElement, 37 | ): void { 38 | if ( 39 | document.documentElement.clientHeight > 40 | section.getBoundingClientRect().height 41 | ) { 42 | progressBar.style.width = '100%'; 43 | progressBar.ariaValueNow = '100'; 44 | 45 | return; 46 | } 47 | 48 | const updateProgressBar = () => progressBarAnimation(section, progressBar); 49 | 50 | window.addEventListener('scroll', updateProgressBar); 51 | 52 | function clearProgressBarEvent() { 53 | window.removeEventListener('scroll', updateProgressBar); 54 | window.removeEventListener( 55 | 'livewire:navigating', 56 | clearProgressBarEvent, 57 | ); 58 | } 59 | 60 | window.addEventListener('livewire:navigating', clearProgressBarEvent); 61 | }; 62 | -------------------------------------------------------------------------------- /database/seeders/PostSeeder.php: -------------------------------------------------------------------------------- 1 | make(ContentService::class); 26 | 27 | for ($i = 1; $i <= self::POST_COUNT; $i++) { 28 | $title = fake()->text(30); 29 | $body = fake()->paragraph(50); 30 | $excerpt = $contentService->getExcerpt($body); 31 | $slug = $contentService->getSlug($title); 32 | 33 | $data[] = [ 34 | 'title' => $title, 35 | 'body' => $body, 36 | 'slug' => $slug, 37 | 'excerpt' => $excerpt, 38 | 'category_id' => fake()->numberBetween(1, 3), 39 | 'user_id' => random_int(1, $userCount), 40 | 'created_at' => fake()->dateTimeBetween(startDate: '-3 years'), 41 | 'updated_at' => now(), 42 | ]; 43 | 44 | if ($i % self::CHUNK === 0) { 45 | yield $data; 46 | 47 | $data = []; 48 | } 49 | } 50 | } 51 | 52 | public function run(): void 53 | { 54 | $userCount = User::query()->count(); 55 | 56 | foreach ($this->postGenerator($userCount) as $data) { 57 | Post::query()->insert($data); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Feature/Api/OembedApiTest.php: -------------------------------------------------------------------------------- 1 | 'https://twitter.com/TwitterDev/status/1603823063690199040', 10 | 'author_name' => 'Twitter Dev', 11 | 'author_url' => 'https://twitter.com/TwitterDev', 12 | 'html' => "

Testing… Testing… Twitter Dev is back online! 📡

It’s been a time for change here at Twitter Dev but we have an important message to share with you. 🧵

— Twitter Dev (@TwitterDev) December 16, 2022
\n", 13 | 'width' => 550, 14 | 'height' => null, 15 | 'type' => 'rich', 16 | 'cache_age' => '3153600000', 17 | 'provider_name' => 'Twitter', 18 | 'provider_url' => 'https://twitter.com', 19 | 'version' => '1.0', 20 | ]; 21 | 22 | Http::fake(['https://publish.twitter.com/oembed*' => Http::response($response)]); 23 | 24 | postJson('/api/oembed/twitter', [ 25 | 'url' => 'https://twitter.com/TwitterDev/status/1603823063690199040', 26 | 'theme' => 'dark', 27 | ]) 28 | ->assertStatus(200) 29 | ->assertJson($response); 30 | }); 31 | 32 | test('if the embedded twitter link is an invalid link, return the alternative html content', function () { 33 | Http::fake(['https://publish.twitter.com/oembed*' => Http::response('Not Found', 404)]); 34 | 35 | postJson('/api/oembed/twitter', ['url' => 'https://twitter.com/TwitterDev/status/123456789', 'theme' => 'dark']) 36 | ->assertStatus(400) 37 | ->assertJson(['html' => '

Twitter 連結發生錯誤... 🥲

']); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/Feature/Comments/DeleteCommentTest.php: -------------------------------------------------------------------------------- 1 | create(); 9 | 10 | Livewire::actingAs(User::find($comment->user_id)); 11 | 12 | Livewire::test('comments.list', [ 13 | 'postId' => $comment->post_id, 14 | 'postUserId' => $comment->post->user_id, 15 | ]) 16 | ->call('destroyComment', id: $comment->id) 17 | ->assertDispatched('toast', 18 | status: 'success', 19 | message: '成功刪除留言!', 20 | ); 21 | 22 | $this->assertDatabaseMissing('comments', ['id' => $comment->id]); 23 | }); 24 | 25 | test('post author can delete other users comment', function () { 26 | $comment = Comment::factory()->create(); 27 | 28 | Livewire::actingAs(User::find($comment->post->user_id)); 29 | 30 | Livewire::test('comments.list', [ 31 | 'postId' => $comment->post_id, 32 | 'postUserId' => $comment->post->user_id, 33 | ]) 34 | ->call('destroyComment', id: $comment->id) 35 | ->assertDispatched('toast', 36 | status: 'success', 37 | message: '成功刪除留言!', 38 | ); 39 | 40 | $this->assertDatabaseMissing('comments', ['id' => $comment->id]); 41 | }); 42 | 43 | it('will show alert when user want to delete the deleted comment', function () { 44 | $comment = Comment::factory()->create(); 45 | $commentId = $comment->id; 46 | $postId = $comment->post_id; 47 | $postAuthorId = $comment->post->user_id; 48 | 49 | $comment->delete(); 50 | 51 | Livewire::test('comments.list', [ 52 | 'postId' => $postId, 53 | 'postUserId' => $postAuthorId, 54 | ]) 55 | ->call('destroyComment', id: $commentId) 56 | ->assertDispatched('toast', status: 'danger', message: '該留言已被刪除!'); 57 | }); 58 | -------------------------------------------------------------------------------- /database/migrations/2025_09_01_110048_convert_passkeys_to_polymorphic_relationship.php: -------------------------------------------------------------------------------- 1 | dropForeign(['user_id']); 18 | 19 | // Rename user_id to owner_id 20 | $table->renameColumn('user_id', 'owner_id'); 21 | 22 | // Add an owner_type column 23 | $table->string('owner_type')->nullable()->after('owner_id'); 24 | }); 25 | 26 | // Set all existing records to a User type 27 | DB::table('passkeys')->update([ 28 | 'owner_type' => 'App\Models\User', 29 | ]); 30 | 31 | // Make owner_type non-nullable 32 | Schema::table('passkeys', function (Blueprint $table) { 33 | $table->string('owner_type') 34 | ->nullable(false) 35 | ->change(); 36 | }); 37 | } 38 | 39 | /** 40 | * Reverse the migrations. 41 | */ 42 | public function down(): void 43 | { 44 | Schema::table('passkeys', function (Blueprint $table) { 45 | // Drop the owner_type column 46 | $table->dropColumn('owner_type'); 47 | 48 | // Rename owner_id back to user_id 49 | $table->renameColumn('owner_id', 'user_id'); 50 | 51 | // Re-add the foreign key constraint 52 | $table->foreign('user_id') 53 | ->references('id') 54 | ->on('users') 55 | ->onDelete('cascade'); 56 | }); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /resources/ts/oembed/embed-youtube-oembed.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | processYoutubeOembeds: Function; 4 | } 5 | } 6 | 7 | // 定義一個函式來處理 oembed 轉換 8 | async function convertOembedToIframe(oembedElement: HTMLElement) { 9 | const screenWidth: number = window.screen.width; 10 | 11 | let maxWidth: number = 640; 12 | let maxHeight: number = 360; 13 | 14 | if (screenWidth <= 425) { 15 | maxWidth = 320; 16 | maxHeight = 180; 17 | } 18 | 19 | const url = oembedElement.getAttribute('url'); 20 | 21 | if (!url || !isYouTubeUrl(url)) { 22 | return; 23 | } 24 | 25 | let oembedApiUrl = `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent( 26 | url, 27 | )}`; 28 | oembedApiUrl += `&maxwidth=${maxWidth}&maxheight=${maxHeight}`; 29 | 30 | let response = await fetch(oembedApiUrl); 31 | let data = await response.json(); 32 | 33 | if (data.html) { 34 | oembedElement.insertAdjacentHTML('afterend', data.html); 35 | // 標記為已處理,在 SPA 應用中,避免重複處理 36 | oembedElement.classList.add('oembed-processed'); 37 | } 38 | } 39 | 40 | // 定義一個函式來檢查是否為 YouTube 連結 41 | function isYouTubeUrl(url: string): boolean { 42 | return ( 43 | /^https?:\/\/(www\.)?youtube\.com\/watch\?v=/.test(url) || 44 | /^https?:\/\/youtu\.be\//.test(url) 45 | ); 46 | } 47 | 48 | // 主要處理函式 49 | window.processYoutubeOembeds = function () { 50 | const oembedElements: NodeListOf = document.querySelectorAll( 51 | 'oembed:not(.oembed-processed)', 52 | ); 53 | 54 | oembedElements.forEach((oembedElement) => { 55 | const figureElement = oembedElement.closest('figure.media'); 56 | 57 | if (figureElement) { 58 | convertOembedToIframe(oembedElement).catch((error) => { 59 | console.error(error); 60 | }); 61 | } 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /app/Console/Commands/CreateTagCommand.php: -------------------------------------------------------------------------------- 1 | match (true) { 40 | strlen($value) < 3 => 'The name must be at least 3 characters.', 41 | strlen($value) > 50 => 'The name must not exceed 50 characters.', 42 | default => null 43 | } 44 | ); 45 | 46 | $name = Str::of($name)->ucfirst()->trim(); 47 | 48 | if (Tag::where('name', $name)->exists()) { 49 | $this->error('Tag "'.$name.'" already exists'); 50 | 51 | return self::FAILURE; 52 | } 53 | 54 | $confirmed = confirm( 55 | label: 'Do you want to create a tag with the name "'.$name.'"?', 56 | ); 57 | 58 | if ($confirmed) { 59 | Tag::create(['name' => $name]); 60 | $this->info('Tag created successfully'); 61 | 62 | return self::SUCCESS; 63 | } 64 | 65 | $this->error('Tag creation canceled'); 66 | 67 | return self::SUCCESS; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /resources/views/components/icons/typescript.blade.php: -------------------------------------------------------------------------------- 1 | @props(['rectClassName' => 'fill-[#3178c6]', 'pathClassName' => 'fill-white']) 2 | 3 | 9 | 15 | 21 | 22 | -------------------------------------------------------------------------------- /app/Services/Serializer.php: -------------------------------------------------------------------------------- 1 | create(); 23 | 24 | return new self($serializer); 25 | } 26 | 27 | public function __construct( 28 | protected SerializerInterface|NormalizerInterface $serializer, 29 | ) {} 30 | 31 | /** 32 | * @throws ExceptionInterface 33 | */ 34 | public function toJson(mixed $value): string 35 | { 36 | return $this->serializer->serialize( 37 | $value, 38 | 'json', 39 | [ 40 | AbstractObjectNormalizer::SKIP_NULL_VALUES => true, // Highly recommended! 41 | JsonEncode::OPTIONS => JSON_THROW_ON_ERROR, // Optional 42 | ] 43 | ); 44 | } 45 | 46 | /** 47 | * @throws ExceptionInterface 48 | */ 49 | public function fromJson(string $value, string $desiredClass) 50 | { 51 | return $this 52 | ->serializer 53 | ->deserialize($value, $desiredClass, 'json'); 54 | } 55 | 56 | /** 57 | * @throws ExceptionInterface 58 | */ 59 | public function toArray(mixed $value): array 60 | { 61 | return $this->serializer->normalize($value, 'json'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Unit/Trait/MarkdownConverterTest.php: -------------------------------------------------------------------------------- 1 | convertToHtml($body); 24 | 25 | expect($convertedBody) 26 | ->toContain('

Header 1

') 27 | ->not->toContain('

Header 1

') 28 | ->toContain('

Header 2

') 29 | ->not->toContain('

Header 2

') 30 | ->toContain('

Header 3

') 31 | ->not->toContain('

Header 3

') 32 | ->toContain('

Header 4

') 33 | ->not->toContain('

Header 4

') 34 | ->toContain('

Header 5

') 35 | ->not->toContain('
Header 5
') 36 | ->toContain('

Header 6

') 37 | ->not->toContain('
Header 6
'); 38 | }); 39 | 40 | it('can convert the markdown content to html', function () { 41 | $trait = new class 42 | { 43 | use MarkdownConverter; 44 | }; 45 | 46 | $body = <<<'MARKDOWN' 47 | # Title 48 | 49 | This is a bold **text** 50 | 51 | This a italic *text* 52 | 53 | Show a list 54 | 55 | - item 1 56 | - item 2 57 | - item 3 58 | MARKDOWN; 59 | 60 | $convertedBody = $trait->convertToHtml($body); 61 | 62 | expect($convertedBody) 63 | ->toContain('

Title

') 64 | ->toContain('

This is a bold text

') 65 | ->toContain('

This a italic text

') 66 | ->toContain('

Show a list

') 67 | ->toContain('
    ') 68 | ->toContain('
  • item 1
  • ') 69 | ->toContain('
  • item 2
  • ') 70 | ->toContain('
  • item 3
  • ') 71 | ->toContain('
'); 72 | }); 73 | -------------------------------------------------------------------------------- /config/feed.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'main' => [ 6 | /* 7 | * Here you can specify which class and method will return 8 | * the items that should appear in the feed. For example: 9 | * [App\Model::class, 'getAllFeedItems'] 10 | * 11 | * You can also pass an argument to that method. Note that their key must be the name of the parameter: 12 | * [App\Model::class, 'getAllFeedItems', 'parameterName' => 'argument'] 13 | */ 14 | 'items' => [App\Models\Post::class, 'getFeedItems'], 15 | 16 | /* 17 | * The feed will be available on this url. 18 | */ 19 | 'url' => '/post/feed', 20 | 21 | 'title' => 'DocFunc', 22 | 'description' => '紀錄生活上的大小事!', 23 | 'language' => 'zh-TW', 24 | 25 | /* 26 | * The image to display for the feed. For Atom feeds, this is displayed as 27 | * a banner/logo; for RSS and JSON feeds, it's displayed as an icon. 28 | * An empty value omits the image attribute from the feed. 29 | */ 30 | 'image' => env('ASSET_URL').'/images/icon/logo.svg', 31 | 32 | /* 33 | * The format of the feed. Acceptable values are 'rss', 'atom', or 'json'. 34 | */ 35 | 'format' => 'json', 36 | 37 | /* 38 | * The view that will render the feed. 39 | */ 40 | 'view' => 'feed::json', 41 | 42 | /* 43 | * The mime type to be used in the tag. Set to an empty string to automatically 44 | * determine the correct value. 45 | */ 46 | 'type' => '', 47 | 48 | /* 49 | * The content type for the feed response. Set to an empty string to automatically 50 | * determine the correct value. 51 | */ 52 | 'contentType' => '', 53 | ], 54 | ], 55 | ]; 56 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | extend(Tests\TestCase::class) 17 | ->in('Feature', 'Browser'); 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Expectations 22 | |-------------------------------------------------------------------------- 23 | | 24 | | When you're writing tests, you often need to check that values meet certain conditions. The 25 | | "expect()" function gives you access to a set of "expectations" methods that you can use 26 | | to assert different things. Of course, you may extend the Expectation API at any time. 27 | | 28 | */ 29 | 30 | // A brief example of extending the Expectation API 31 | expect()->extend('toBePositive', function () { 32 | return $this->toBeGreaterThan(0); 33 | }); 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Functions 38 | |-------------------------------------------------------------------------- 39 | | 40 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 41 | | project that you don't want to repeat in every file. Here you can also expose helpers as 42 | | global functions to help you to reduce the number of lines of code in your test files. 43 | | 44 | */ 45 | 46 | function loginAsUser(null|User|int $user = null) 47 | { 48 | if (is_int($user)) { 49 | $user = User::find($user); 50 | } 51 | 52 | if (is_null($user)) { 53 | $user = User::factory()->create(); 54 | } 55 | 56 | test()->actingAs($user); 57 | 58 | return $user; 59 | } 60 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000002_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('queue')->index(); 17 | $table->longText('payload'); 18 | $table->unsignedTinyInteger('attempts'); 19 | $table->unsignedInteger('reserved_at')->nullable(); 20 | $table->unsignedInteger('available_at'); 21 | $table->unsignedInteger('created_at'); 22 | }); 23 | 24 | Schema::create('job_batches', function (Blueprint $table) { 25 | $table->string('id')->primary(); 26 | $table->string('name'); 27 | $table->integer('total_jobs'); 28 | $table->integer('pending_jobs'); 29 | $table->integer('failed_jobs'); 30 | $table->longText('failed_job_ids'); 31 | $table->mediumText('options')->nullable(); 32 | $table->integer('cancelled_at')->nullable(); 33 | $table->integer('created_at'); 34 | $table->integer('finished_at')->nullable(); 35 | }); 36 | 37 | Schema::create('failed_jobs', function (Blueprint $table) { 38 | $table->id(); 39 | $table->string('uuid')->unique(); 40 | $table->text('connection'); 41 | $table->text('queue'); 42 | $table->longText('payload'); 43 | $table->longText('exception'); 44 | $table->timestamp('failed_at')->useCurrent(); 45 | }); 46 | } 47 | 48 | /** 49 | * Reverse the migrations. 50 | */ 51 | public function down(): void 52 | { 53 | Schema::dropIfExists('jobs'); 54 | Schema::dropIfExists('job_batches'); 55 | Schema::dropIfExists('failed_jobs'); 56 | } 57 | }; 58 | --------------------------------------------------------------------------------