├── 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/HEAD/public/images/icon/search-by-algolia-dark-background.png
--------------------------------------------------------------------------------
/public/images/icon/search-by-algolia-light-background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yilanboy/docfunc/HEAD/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 | merge(['class' => 'flex w-full items-center rounded-md px-4 py-2 hover:bg-zinc-200 dark:hover:bg-zinc-700 cursor-pointer', 'type' => 'button', 'role' => 'menuitem']) }}
3 | >
4 | {{ $slot }}
5 |
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 | merge([
3 | 'class' =>
4 | 'relative z-0 inline-grid w-full select-none grid-cols-3 items-center justify-center gap-1 rounded-xl bg-zinc-300/50 p-1 text-zinc-500 dark:bg-zinc-500/30 dark:text-zinc-50',
5 | ]) }}
6 | >
7 | {{ $slot }}
8 |
9 |
--------------------------------------------------------------------------------
/resources/views/components/icons/clock.blade.php:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/resources/views/components/tabs/button.blade.php:
--------------------------------------------------------------------------------
1 | merge([
4 | 'class' =>
5 | 'relative z-20 inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium',
6 | ]) }}
7 | >
8 | {{ $slot }}
9 |
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 | merge([
3 | 'class' =>
4 | 'dark:bg-lividus-600 fixed bottom-7 right-7 z-10 hidden h-16 w-16 cursor-pointer rounded-full bg-emerald-500 text-zinc-50 transition duration-150 ease-in hover:scale-110',
5 | 'id' => 'scroll-to-top-btn',
6 | 'type' => 'button',
7 | 'title' => 'Go to top',
8 | ]) }}
9 | >
10 |
11 |
12 |
13 |
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 | merge([
3 | 'type' => 'submit',
4 | 'class' =>
5 | 'focus:outline-hidden focus:ring-3 dark:bg-lividus-600 dark:ring-lividus-400 dark:hover:bg-lividus-500 dark:focus:border-lividus-700 dark:active:bg-lividus-600 inline-flex cursor-pointer items-center justify-center rounded-xl border border-transparent bg-emerald-600 px-4 py-2 uppercase tracking-widest text-zinc-50 ring-emerald-300 transition duration-150 ease-in-out hover:bg-emerald-700 focus:border-emerald-700 active:bg-emerald-600 disabled:opacity-25',
6 | ]) }}
7 | >
8 | {{ $slot }}
9 |
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 |
27 | {{-- 文章列表側邊欄 --}}
28 |
29 |
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 |
5 |
6 |
11 |
12 | {{-- background --}}
13 |
16 |
17 |
18 | {{-- dot --}}
19 |
22 |
23 |
24 |
25 | {{ $slot }}
26 |
27 |
28 |
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 |
36 | {{-- 文章列表側邊欄 --}}
37 |
38 |
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 |
{{ $label }}
6 |
7 |
8 |
12 | {{ $slot }}
13 |
14 |
21 |
26 |
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 |
11 | {{ $attributes->get('placeholder') }}
12 |
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 |
11 | {{ $attributes->get('placeholder') }}
12 |
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 |
41 | {{-- 文章列表側邊欄 --}}
42 |
43 |
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 |
4 |
5 | {{-- character count --}}
6 |
10 |
11 |
12 |
13 | {{-- save button --}}
14 |
20 |
24 |
25 |
26 |
27 |
31 |
32 |
33 |
34 |
35 |
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 |
47 |
48 | 刪除
49 |
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 | {{ $slot }}
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' => "\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 |
--------------------------------------------------------------------------------