67 Commits

Author SHA1 Message Date
Yutaka Kurosaki 5c338e3ae5 Native locale_names for 14 locales + drop forced-en in editor tabs
Translates the 16-language locale_names block in zh-CN, zh-TW, ko, hi,
vi, tr, de, fr, es, pt-BR, ru, uk, it, pl to the target locale's own
language (e.g. de file: 'en' => 'Englisch', 'ja' => 'Japanisch').

DocumentEditor blade no longer hardcodes 'en' as the locale_names
lookup — falls back to the current UI locale. Test still passes
because tests run with default app locale 'en' and the en file
maps to "Japanese" / "English" etc.

A user editing in ja now sees [English] [日本語 ★] tabs instead of
[English] [Japanese ★].
2026-05-10 13:13:25 +09:00
Yutaka Kurosaki b90e3534ce Native UI translations for translation/edit-flow keys (14 locales)
Translates 7 newly-added documents.* keys (translation_added,
translation_deleted, add_translation, set_as_default, delete_translation,
delete_translation_blocked, translation_tabs_label) from English mirror
to native equivalents for zh-CN, zh-TW, ko, hi, vi, tr, de, fr, es,
pt-BR, ru, uk, it, pl. en/ja already had natives.

locale_names labels still mirror English for those 14 — separate follow-up
since the editor tab labels currently force English lookup for test reasons.
2026-05-10 13:10:03 +09:00
Yutaka Kurosaki 85a3a5a422 Translate fallback_notice to native for 14 remaining locales
Replaces the English mirror with native-language equivalents of the
ja string ("この記事には選択した言語の翻訳がありません。元の言語版を表示しています。")
for zh-CN, zh-TW, ko, hi, vi, tr, de, fr, es, pt-BR, ru, uk, it, pl.

Matches the no-:locale-placeholder style already used in ja so the
banner reads naturally in each UI language. en still uses the
parameterized ":locale" version since it's the master template.
2026-05-10 13:06:59 +09:00
Yutaka Kurosaki 1ce1fa23a4 Add documents.delete_translation lang key for editor button
The editor's delete-translation button used `__('messages.documents.delete_translation') ?? __('messages.documents.delete')`, but `__()` returns the key string (not null) on miss so the `??` fallback never fires — the button rendered the literal key. Adds the missing key to all 16 locales (en+ja human-translated, others mirror en) and simplifies the blade to a single `__()` call.

Plan doc also reflects the SQLite dropIndex requirement found during Task 2.
2026-05-10 12:50:57 +09:00
Yutaka Kurosaki 0c13ad1e64 Update DocumentSeeder to use DocumentService::createDocument
Removes direct Document::create() calls that referenced the dropped
title/content/rendered_html columns. Initial seed now creates the
default-locale translation through the service.
2026-05-10 12:45:06 +09:00
Yutaka Kurosaki c9586612f5 Make QuickSwitcher search across all locales
Delegates to DocumentService::search which queries DocumentTranslation
and collapses to distinct documents. Display titles use the Document
title accessor (current locale + fallback).
2026-05-10 12:43:01 +09:00
Yutaka Kurosaki 0100a0afb4 Add locale tabs to DocumentEditor
Editor accepts a locale URL parameter, loads the corresponding
translation (or empty form for new locales), and exposes
addTranslation/setDefaultLocale/deleteTranslation actions. Tab bar
shows existing locales with default-locale star and a + dropdown
for missing locales.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:40:54 +09:00
Yutaka Kurosaki 97171960bd Show fallback banner when current-locale translation is missing
DocumentViewer computes viewLocale and isFallback at mount; banner
links authenticated owners to the editor for the current UI locale.
Adds documents.fallback_notice + locale_names to all 16 lang files
(en+ja human-translated, others mirror en for now).
2026-05-10 12:36:25 +09:00
Yutaka Kurosaki 187349521d Add translation CRUD routes and controller
POST/DELETE for translations gated by can:update,document middleware.
Locale validated against SUPPORTED_LOCALES. Default-locale deletion
returns 422; duplicate-locale add returns 422. Flash messages added
to en/ja lang files (other locales updated in Task 9).
2026-05-10 12:28:25 +09:00
Yutaka Kurosaki 6d71f5fecf Re-implement syncLinks and processLinks via WikiLinkResolver
syncLinks parses the default-locale content; processLinks resolves
each [[link]] against the current locale at render time. Link labels
preserve original spelling; destination resolves to the same document
in the current locale (with fallback).
2026-05-10 12:25:19 +09:00
Yutaka Kurosaki 7909c33074 Make DocumentService locale-aware
createDocument/updateDocument now accept a \$locale parameter and
write to document_translations. Adds addTranslation, deleteTranslation,
setDefaultLocale (with path/slug regen), distinct-document search,
and findByTitle that delegates to WikiLinkResolver.
2026-05-10 12:23:14 +09:00
Yutaka Kurosaki d7522f592d Add WikiLinkResolver with deterministic 5-step resolution
Prefers current locale, then document default_locale, then any locale
(lowest document_id), then slug match (legacy).
2026-05-10 12:19:57 +09:00
Yutaka Kurosaki 0c399c9f0f Refactor Document to read title/content via translations
Adds translations/defaultTranslation relations, current-locale accessors
with fallback to default_locale, isFallback/availableLocales helpers,
and search scope that delegates to DocumentTranslation.
2026-05-10 12:17:31 +09:00
Yutaka Kurosaki b7a70f74e5 Add DocumentTranslation model with renderMarkdown and search scope
Includes a minimal Document::translations() HasMany relation so that
DocumentFactory's afterCreating callback (which calls
$document->translations()->count()) works. The full Document model
refactor (accessors, fallback helpers, default-translation accessor)
lands in Task 4.
2026-05-10 12:14:24 +09:00
Yutaka Kurosaki 4a8622c385 Harden migration: transaction, chunking, lossy-down doc, data-preservation test
- Wrap the data copy in DB::transaction (FULLTEXT ALTER stays outside)
- Switch to chunkById(500) so the migration scales
- Document down() as irreversible for non-default-locale translations
- Add test_existing_documents_data_is_copied_to_translations to cover
  the data copy itself (the only previously-untested behavior)
- Drop unused Migrator import in DocumentMigrationTest
- Also restore title index in down() so up() can be re-run cleanly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:11:31 +09:00
Yutaka Kurosaki f2bdb6a069 Add document_translations table and migrate existing data
documents.{title,content,rendered_html} move to document_translations
keyed by (document_id, locale). Existing rows are copied to a single
translation in config('app.locale'). documents gains default_locale.

Also guard the original FULLTEXT ALTER TABLE with a MySQL driver check
so that the SQLite test environment can run all migrations cleanly.
2026-05-10 12:04:05 +09:00
Yutaka Kurosaki e83bd6981d Fix DocumentFactory withoutTranslations + path trailing period
afterCreating appends rather than replaces, so a no-op closure does not
override configure(). Use withoutAfterCreating() to actually clear the
translation-creation callback (otherwise DocumentTranslation::factory()
recurses through Document::factory()->withoutTranslations()).

Also use words() instead of sentence() to avoid Faker's trailing period
producing paths like "Foo bar..md".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:58:41 +09:00
Yutaka Kurosaki 7f2f8a2248 Add Document and DocumentTranslation factories 2026-05-10 11:53:33 +09:00
Yutaka Kurosaki ab846b71b2 Add implementation plan for article-level i18n
12 TDD tasks: factories, migration with data move, DocumentTranslation
model, Document refactor with locale-aware accessors, WikiLinkResolver,
DocumentService rewrite, syncLinks/processLinks via resolver, translation
CRUD routes/controller, viewer fallback banner, editor locale tabs,
QuickSwitcher cross-locale search, and seeder cleanup. Each task includes
exact file paths, failing tests, minimal implementation, and a commit step.
2026-05-10 10:10:42 +09:00
Yutaka Kurosaki 01fb8b9fcf Add design spec for article-level i18n with default-locale fallback
Documents the data model (1 doc + document_translations table), service
layer changes, routing/editor UX, wiki-link resolution order, search,
and migration plan. Article URLs stay locale-independent; display falls
back to each document's default_locale when the requested locale lacks
a translation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:46:29 +09:00
Yutaka Kurosaki ba25b544f5 Add implementation plan for admin media manager
Eleven TDD tasks from migration through editor integration: model,
service (store/rename/delete + auto-suffix), admin CRUD controller,
real Blade UI, auth-gated download, listener Phase-1 logical-path
rewrite for Image+Link nodes, and ImageUploadController rewiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:54:54 +09:00
Yutaka Kurosaki 3c185fac37 Add design spec for admin media manager
Defines logical/physical filename split, MD reference resolution policy,
component structure, data flow, and test coverage for the new
upload/rename/download/delete flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:42:18 +09:00
Yutaka Kurosaki b924564c22 Upgrade to Laravel 13
Bump laravel/framework ^12.0 → ^13.0, laravel/tinker ^2.10 → ^3.0,
phpunit/phpunit ^11.5 → ^12.0, and php ^8.2 → ^8.3 (Laravel 13
minimum). No app code changes required: codebase has no
VerifyCsrfToken, JobAttempted/QueueBusy listeners, custom
Manager::extend, custom queue drivers, or model boot()
instantiation that the v13 breaking changes touch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:12 +09:00
Yutaka Kurosaki def78d4754 Address final review: Vimeo regex boundary + spec accuracy
- Vimeo regex now rejects URLs like vimeo.com/123abc that were
  silently truncated to ID 123 and produced broken iframes. Negative
  lookahead (?![A-Za-z0-9]) ensures the captured digits are not
  followed by alphanumerics. Two false-positive test cases added.
- Spec corrected: HtmlInline nodes ARE filtered regardless of
  insertion path; the implementation uses a dedicated MediaEmbedNode
  + renderer to bypass the filter only for trusted programmatic embeds.
  Components list updated to include the two extra files.
- Plan Task 6 regex updated for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:18:26 +09:00
Yutaka Kurosaki 81efac4a53 Add integration tests for mixed media in Markdown rendering
Covers image+video coexistence, multiple videos in one paragraph,
videos inside list items, wiki link non-interference, YouTube
timestamps end-to-end, and audio rendering through renderMarkdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:08:06 +09:00
Yutaka Kurosaki f26b930b5f Wire MediaEmbedExtension into Document::renderMarkdown
The extension registers a DocumentParsedEvent listener that walks the
AST, finds Image nodes whose URLs match media patterns (via
MediaUrlResolver), and replaces them with MediaEmbedNode instances
containing the appropriate <video>/<audio>/<iframe> markup.

A custom MediaEmbedNode + MediaEmbedNodeRenderer pair bypasses the
html_input filter (which would strip raw HTML when set to 'strip'),
allowing programmatically generated embed HTML to pass through safely
while user-authored raw HTML remains stripped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:03:09 +09:00
Yutaka Kurosaki 6ee4dcfc21 Detect Vimeo URLs and emit iframe with dnt=1
Recognizes vimeo.com/{id} and player.vimeo.com/video/{id}. Preserves
timestamps from #t=30s and ?t=30s as #t=30s on the embed URL (Vimeo
convention).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:57:05 +09:00
Yutaka Kurosaki 9486d97c73 Normalize YouTube timestamp parameters to ?start=N
Accepts ?t=30s, ?t=30, ?t=1m20s, ?t=1h2m3s, and ?start=N. Converts to
seconds and emits as ?start=N on the embed URL. ?t= takes priority over
?start= when both are present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:52:49 +09:00
Yutaka Kurosaki 6debaf93bc Fix regex delimiter in plan Task 5
Task 4 implementer discovered that # delimiter conflicts with literal #
inside [/?#] and [&#] character classes (PHP PCRE terminates the regex
early). Same patterns repeat in Task 5; pre-update so a re-execution
does not hit the same bug. Vimeo regex in Task 6 is unaffected (no
literal # in pattern body).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:51:48 +09:00
Yutaka Kurosaki 5b6e344ee9 Detect YouTube URLs and emit privacy-enhanced iframe
Recognizes youtu.be, watch?v=, shorts, embed, and mobile variants.
Emits an iframe pointing to youtube-nocookie.com with lazy loading,
strict-origin referrer policy, and allowfullscreen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:47:46 +09:00
Yutaka Kurosaki bb9843fd47 Detect local audio URLs in MediaUrlResolver
Recognizes mp3/wav/ogg/m4a and emits <audio controls class="kb-audio">.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:44:35 +09:00
Yutaka Kurosaki 7e445eb2fe Detect local video URLs in MediaUrlResolver
Recognizes mp4/webm/ogv/mov/m4v on URL path (case-insensitive, ignoring
query strings) and emits <video controls class="kb-video">.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:41:29 +09:00
Yutaka Kurosaki 6daa001388 Scaffold MediaUrlResolver with null fallback
Initial skeleton returning null for any non-media URL. Subsequent commits
add detection for video, audio, YouTube, and Vimeo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:38:19 +09:00
Yutaka Kurosaki 1563aff964 Add implementation plan for Markdown media embed
Plan breaks the work into 9 TDD tasks: scaffold resolver, video
detection, audio detection, YouTube URL detection, YouTube timestamps,
Vimeo detection, listener+extension wiring, integration tests, full
test suite verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:25:08 +09:00
Yutaka Kurosaki 692f4d5492 Restrict document edit/delete to owners and close public registration
Adds DocumentPolicy gating update/delete to the creator (admins bypass via
before()), invokes $this->authorize() in DocumentEditor mount/save/delete,
applies can:update,document on the edit route, hides the edit button for
non-owners, and removes the open /register routes so accounts must be
provisioned via the admin panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:22:18 +09:00
Yutaka Kurosaki 01a11328ec Add design spec for Markdown media embed extension
Approved design for extending image syntax `![](url)` to render videos,
audio, YouTube, and Vimeo embeds. Preserves html_input=>strip safety and
existing image/Wiki-link behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:21:10 +09:00
Yutaka Kurosaki 85f67871fa Update dependencies to fix security vulnerabilities
- npm audit fix: resolves axios, postcss, vite, rollup, picomatch, follow-redirects advisories
- composer update: refreshes PHP dependencies

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:18:38 +09:00
y963admin 80deff661d Add image upload support to document editor
- Create ImageUploadController to handle image uploads
- Store images in storage/app/public/images with UUID filenames
- Integrate with EasyMDE editor for drag-drop, paste, and toolbar upload
- Use original filename as alt text in markdown

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 20:08:40 +09:00
y963admin 8ea8b3f6b6 Improve current document highlighting with better URL matching
- Add removal of previous highlighting before applying new one
- Support multiple URL matching strategies (exact, full URL, ends with)
- Add debug logging to troubleshoot highlighting issues
- Reset all links before applying highlight to ensure clean state
2025-12-04 02:45:24 +09:00
y963admin d52968e697 Add current document highlighting in sidebar navigation
- Highlight active document with indigo background and bold font
- Change icon color to indigo for active document
- Use JavaScript to match current URL path with sidebar links
- Update highlighting on page load and Alpine navigation events
- Active document is visually distinct from other items
2025-12-04 02:42:20 +09:00
y963admin bed7137e43 Remove debug console logs from sidebar scroll preservation
- Remove all console.log statements
- Clean up code for production use
- Preserve scroll position functionality without debug output
2025-12-04 02:37:07 +09:00
y963admin 028e0b11c7 Fix sidebar scroll preservation by correcting link selector
- Change selector from 'a[x-navigate]' to 'a' (no x-navigate attribute)
- Remove sessionStorage.removeItem to prevent clearing saved scroll position
- Add '0' check to prevent restoring scroll position to top
- Add debug logging for troubleshooting
- Now works correctly in all browsers including Chrome
2025-12-04 02:35:29 +09:00
y963admin 5bf43abab9 Remove x-navigate directive, use native HTML links with scroll preservation
- Remove x-navigate.preserve-scroll from sidebar links
- Use standard <a> tags for navigation
- Implement manual JavaScript-based scroll position management
- Save scroll position to sessionStorage before navigation
- Restore scroll position after page load
- Works consistently across all browsers including Chrome
2025-12-04 02:30:48 +09:00
y963admin f96ad4d14f Use manual scroll position management instead of x-navigate.preserve-scroll
- Add JavaScript to save sidebar scroll position before navigation
- Restore scroll position after page load using sessionStorage
- Works consistently in Chrome and other browsers
- Handles both DOMContentLoaded and window load events
- Compatible with Alpine navigate and standard navigation
2025-12-04 02:24:00 +09:00
y963admin a4aff43091 Add preserve-scroll modifier to x-navigate directive
- Use x-navigate.preserve-scroll to maintain sidebar scroll position during navigation
- Prevents page from scrolling to top after clicking sidebar links
- Alpine navigate automatically saves and restores scroll position
2025-12-04 02:17:13 +09:00
y963admin 1e20982e00 Simplify sidebar scroll preservation using only x-navigate directive
- Remove custom sessionStorage scroll management logic
- Rely solely on x-navigate directive from Alpine for scroll preservation
- x-navigate handles automatic scroll position saving and restoring
- Cleaner and simpler implementation
- Keep x-navigate directive on all sidebar links
2025-12-04 02:13:28 +09:00
y963admin ec7aaf44a9 Fix sidebar scroll preservation per page with x-navigate directive
- Add x-navigate directive to all sidebar document links for Alpine navigation
- Store scroll position per page using URL path as key in sessionStorage
- Each page now maintains its own scroll position in the sidebar
- Save scroll position before navigation and restore after navigation
- Scroll position is preserved when clicking links in the sidebar
- Works correctly with Alpine navigate events triggered by x-navigate directive
2025-12-04 02:11:06 +09:00
y963admin 00a5951654 Improve sidebar scroll position preservation with sessionStorage fallback
- Replace localStorage with sessionStorage for session-based scroll restoration
- Add console logging for debugging scroll behavior
- Support both Livewire and Alpine navigate events
- Intercept sidebar link clicks to ensure scroll position is saved before navigation
- Use setTimeout for smoother DOM restoration timing
- Restore scroll position on page load and window load events
- Sidebar now maintains scroll position consistently across navigation
2025-12-04 01:55:59 +09:00
y963admin 8dba510a6c Fix sidebar scroll position preservation during page navigation
- Replace unstable x-navigate:scroll directive with custom Alpine event handlers
- Use alpine:navigating event to save sidebar scroll position to localStorage
- Use alpine:navigated event to restore sidebar scroll position after navigation
- Sidebar now maintains scroll position when clicking document links
- Fixed 'Element not found' error that was preventing scroll restoration
- Uses requestAnimationFrame for smooth DOM restoration
2025-12-04 01:47:51 +09:00
y963admin e66ece71e3 Preserve sidebar scroll position when navigating between documents
- Add x-navigate:scroll directive to sidebar container to maintain scroll position
- Add x-navigate:scroll to all document links in sidebar (tree-item.blade.php)
- Add x-navigate:scroll to 'New Document' button
- When clicking a link in sidebar, the sidebar scroll position is now preserved during page navigation
- Fixes issue where sidebar would scroll to top after loading a document
2025-12-04 01:37:19 +09:00
y963admin b96012f598 Add resizable sidebar feature and increase default width to 320px
- Add resizable handle on the right edge of sidebar in desktop view (lg and above)
- Allow dragging to adjust sidebar width between 200px and 600px
- Persist resize settings in localStorage across page reloads
- Keep mobile mode unchanged with fixed 256px width
- Increase default width from 256px to 320px
- Implement visual feedback with color change on mouse hover
2025-12-04 01:24:47 +09:00
y963admin e50ed261e1 Show timezone information 2025-11-30 13:58:03 +09:00
y963admin 79a09430aa Fix timezone issue: Set default timezone to Asia/Tokyo
- Update config/app.php to use APP_TIMEZONE from .env with Asia/Tokyo as default
- Add APP_TIMEZONE to .env.example
- Fixes issue where timestamps were displayed 9 hours behind (UTC vs JST)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:46:16 +09:00
y963admin 33fef93ce0 Update README with comprehensive project documentation
- Add detailed feature overview (wiki-links, folders, multi-language, responsive)
- Include complete installation and setup instructions
- Document project structure and key concepts
- Add development workflow and common commands
- Include troubleshooting section
- Update technology stack and credits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:19:43 +09:00
y963admin e14cc5dd43 Implement responsive design for mobile and tablet devices
- Add hamburger menu for mobile sidebar with slide-out animation
- Make header navigation responsive with icon-only buttons on mobile
- Adjust document viewer, editor, and quick switcher layouts for smaller screens
- Preserve all existing functionality including localStorage folder state persistence
- Use Tailwind responsive utilities (sm:, md:, lg:) for progressive enhancement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:17:50 +09:00
y963admin c9fe1f6ed0 Reorder language dropdown for better UX
New order:
1. Primary: English, Japanese
2. Chinese: Simplified, Traditional
3. Korean
4. Other Asian: Hindi, Vietnamese, Turkish
5. European: German, French, Spanish, Portuguese, Russian,
   Ukrainian, Italian, Polish

This prioritizes the most commonly used languages in the target
market while maintaining a logical regional grouping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:02:05 +09:00
y963admin 01c92aeb7c Add translation files for 8 new languages
Created complete message translation files for:
- pt-BR: Portuguese (Brazil) - 250M+ speakers
- ru: Russian - 260M+ speakers
- uk: Ukrainian - 40M+ speakers
- it: Italian - 67M speakers
- hi: Hindi - 600M+ speakers
- vi: Vietnamese - 95M speakers
- tr: Turkish - 80M speakers
- pl: Polish - 45M speakers

All translation files include complete translations for:
- Navigation, documents, quick switcher
- Admin panel, settings, common terms
- Authentication, errors, profile management

Total supported languages: 16 (LTR only)
These translations provide native language support for
1.5+ billion additional speakers worldwide.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:01:12 +09:00
y963admin 211867e2eb Add 8 major languages (LTR only)
Added languages:
- pt-BR: Português (Brasil) - 250M+ speakers
- ru: Русский - 260M+ speakers
- uk: Українська - 40M+ speakers
- it: Italiano - 67M speakers
- hi: हिन्दी - 600M+ speakers
- vi: Tiếng Việt - 95M speakers
- tr: Türkçe - 80M speakers
- pl: Polski - 45M speakers

Total supported languages: 16
RTL languages (Arabic, Hebrew, etc.) excluded for now,
as they require additional layout implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 12:54:14 +09:00
y963admin f57d235651 Remove language setting from profile page
Since language switching is now available in the header for all users,
the separate language setting section in the profile page is redundant.

Changes:
- Remove update-locale-form include from profile/edit.blade.php
- Delete profile/partials/update-locale-form.blade.php

Users can now change language using the header dropdown instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 12:36:53 +09:00
y963admin b419991940 Store guest user language preference in long-lived cookie
Changes:
- LocaleController: Set 1-year cookie when language is changed
- SetLocale middleware: Check cookie after session, before default
  Priority: User DB > Session > Cookie > Default

This allows guest users to retain their language preference
even after closing the browser (persists for 1 year).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 12:32:01 +09:00
y963admin 61d42d79f1 Enable language switching for guest users
Changes:
- Move locale.update route outside auth middleware
- Update LocaleController to support both authenticated and guest users
  - Guest users: Save locale preference to session only
  - Authenticated users: Save to both session and database
- Add language switcher dropdown to header for all users
  - Display current language with globe icon
  - Show all 8 supported languages in dropdown
  - Highlight currently selected language with checkmark

This allows non-logged-in users to change the interface language,
improving accessibility for international visitors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 12:30:35 +09:00
y963admin 5b83936c4b livewire config fix 2025-11-29 17:53:18 +09:00
y963admin ef238891f5 fix: Set Livewire asset_url to null for subdirectory support
ASSET_URL was being used as the script src, causing:
<script src="https://domain.com/kb">

Setting asset_url to null lets Livewire use the correct default path.
2025-11-29 17:48:12 +09:00
y963admin a9b4b93d8c fix: Remove wire:navigate to fix MIME type error
Livewire 3's SPA navigation (wire:navigate) was causing the browser
to register document URLs as JavaScript sources, resulting in:
'Refused to execute script... MIME type text/html is not executable'

Removed wire:navigate from:
- partials/tree-item.blade.php (sidebar links)
- livewire/document-viewer.blade.php (backlinks)
- livewire/quick-switcher.blade.php (search results)

Pages will now do full page loads instead of SPA navigation.
2025-11-29 17:40:59 +09:00
y963admin 9625268e67 fix: Add Livewire config and custom 404 page for subdirectory support
- Add config/livewire.php with app_url and asset_url settings
- Add custom 404 error page with consistent design
- Add error translations for all 8 languages

For subdirectory deployment:
1. Set APP_URL to include subdirectory (e.g., https://example.com/kb)
2. Set ASSET_URL if using CDN
3. Clear config cache after deployment
2025-11-29 17:27:40 +09:00
y963admin ac56889a87 fix: Use slug for Document route model binding
- Add getRouteKeyName() to return 'slug' for Document model
- Add resolveRouteBinding() to support both slug and ID lookups
- Update QuickSwitcher to use slug instead of ID
- Update quick-switcher blade to use slug in routes

This ensures URLs use readable slugs (e.g., /documents/home)
while maintaining backwards compatibility with ID-based URLs
2025-11-29 17:22:17 +09:00
y963admin 893d3c7a69 fix: Replace hardcoded paths with route/url helpers for subdirectory support
Files updated:
- layouts/knowledge-base.blade.php - Use url('/') for home link
- layouts/navigation.blade.php - Use url('/') for nav links
- layouts/guest.blade.php - Use url('/') for logo link
- Document.php - Use route() for wiki links
- DocumentLink.php - Use route() for URL attribute
- AuthenticatedSessionController.php - Use url('/') for redirects
- DocumentEditor.php - Use url('/') for redirect
- ProfileController.php - Use url('/') for redirect

This ensures the app works when deployed in a subdirectory
2025-11-29 17:14:46 +09:00
77 changed files with 12496 additions and 1760 deletions
+97
View File
@@ -12,6 +12,20 @@ Markdown対応のナレッジベースアプリケーションです。Wiki風
- 👥 **ユーザー管理** - 管理者によるユーザーのCRUD操作 - 👥 **ユーザー管理** - 管理者によるユーザーのCRUD操作
- 🔒 **認証** - Laravel Breezeによるログイン/登録機能 - 🔒 **認証** - Laravel Breezeによるログイン/登録機能
- 🎨 **シンタックスハイライト** - コードブロックの自動ハイライト - 🎨 **シンタックスハイライト** - コードブロックの自動ハイライト
- 🌐 **多言語対応** - 8言語サポート(ユーザーごとに言語設定可能)
## 対応言語
| 言語 | コード |
|------|--------|
| English | en |
| 日本語 | ja |
| Deutsch | de |
| Français | fr |
| Español | es |
| 简体中文 | zh-CN |
| 繁體中文 | zh-TW |
| 한국어 | ko |
## 技術スタック ## 技術スタック
@@ -49,6 +63,7 @@ Markdown対応のナレッジベースアプリケーションです。Wiki風
│ │ ├── Livewire/ # Livewireコンポーネント │ │ ├── Livewire/ # Livewireコンポーネント
│ │ ├── Models/ # Eloquentモデル │ │ ├── Models/ # Eloquentモデル
│ │ └── Services/ # ビジネスロジック │ │ └── Services/ # ビジネスロジック
│ ├── lang/ # 言語ファイル(i18n)
│ ├── resources/views/ │ ├── resources/views/
│ │ ├── layouts/ # レイアウト │ │ ├── layouts/ # レイアウト
│ │ ├── livewire/ # Livewireビュー │ │ ├── livewire/ # Livewireビュー
@@ -165,6 +180,62 @@ DocumentSeederを実行すると以下のドキュメントが作成されます
※ 既存のドキュメントがある場合、DocumentSeederはスキップされます。 ※ 既存のドキュメントがある場合、DocumentSeederはスキップされます。
## 本番環境へのデプロイ
### ⚠️ 重要: サブドメインを使用してください
このアプリケーションは **サブドメイン** でのデプロイを推奨します。
```
✅ 推奨: kb.example.com
❌ 非推奨: example.com/kb (サブディレクトリ)
```
**理由**: Livewire 3はサブディレクトリデプロイに完全対応していません。`/livewire/update` エンドポイントがサブディレクトリを考慮しないため、AJAX通信が失敗します。
### デプロイ手順
1. **サブドメインのDNS設定**
```
kb.example.com → サーバーIPアドレス
```
2. **Webサーバー設定**nginx例)
```nginx
server {
listen 80;
server_name kb.example.com;
root /var/www/knowledge-base/src/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
```
3. **環境変数の設定**
```env
APP_URL=https://kb.example.com
APP_ENV=production
APP_DEBUG=false
```
4. **本番用の最適化**
```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan optimize
```
## よく使うコマンド ## よく使うコマンド
```bash ```bash
@@ -181,6 +252,12 @@ docker exec kb_php npm [command]
# コンテナに入る # コンテナに入る
docker exec -it kb_php bash docker exec -it kb_php bash
# キャッシュクリア
docker exec kb_php php artisan config:clear
docker exec kb_php php artisan route:clear
docker exec kb_php php artisan view:clear
docker exec kb_php php artisan cache:clear
``` ```
## 管理者機能 ## 管理者機能
@@ -219,6 +296,15 @@ docker exec -it kb_php bash
[[Laravel/Livewire/Components]] も確認してください。 [[Laravel/Livewire/Components]] も確認してください。
``` ```
## 言語設定
ユーザーは「プロフィール」ページから使用言語を変更できます。
1. 右上のユーザー名をクリック
2. 「プロフィール」を選択
3. 「言語設定」セクションで言語を選択
4. 「保存」をクリック
## トラブルシューティング ## トラブルシューティング
### パーミッションエラー ### パーミッションエラー
@@ -243,6 +329,17 @@ docker compose up -d
`src/.env` の `DB_HOST` が `kb_mysql` になっているか確認してください。 `src/.env` の `DB_HOST` が `kb_mysql` になっているか確認してください。
### Livewireのエラー(本番環境)
「404 /livewire/update」エラーが出る場合は、サブディレクトリではなくサブドメインでデプロイしてください。
```bash
# キャッシュをクリア
php artisan config:clear
php artisan route:clear
php artisan cache:clear
```
## ライセンス ## ライセンス
MIT License MIT License
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
# Media Embed Design
**Date:** 2026-05-09
**Status:** Approved
**Scope:** Add support for embedding video files, audio files, YouTube, and Vimeo in Markdown documents using the standard image syntax `![](url)`.
## Background
The knowledge base currently renders Markdown via `League\CommonMark` with `html_input => 'strip'`, which removes raw HTML. This is a deliberate safety choice: the project is published as OSS and may be deployed in environments with multiple authors or untrusted input, so raw HTML passthrough is undesirable.
To migrate fixed pages from a previous WordPress site (which used `<video>` tags and YouTube/Vimeo embeds), Markdown needs a safe mechanism to express media embeds. The chosen approach extends the existing image syntax: when an `![](url)` URL points to a media resource, the rendered output becomes `<video>`, `<audio>`, or `<iframe>` instead of `<img>`.
## Goals
- Support embedding local video and audio files via `![](url)` syntax
- Support YouTube and Vimeo embeds via the same syntax
- Use privacy-enhanced embed modes (`youtube-nocookie.com`, Vimeo `?dnt=1`)
- Preserve existing image rendering and Wiki link behavior unchanged
- Maintain `html_input => 'strip'` for safety
- Provide unit-test coverage for URL parsing and rendering
## Non-Goals
- Custom attributes (width, autoplay, poster) — sizing handled via CSS only
- Other embed providers (Twitch, SoundCloud, Spotify, etc.)
- `og:video` OGP tags
- VTT subtitles / `<track>` elements
- Download cards for zip/binary files (a separate future task)
- Rerendering existing documents (a separate Artisan command may be added later)
## Architecture
```
Markdown input
CommonMarkParser
│ (after parse)
DocumentParsedEvent ───► MediaEmbedExtension listener
│ Walk Image nodes, classify URL:
│ ├─ video extension → <video>
│ ├─ audio extension → <audio>
│ ├─ YouTube URL → <iframe> (nocookie)
│ ├─ Vimeo URL → <iframe> (dnt)
│ └─ other → leave unchanged (renders as <img>)
│ Replace matching node with HtmlInline
HTML output (existing render flow unchanged)
```
The extension lives entirely in CommonMark's event-based AST modification layer. No changes are required to the existing Wiki link, GFM, or image rendering logic.
### Boundary Summary
- **Input:** Markdown string (unchanged)
- **Output:** HTML string — some `![](...)` produce `<video>`, `<audio>`, or `<iframe>` instead of `<img>`
- **Untouched:** Wiki links, GFM extension, default image rendering, `html_input => 'strip'` policy
## Components
### New files
#### `src/app/Markdown/MediaEmbedExtension.php`
CommonMark `ExtensionInterface` implementation. Sole responsibility: register the listener.
- Public API: `register(EnvironmentBuilderInterface $env): void`
- Wires `DocumentParsedEvent` to `MediaEmbedListener::handle`
#### `src/app/Markdown/MediaUrlResolver.php`
Pure URL classification class with no external dependencies. Highly testable.
- Public API: `resolve(string $url): ?string`
- Returns the replacement HTML string if URL is a recognized media resource
- Returns `null` if URL should fall through to default image rendering
- Internal helpers:
- `detectVideo(string $url): ?string`
- `detectAudio(string $url): ?string`
- `detectYouTube(string $url): ?string`
- `detectVimeo(string $url): ?string`
- Order: video → audio → YouTube → Vimeo → null
#### `src/app/Markdown/MediaEmbedListener.php`
Thin glue layer. Receives `DocumentParsedEvent`, walks the AST, and delegates URL classification to `MediaUrlResolver`.
- Public API: `handle(DocumentParsedEvent $event): void`
- For each `Image` node: call resolver; if non-null, replace node with a `MediaEmbedNode`
#### `src/app/Markdown/MediaEmbedNode.php`
Custom AST node that carries the pre-rendered embed HTML string.
- Extends `AbstractStringContainer`
- Does NOT implement `RawMarkupContainerInterface` — this is intentional so the node is not subject to `HtmlFilter`
- Holds its literal content (the HTML string) for direct output by its renderer
#### `src/app/Markdown/MediaEmbedNodeRenderer.php`
Dedicated renderer for `MediaEmbedNode`.
- Implements `NodeRendererInterface`
- Returns the node's literal content directly, without invoking any HTML filter
- This is the mechanism that allows trusted embed HTML to survive the `html_input => 'strip'` policy
### Modified files
#### `src/app/Models/Document.php` — `renderMarkdown()`
Add a single line:
```php
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
```
No other changes.
### File-split rationale
Separating `MediaUrlResolver` from `MediaEmbedListener` isolates "URL parsing / HTML generation" from "AST manipulation." The former is pure and exhaustively testable; the latter is a thin glue layer. This keeps each unit single-purpose and easier to reason about.
## Data Flow Specification
### Input → Output reference
| Markdown input | Output HTML (key parts) |
|---|---|
| `![alt](/foo.png)` | `<img src="/foo.png" alt="alt">` *(default, unchanged)* |
| `![](/demo.mp4)` | `<video src="/demo.mp4" controls class="kb-video"></video>` |
| `![](/audio.mp3)` | `<audio src="/audio.mp3" controls class="kb-audio"></audio>` |
| `![](https://youtu.be/abc123XYZ_-)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
| `![](https://www.youtube.com/watch?v=abc123XYZ_-&t=30s)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-?start=30" ...></iframe>` |
| `![](https://www.youtube.com/shorts/abc123XYZ_-)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
| `![](https://vimeo.com/123456789)` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1" ...></iframe>` |
| `![](https://vimeo.com/123456789#t=30s)` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1#t=30s" ...></iframe>` |
### Extension matching (case-insensitive)
- Video: `mp4`, `webm`, `ogv`, `mov`, `m4v`
- Audio: `mp3`, `wav`, `ogg`, `m4a`
Matching is performed on the URL **path** only (after stripping `?query` and `#fragment`) so signed CDN URLs with `?token=...` are not misclassified.
### YouTube URL recognition
The video ID is the strict pattern `[A-Za-z0-9_-]{11}`. Recognized URL forms:
| Pattern | Example |
|---|---|
| `youtu.be/{id}` | `https://youtu.be/abc123XYZ_-` |
| `youtube.com/watch?v={id}` | `https://www.youtube.com/watch?v=abc123XYZ_-` |
| `youtube.com/shorts/{id}` | `https://www.youtube.com/shorts/abc123XYZ_-` |
| `youtube.com/embed/{id}` | `https://www.youtube.com/embed/abc123XYZ_-` |
| `m.youtube.com/...` | mobile variant of the above |
Timestamp normalization (first match wins; `t` preferred over `start`):
- `?t=30s` / `?t=30` / `&t=1m20s` → seconds → `?start=N`
- `?start=N` → preserved
- No timestamp → no `?start` parameter
### Vimeo URL recognition
| Pattern | Example |
|---|---|
| `vimeo.com/{id}` | `https://vimeo.com/123456789` |
| `player.vimeo.com/video/{id}` | `https://player.vimeo.com/video/123456789` |
ID is digits only.
Timestamp:
- `#t=30s` → preserved as `#t=30s` (Vimeo convention)
- `?t=30s` → preserved as `#t=30s`
### iframe attribute template
```html
<iframe src="..."
width="560" height="315"
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
frameborder="0"
class="kb-embed kb-embed-{provider}">
</iframe>
```
`{provider}` is `youtube` or `vimeo`. Class hooks let CSS introduce aspect-ratio control later.
### Resolution order
1. Video extension → emit `<video>`, return
2. Audio extension → emit `<audio>`, return
3. YouTube → emit `<iframe>`, return
4. Vimeo → emit `<iframe>`, return
5. None match → return `null`; node renders as default `<img>`
## Error Handling and Edge Cases
| Case | Behavior | Reason |
|---|---|---|
| `parse_url` failure | return `null` → default `<img>` | Fall back to CommonMark default |
| URL with no extension | return `null` → default `<img>` | Extension matching is path-suffix based |
| YouTube ID does not match `[A-Za-z0-9_-]{11}` | return `null` → default `<img>` | Strict matching avoids false positives |
| Vimeo ID is not digits | return `null` → default `<img>` | Same |
| Empty URL | return `null` | `parse_url` returns empty path |
**Principle:** Unrecognized URLs are not transformed. Exceptions are not thrown. Default CommonMark rendering handles the fallback.
### XSS hardening
All output URLs are passed through `htmlspecialchars($url, ENT_QUOTES, 'UTF-8')` before being embedded in HTML strings. Attack-vector analysis:
- `![](javascript:alert(1))` — does not match a media extension → `null` → CommonMark's `allow_unsafe_links => false` blocks `<img src="javascript:...">`
- `![](https://youtu.be/"><script>...)` — strict ID regex `[A-Za-z0-9_-]{11}` cannot extract from a URL containing `"` or `>``null` → default rendering, where CommonMark also escapes
- `![](/foo.mp4")` — trailing quote breaks extension matching at the path-cleaning step; even if it passed, `htmlspecialchars` would escape the output
### Relation to `html_input => 'strip'`
The `'strip'` setting is preserved. All `HtmlInline` nodes — whether written by the user in Markdown source or inserted programmatically by an extension — go through `HtmlFilter::filter()`, which strips their content under `'strip'` mode. To emit the embed HTML safely without bypassing this policy, the extension introduces a custom node type:
- `MediaEmbedNode` extends `AbstractStringContainer` and deliberately does NOT implement `RawMarkupContainerInterface`
- `MediaEmbedNodeRenderer` returns the node's literal content directly, without invoking any HTML filter
Therefore:
- User-written `<script>` in Markdown source → produces `HtmlInline` → still stripped
- `<video>` / `<audio>` / `<iframe>` inserted by `MediaEmbedExtension` → produces `MediaEmbedNode` → output as intended
- The security boundary is "only the explicitly trusted node type bypasses filtering," and that node type is reachable only through `MediaEmbedListener` after `MediaUrlResolver` has classified the URL as a known media pattern.
### `alt` and `title`
Markdown image syntax allows `![alt](url "title")`.
- `<video>` / `<audio>` have no `alt` attribute → ignored
- `title` is preserved on `<video>` / `<audio>` as `title="..."` (optional)
- iframes ignore both (the YouTube/Vimeo player surfaces its own title)
VTT subtitles / `<track>` elements are out of scope.
### Multiple media in one paragraph
```markdown
![](/a.mp4) and ![](/b.mp4)
```
Two `<video>` elements appear within the same `<p>`. `<video>` is phrasing content per the HTML spec, so this is valid. CSS can apply `display: block` if needed.
### Existing documents
Existing rows in `documents.rendered_html` may be stale after this change. Mitigation is left to the implementation phase — most likely a `docs:rerender` Artisan command (or a one-off `tinker` invocation) that re-saves each `Document` to trigger the existing render hook. This is **not part of the design scope** and should be tracked separately during implementation planning.
## Testing Strategy
### `tests/Unit/Markdown/MediaUrlResolverTest.php`
Pure-unit tests against `MediaUrlResolver::resolve`.
**Video extensions** (one case per extension):
- `/demo.mp4`, `/demo.webm`, `/demo.ogv`, `/demo.mov`, `/demo.m4v``<video>` output
- `/demo.MP4` (uppercase) → recognized
- `https://example.com/path/demo.mp4?token=abc` → query stripped, recognized
**Audio extensions** (one case per extension):
- `/clip.mp3`, `/clip.wav`, `/clip.ogg`, `/clip.m4a``<audio>` output
**YouTube** (full URL pattern coverage):
- `https://youtu.be/dQw4w9WgXcQ`
- `https://www.youtube.com/watch?v=dQw4w9WgXcQ`
- `https://www.youtube.com/shorts/dQw4w9WgXcQ`
- `https://www.youtube.com/embed/dQw4w9WgXcQ`
- `https://m.youtube.com/watch?v=dQw4w9WgXcQ`
- Timestamps: `?t=30s`, `?t=90`, `?t=1m20s`, `?start=30`
- Output contains `youtube-nocookie.com`
**Vimeo:**
- `https://vimeo.com/123456789`
- `https://player.vimeo.com/video/123456789`
- Timestamps: `#t=30s`, `?t=30s`
- Output contains `?dnt=1`
**Fallback (returns `null`):**
- Normal images: `/photo.jpg`, `/icon.svg`
- No extension: `/foo`
- Invalid URL: empty string, `javascript:alert(1)`, `http://`
- Negative-match candidates: `https://example.com/youtu.be-fake/abc` (host mismatch)
- Invalid YouTube ID: `https://youtu.be/short` (less than 11 chars), special characters
**XSS resilience:**
- `https://youtu.be/abc"><script>``null` (strict ID extraction fails)
- Video URL containing `"` produces escaped output
### `tests/Unit/Markdown/MediaEmbedExtensionTest.php`
Integrated unit tests through `Document::renderMarkdown()`:
- Default image survives unchanged: `![alt](/foo.png)``<img>`
- Video embed succeeds: `![](/foo.mp4)``<video>`, no `<img>`
- Mixed Markdown: image, video, YouTube, Vimeo coexist correctly
- Wiki link coexistence: `[[other-doc]]` is unaffected
- Multiple media in one paragraph: `![](/a.mp4) ![](/b.mp4)` → two `<video>`
- List item: `- ![](/a.mp4)``<video>` inside `<li>`
### Test data convention
No fixture files. Test inputs are inline string literals so they remain greppable.
### Running
```bash
docker compose exec php php artisan test --filter=MediaUrlResolverTest
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest
```
`composer test` (full suite) must remain green.
### Coverage target
No formal coverage measurement. The bar is: **every URL pattern listed in the Data Flow Specification has at least one corresponding test case.**
## Open Items for Implementation Phase
These are deliberately deferred to the planning phase, not the design:
- Whether to add a `docs:rerender` Artisan command for existing rows
- CSS additions for `.kb-video`, `.kb-audio`, `.kb-embed-*` (likely a future task)
- Updating `CLAUDE.md` to document the new media-embed convention
@@ -0,0 +1,192 @@
# Media Manager — Design Spec
Date: 2026-05-09
## Goal
Add a simple media manager to the admin panel so users can upload, rename, download, and delete arbitrary files (images, video, audio, PDF, ZIP) and reference them from Markdown documents by a stable logical filename or path.
## Non-Goals
- Folder management UI (explicit folder create/move/rename). Folders are expressed implicitly via slashes in the logical path.
- Versioning or revision history of media files.
- Automatic rewriting of existing Markdown when a media file is renamed or deleted (broken-link warning is left to manual operation).
- Migration of existing `storage/app/public/images/` files into the new media table.
## Core Design
### Logical / Physical Separation
- **Logical path** (DB-managed): user-facing identifier used in Markdown, e.g., `report.png` or `2026/spec.pdf`. UNIQUE.
- **Physical path** (filesystem): `media/{uuid}.{ext}` under the `public` disk. Filename is a UUID; extension preserved from the original upload.
- Renaming changes only the logical path; the physical file is never moved.
- Deletion removes the DB row and the physical file.
### Markdown Reference Resolution
- Markdown reference syntax stays standard: `![alt](report.png)` or `![alt](2026/spec.pdf)`.
- Resolution policy: **exact match only** on `logical_path`. No basename fallback, no fuzzy matching.
- Only schema-less, non-absolute paths are looked up. `https://…`, `http://…`, and paths starting with `/` are passed through to the existing pipeline (YouTube/Vimeo/video/audio handlers).
### Editor Drag-and-Drop Integration
- The existing `POST /images/upload` endpoint (used by EasyMDE) is rerouted through the same `MediaService`.
- The endpoint returns the **logical path** (e.g., `report.png`) as `data.filePath`, so the inserted Markdown becomes `![alt](report.png)` rather than an absolute UUID URL.
- Logical-path collisions during editor uploads are auto-resolved server-side by appending `-2`, `-3`, … to the basename (max 100 attempts before erroring). Collisions in the manual admin upload form are surfaced as validation errors instead.
## Data Model
### Table `media_files`
| Column | Type | Notes |
|--|--|--|
| `id` | bigint, pk | |
| `logical_path` | varchar(512), UNIQUE | Validated: no leading/trailing `/`, no `..`, no empty segments, no consecutive `/`, non-empty |
| `physical_path` | varchar(255) | `media/{uuid}.{ext}` (relative to the public disk) |
| `original_name` | varchar(255) | Original filename at upload time. Used as the Content-Disposition filename on download |
| `mime_type` | varchar(127) | Detected at upload |
| `size` | unsigned bigint | Bytes |
| `uploaded_by` | foreign key → `users.id`, nullable | ON DELETE SET NULL |
| `created_at` / `updated_at` | timestamp | |
Indexes: `UNIQUE(logical_path)`, plain index on `uploaded_by`.
### Eloquent Model `App\Models\MediaFile`
- `publicUrl()`: returns `Storage::disk('public')->url($this->physical_path)`.
- Override `delete()` to remove the physical file before deleting the DB row. If the physical delete fails, the DB row is **kept** and the failure is logged so it can be retried.
## Components
### Backend
| Role | File | Responsibility |
|--|--|--|
| Routes | `routes/web.php` (additions) | Admin resource routes for media; standalone `auth`-only download route |
| Admin controller | `app/Http/Controllers/Admin/MediaController.php` | index / store / edit / update / destroy. Inline `validate()`; no FormRequest |
| Service | `app/Services/MediaService.php` | `store(UploadedFile, ?string $logicalPath, bool $autoSuffixOnConflict = false)`, `rename(MediaFile, string $newLogicalPath)`, `delete(MediaFile)`. Owns logical-path normalization, collision detection, and physical I/O |
| Listener update | `app/Markdown/MediaEmbedListener.php` | Add a Phase-1 logical-path rewrite that walks `Image` and `Link` nodes and replaces matching `logical_path` URLs with the physical public URL before the existing video/audio/YouTube/Vimeo detection runs |
| Editor integration | `app/Http/Controllers/ImageUploadController.php` | Rewritten to call `MediaService::store(..., autoSuffixOnConflict: true)` and return `data.filePath = <logical_path>` |
### Frontend
| View | Content |
|--|--|
| `resources/views/admin/media/index.blade.php` | Search box, inline upload form, paginated table (logical path / type icon or thumbnail / size / updated_at / actions). Actions: rename, download, delete |
| `resources/views/admin/media/edit.blade.php` | Rename-only form (edits `logical_path`) |
Navigation entry added to `resources/views/layouts/navigation.blade.php` under the admin dropdown.
### Routing
```php
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
Route::resource('users', AdminUserController::class)->except(['show']);
Route::resource('media', AdminMediaController::class)->except(['show', 'create']);
});
Route::middleware('auth')->group(function () {
// existing routes...
Route::get('/media/{media}/download', [AdminMediaController::class, 'download'])->name('media.download');
});
```
Rationale: CRUD is admin-only; download is reachable by any authenticated user (so document readers can grab attached PDFs/ZIPs). The physical file under `/storage/media/{uuid}.ext` is already publicly readable via the public disk — the `download` route only adds the `original_name` Content-Disposition.
## Data Flow
### Upload (admin form)
1. `POST /admin/media` (multipart) → `MediaController::store`
2. Validate: `file` required; allowed MIME (image jpeg/png/gif/webp/svg, video mp4/webm, audio mp3/m4a/ogg/wav, application/pdf, application/zip); `max:102400` (100 MB); `logical_path` optional.
3. `MediaService::store($file, $logicalPath, autoSuffixOnConflict: false)`:
- Normalize logical path (trim, reject `..`, reject leading/trailing `/`, reject empty/consecutive segments, default to `original_name` if blank).
- Check `logical_path` UNIQUE; on conflict throw a validation exception.
- Save physical file to `media/{uuid}.{ext}` via `Storage::disk('public')->putFileAs`.
- Create the `media_files` row with `uploaded_by = auth()->id()`.
4. Redirect to `admin.media.index` with a success flash.
### Rename
1. `PUT /admin/media/{media}``MediaController::update`
2. Validate the new `logical_path` (same normalization + UNIQUE excluding self).
3. Update the DB row only. Physical file is untouched.
4. **Side effect**: any existing Markdown referring to the old logical path becomes a broken link. No automatic rewrite in this iteration.
### Download
1. `GET /media/{media}/download``MediaController::download` (auth-only).
2. Returns `Storage::disk('public')->download($physical_path, $original_name)`.
### Delete
1. `DELETE /admin/media/{media}``MediaController::destroy`
2. `MediaService::delete($media)`: try to delete the physical file; on success delete the DB row. On physical-delete failure, log it and surface a warning flash; the DB row is preserved so the operation can be retried.
3. Existing Markdown references to the deleted file become broken links. No warning surfaced in this iteration.
### Markdown Render-Time Resolution
The `MediaEmbedListener` walks both `Image` and `Link` nodes during `DocumentParsedEvent` in two ordered phases:
**Phase 1 — Logical-path rewrite** (new):
For each `Image` or `Link` node, take its URL.
- If the URL has a scheme (`http(s)://`, etc.) or starts with `/`, skip it.
- Otherwise look up `MediaFile::where('logical_path', $url)->first()`.
- On hit, replace the node's URL with `$media->publicUrl()` (e.g., `/storage/media/{uuid}.png`). The node type is unchanged.
**Phase 2 — Embed detection** (existing, unchanged):
The existing `MediaEmbedListener` logic runs against the (possibly rewritten) URL. `Image` nodes whose URL ends in a video/audio extension or matches YouTube/Vimeo get replaced with a `MediaEmbedNode` rendered as `<video>` / `<audio>` / iframe. Everything else falls through to CommonMark's default rendering.
Net behavior:
- `![alt](report.png)` → image extension, no embed rewrite, `<img src="/storage/media/{uuid}.png" alt="alt">`.
- `![](demo.mp4)` → URL rewritten to physical, then video extension detected, replaced with `<video>` tag.
- `[manual](manual.pdf)` → Link node, URL rewritten to physical, CommonMark renders `<a href="/storage/media/{uuid}.pdf">manual</a>`.
- `![](https://example.com/foo.png)` → has scheme, Phase 1 skips; Phase 2 also has nothing to do; CommonMark renders default `<img>`.
`MediaUrlResolver` itself stays focused on URL→embed-HTML mapping for video/audio/YouTube/Vimeo (i.e., its public surface does not change). The logical-path rewrite is a separate concern handled in the listener.
### Editor Drag-and-Drop
1. EasyMDE → `POST /images/upload` (existing endpoint).
2. `ImageUploadController` calls `MediaService::store($file, null, autoSuffixOnConflict: true)`.
3. Response: `{ data: { filePath: <logical_path>, altText: <basename without ext> } }`.
4. EasyMDE inserts `![<basename>](<logical_path>)` into the Markdown.
## Validation & Error Handling
| Case | Behavior |
|--|--|
| Invalid logical path (`..`, leading/trailing `/`, empty/consecutive segments, > 512 chars, empty after trim) | Validation error |
| Logical-path collision (admin form) | Validation error: "同じパスのファイルが既に存在します" |
| Logical-path collision (editor D&D) | Auto-suffix `-2`, `-3`, … up to 100 attempts; on exhaustion return a 422 |
| Disallowed MIME / extension | Validation error |
| File over 100 MB | Validation error. `php.ini` `upload_max_filesize` / `post_max_size` must be ≥ 100 MB; document this as an environment requirement |
| Physical-delete failure | Log + warning flash; DB row kept |
| Deleted/renamed media still referenced in MD | No warning; the resolver misses, the link renders as a broken link |
| Markdown URL is absolute (`http(s)://`, leading `/`) | Resolver passes through to existing video/audio/YouTube/Vimeo logic |
| Authorization | All CRUD under `auth + admin` middleware; download under `auth` only |
## Testing
PHPUnit feature tests using a fake `public` disk so real file I/O is exercised in temp storage.
| Test | Coverage |
|--|--|
| `MediaServiceTest::store` | with explicit logical path, with default (original name), conflict rejection, invalid-path rejection, `uploaded_by` recorded |
| `MediaServiceTest::rename` | DB updated, physical file unchanged, conflict rejection |
| `MediaServiceTest::delete` | physical + DB removed; on physical-fail DB row kept |
| `MediaServiceTest::auto_suffix` | editor path: `report.png` collision → `report-2.png` |
| `Admin\MediaControllerTest` | non-admin → 403 on index/store/update/destroy |
| `MediaDownloadTest` | unauth → redirect/401, auth → file with `original_name` |
| `MediaEmbedListenerTest` (extended) | logical-path hit rewrites Image/Link URL to physical; miss leaves URL untouched; absolute URL (with scheme or leading `/`) skipped |
| `DocumentRenderIntegrationTest` | `![](report.png)` rendered to `<img src="/storage/media/{uuid}.png">` after a `MediaFile` row exists |
Coverage target: the rows above. Not aiming for 100%.
## Out-of-Scope (explicit)
- Bulk operations (multi-select delete, bulk rename).
- Quota/usage tracking per user.
- CDN / signed URLs.
- Migration of existing `images/` content into `media_files`.
- Rewriting existing Markdown when a logical path is renamed or deleted.
@@ -0,0 +1,275 @@
# 記事の多言語対応 + デフォルト言語フォールバック
- **Date:** 2026-05-10
- **Branch:** `feature/article-i18n``main` から分岐)
- **Status:** Design approved, ready for implementation plan
## 背景
UI言語切り替えは16言語対応済み(`SetLocale` ミドルウェア + `users.locale` カラム + セッション/Cookie)。
一方、記事本体(`documents` テーブル)は単一言語のまま:1ドキュメント = 1行で `title`/`content`/`rendered_html` を直接保持し、locale非対応。
本設計は、記事自体を多言語化し、ユーザーのUI言語に応じた翻訳を提示する。翻訳が無ければ「その記事の原語版」へフォールバックする。
## 決定事項サマリ
| 論点 | 決定 |
|---|---|
| 翻訳のデータモデル | 1記事 + `document_translations` 子テーブル |
| デフォルト言語 | 記事ごとに `documents.default_locale` を持つ |
| URL | デフォルト言語のslug固定(言語非依存) |
| 表示title | 現在locale → default_locale でフォールバック |
| 編集UX | 1記事の編集画面に言語タブ |
| `[[wiki-link]]` 解決 | 全言語のtitleを対象、現在locale優先の決定論的順序 |
| 検索 | 全言語のtitle/contentを対象、表示titleは現在locale |
| マイグレーション | 既存記事は `config('app.locale')``default_locale` として移行 |
| フォールバック表示 | 上部バナー + 「翻訳を追加」ボタン |
## Section 1: データモデル
### `documents` テーブル(変更)
| 操作 | カラム | 備考 |
|---|---|---|
| 追加 | `default_locale` VARCHAR(10) NOT NULL | フォールバック先となる原語 |
| 削除 | `title` | `document_translations` へ移管 |
| 削除 | `content` | 同上 |
| 削除 | `rendered_html` | 同上 |
| 維持 | `path`, `slug`, `frontmatter`, `file_size`, `file_hash`, `file_modified_at`, `created_by`, `updated_by`, timestamps, `softDeletes` | |
`path`/`slug` は引き続き「default_locale のtitle」から生成。タイトル変更で再生成される既存ロジックも default_locale のtitleに連動。
### `document_translations` テーブル(新規)
```
id BIGINT PK
document_id FK → documents (cascade delete)
locale VARCHAR(10)
title VARCHAR(255)
content TEXT
rendered_html TEXT NULLABLE
created_by FK → users (nullOnDelete)
updated_by FK → users (nullOnDelete)
created_at, updated_at
UNIQUE (document_id, locale)
INDEX (locale, title) -- wiki-link / QuickSwitcher 用
FULLTEXT (title, content) WITH PARSER ngram -- MySQL のみ(既存と同条件)
```
- `(locale, title)` には**unique制約は付けない**。既存 `documents.title` も unique でないため挙動互換性を保つ。代わりに wiki-link 解決順序を決定論にする。
- SQLite(テスト環境)では FULLTEXT インデックス作成をスキップ(既存マイグレーションと同じ条件分岐)。
### マイグレーション手順
1. `documents.default_locale` カラム追加(デフォルト値 `config('app.locale')`、nullable=false
2. `document_translations` テーブル作成
3. データ移行:既存各 `documents` 行から `(title, content, rendered_html, created_by, updated_by)``document_translations` に複製、`locale = config('app.locale')` をセット
4. `documents` テーブルの旧FULLTEXTインデックス(`documents_search_index`)を削除
5. `documents.title`, `documents.content`, `documents.rendered_html` カラムを削除
`down()` は対称的に逆順で復元するが、データ復元は best-effort(複数translation存在時はdefault_localeのものを採用)。
## Section 2: モデル & サービス層
### `Document` モデル
- `title`/`content`/`rendered_html` をfillable/直接プロパティから削除
- 新リレーション
- `translations(): HasMany``DocumentTranslation`
- `defaultTranslation(): HasOne``where('locale', $this->default_locale)`
- アクセサ(**現在 `App::getLocale()` のtranslation → 無ければdefault_localeのtranslation**
- `getTitleAttribute(): string`
- `getContentAttribute(): string`
- `getRenderedHtmlAttribute(): ?string`
- 新メソッド
- `translationFor(string $locale, bool $fallback = true): ?DocumentTranslation`
- `isFallback(string $requestedLocale): bool` — バナー判定用
- `availableLocales(): array` — タブ生成用、translation存在のlocale一覧
### `DocumentTranslation` モデル(新規)
```php
fillable: [document_id, locale, title, content, rendered_html, created_by, updated_by]
casts: timestamps
relations: document(): BelongsTo, creator(), updater()
scope: scopeSearch(Builder, string) -- MATCH(title, content) AGAINST(?)
static: renderMarkdown(string): string -- Document から移管(純粋関数)
```
### `DocumentService` 改修
| メソッド | 変更内容 |
|---|---|
| `createDocument($title, $content, $userId, $locale = null)` | `$locale` 未指定なら `App::getLocale()`。document を作成し `default_locale = $locale`、translationを1件作成。path/slug は title から生成 |
| `updateDocument(Document $doc, $title, $content, $userId, $locale)` | 指定 `$locale` のtranslationをupsert。`$locale === default_locale` のときだけ path/slug 再生成 |
| `addTranslation(Document $doc, $locale, $title, $content, $userId): DocumentTranslation` | 新言語のtranslation追加。重複locale時は422 |
| `deleteTranslation(Document $doc, string $locale): void` | default_locale の翻訳は削除拒否(例外) |
| `setDefaultLocale(Document $doc, string $locale): Document` | `default_locale` 切り替え。新しいdefault_localeのtranslationが存在しない場合422。切替後 path/slug を新default_localeのtitleから再生成 |
| `findByTitle($title, $locale = null): ?Document` | locale指定優先。後述の解決順序を実装 |
| `search(string $query, int $limit = 20)` | `DocumentTranslation::search()` ベース、結果documentをdistinct化 |
| `getDirectoryTree()` | path/階層構造はdocumentレベルのまま、表示titleはアクセサ経由で現在locale + フォールバック |
### `WikiLinkResolver`(新規 `app/Services/WikiLinkResolver.php`
`processLinks()``syncLinks()` から共通利用される解決ロジック:
```
resolve(string $linkText, string $currentLocale): ?Document
1. translations WHERE locale = $currentLocale AND title = $linkText
2. translations WHERE locale = document.default_locale AND title = $linkText
3. translations WHERE title = $linkText
ORDER BY document_id ASC LIMIT 1
4. documents WHERE slug = SlugHelper::generate($linkText)
5. null
```
- `Document::syncLinks()``Document::processLinks()` はResolver利用に書き換え
- `processLinks()` のリンク**表示ラベル**は原文のまま(例: 英語本文中の `[[Getting Started]]` はja表示時もラベル「Getting Started」)。クリック後の遷移先記事は現在localeで表示される
- 既存の `DocumentLink` テーブルはスキーマ無変更(`target_title` を保存しているだけなのでlocale非依存で運用可能)
## Section 3: ルーティング & コントローラ層
### 既存ルート(変更なし)
- `GET /documents/{document}` → slug一致でdocumentバインド
- `GET /documents/{document}/edit`
- `GET /documents/create`
URLは言語非依存。表示言語は `SetLocale` ミドルウェアが解決した `App::getLocale()` に従う。
### `Document::resolveRouteBinding()`
slugは `documents` テーブル直下のため既存ロジックそのまま。
翻訳title(例: `はじめに`)でURLを直接叩いた場合は404のまま(検索/QuickSwitcher経由で見つける想定)。
### 新規ルート: 翻訳の管理
| メソッド | URL | 名前 | 機能 |
|---|---|---|---|
| GET | `/documents/{document}/translations/{locale}/edit` | `documents.translations.edit` | 既存翻訳の編集(`DocumentEditor` を再利用) |
| POST | `/documents/{document}/translations` | `documents.translations.store` | 新言語追加 |
| DELETE | `/documents/{document}/translations/{locale}` | `documents.translations.destroy` | 翻訳削除(default_locale 不可) |
- `{locale}``SetLocale::SUPPORTED_LOCALES` のキーで route constraint
- すべて `auth` + `can:update,document` ミドルウェア配下
### `/` ルート
`slug = home` の document を探してリダイレクト(既存通り、slugは言語非依存のため変更不要)。
## Section 4: Livewire コンポーネント & ビュー
### `DocumentViewer`(改修)
```php
public Document $document;
public string $viewLocale; // 実際に表示中のlocale(フォールバック後)
public bool $isFallback; // バナー表示判定
public string $renderedContent; // wiki-link処理済みHTML
public $backlinks;
```
`mount()` フロー:
1. `$current = App::getLocale()`
2. `$translation = $document->translationFor($current, fallback: true)`
3. `$this->viewLocale = $translation->locale`
4. `$this->isFallback = ($current !== $translation->locale)`
5. `WikiLinkResolver``$translation->rendered_html` 内のwiki-linkを現在locale基準で再解決
ビュー `livewire/document-viewer.blade.php`:
- 上部にフォールバックバナー(`$isFallback === true` のみ)
- 文言は `lang/{locale}/messages.php` に追加(例: `documents.fallback_notice``documents.add_translation`
- 「翻訳を追加」ボタン → `documents.translations.store` への小フォーム(locale = 現在UI locale
- バックリンク表示titleは `$backlink->title` アクセサ経由(自動でフォールバック)
### `DocumentEditor`(改修)
```php
public ?Document $document; // 既存編集時、新規作成時はnull
public string $editingLocale; // 現在編集中のタブ
public string $title; // editingLocale の title
public string $content; // editingLocale の content
public array $availableLocales; // この記事で既に翻訳がある locale 一覧
public bool $isNewLocale; // 既存翻訳の編集 or 新規追加
```
メソッド:
- `switchTab(string $locale)` — 未保存変更があれば確認ダイアログ、translation取得 or 空フォーム
- `save()` — 既存ならupdate、新規ならaddTranslation
- `delete()` — 翻訳削除(default_locale以外)
- `setAsDefault(string $locale)` — default_locale変更(path/slug再生成)
ビュー `livewire/document-editor.blade.php`:
- 上部にタブバー: `[JA*] [EN] [zh-CN] [+ 翻訳を追加 ▾]`
- `*` は default_locale 印
- `+` ドロップダウンで未追加の `SUPPORTED_LOCALES` を選択可能
- 既存のEasyMDE設定はそのまま流用、`wire:model` の単一入力構造を維持
- default_locale変更UI(タブの隣にメニュー or 「この言語をデフォルトにする」ボタン)
### `SidebarTree`(軽微改修)
- `getDirectoryTree()` の戻り値内 `name`path basename)はpathベースのまま
- ファイル名表示は `$document->title`(アクセサ経由)に切り替え
- treeのキャッシュキー(もしあれば)に `App::getLocale()` を含めて、locale切替で再構築
### `QuickSwitcher`(改修)
- 検索クエリを `DocumentTranslation::search()` に投げ、結果documentをdistinct化
- 表示行は `$doc->title`(アクセサ)+ ヒットしたtranslationが現在locale以外なら小さなlocaleバッジ
## Section 5: テスト計画 & 受け入れ基準
### ユニットテスト(追加)
| ファイル | 検証内容 |
|---|---|
| `tests/Unit/Models/DocumentTest.php` | `title`/`content`/`rendered_html` アクセサが現在localeを返す/無ければdefault_localeにフォールバック/`isFallback()` 真偽/`availableLocales()` |
| `tests/Unit/Models/DocumentTranslationTest.php` | `(document_id, locale)` uniquecascade delete`renderMarkdown()` |
| `tests/Unit/Services/WikiLinkResolverTest.php` | 解決順序5段階すべて/同名タイトル衝突時の決定論性/slug fallbacknullケース |
| `tests/Unit/Services/DocumentServiceTest.php` | `createDocument` がtranslationを1件生成/`updateDocument` がdefault_locale時のみpath再生成/`addTranslation``deleteTranslation`default_locale削除拒否)/`setDefaultLocale`(未存在translation時422 + path再生成)/`search` のdistinct化 |
### フィーチャーテスト(追加)
| ファイル | 検証内容 |
|---|---|
| `tests/Feature/DocumentI18nTest.php` | 現在locale=ja で英語のみの記事を表示→英語版が表示されバナーが出る/ja版を追加後はバナーなし/QuickSwitcherで日本語クエリ・英語クエリどちらも同記事ヒット |
| `tests/Feature/DocumentTranslationCrudTest.php` | 翻訳追加POST/編集GET/PATCH/削除DELETEdefault_localeは422)/非権限ユーザは403 |
| `tests/Feature/DocumentMigrationTest.php` | マイグレーション前の擬似データ→migrate実行→translationsへ正しく移行/documentsの旧カラムが消去 |
### 既存テストの修正範囲
- `DocumentService` を呼ぶ既存テストはfactoryで `default_locale` 指定が必要
- `Document::factory()``DocumentTranslationFactory` と連動
- 既存の `processLinks` / `syncLinks` テストは `WikiLinkResolver` 経由に書き換え
### 受け入れ基準(DoD
1. UI言語をjaにして英語のみ記事を開く → 英語コンテンツが表示され、上部に日本語バナー+「翻訳を追加」ボタンが出る
2. 「翻訳を追加」→ 編集画面でja版を作成・保存 → 同URLを再表示 → ja版がバナーなしで表示
3. `[[Getting Started]]``[[はじめに]]` の両方が同一記事へリンクする
4. QuickSwitcherで「はじめに」「Getting Started」どちらでも同記事がヒットする
5. 編集画面の言語タブを切り替えて、各言語のtitle/contentを独立に編集・保存できる
6. default_locale設定の翻訳は削除できない(UIで削除ボタン非表示+バックエンドで422)
7. 既存の `composer test` がすべて緑
8. マイグレーション後、既存記事すべてが `default_locale = config('app.locale')` で正しく翻訳化されている
## ブランチ & コミット計画
- ブランチ: `feature/article-i18n`**`main` から分岐**`feature/media-manager` ではない)
- コミット粒度の目安(1 PR想定で7コミット):
1. マイグレーション(`document_translations` 作成 + 既存データ移行 + 旧カラム削除)
2. `DocumentTranslation` モデル + factory
3. `Document` モデルアクセサ/リレーション改修
4. `WikiLinkResolver` + `DocumentService` 改修
5. ルート + 翻訳CRUDコントローラ
6. `DocumentViewer` + バナー + lang追加
7. `DocumentEditor` タブUI + `SidebarTree`/`QuickSwitcher` 調整
## スコープ外(YAGNI
- 翻訳の自動生成(DeepL等の外部API連携)
- 翻訳の進捗ダッシュボード(カバレッジ表示)
- 言語ごとの別URL`/ja/documents/...`
- title翻訳に応じたslugの言語別生成
- RTL言語サポート
+6
View File
@@ -0,0 +1,6 @@
{
"name": "knowledge-base",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+1
View File
@@ -7,6 +7,7 @@ APP_URL=http://localhost
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
APP_TIMEZONE=Asia/Tokyo
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database # APP_MAINTENANCE_STORE=database
+314 -41
View File
@@ -1,59 +1,332 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> # Knowledge Base System
<p align="center"> An Obsidian-like knowledge base system built with Laravel 12, Livewire v3, and Alpine.js. Create, organize, and link your documents with wiki-style references and a powerful search interface.
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel ## Features
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: ### Core Functionality
- **Markdown-based documents** with live preview using EasyMDE editor
- **Wiki-style linking** with `[[Document Title]]` syntax
- **Automatic backlinks** - see which documents reference the current page
- **Folder organization** - use `/` in titles to auto-organize into folders (e.g., `Laravel/Livewire/Components`)
- **Quick switcher** - Press `Ctrl+K` to instantly search and navigate
- **Full-text search** - MySQL FULLTEXT index with ngram tokenizer for multilingual support
- **ID-based routing** - Clean URLs with guaranteed uniqueness
- [Simple, fast routing engine](https://laravel.com/docs/routing). ### Multi-Language Support
- [Powerful dependency injection container](https://laravel.com/docs/container). Interface available in **16 languages**:
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. - English, 日本語 (Japanese)
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). - 简体中文, 繁體中文 (Simplified/Traditional Chinese)
- Database agnostic [schema migrations](https://laravel.com/docs/migrations). - 한국어 (Korean)
- [Robust background job processing](https://laravel.com/docs/queues). - हिन्दी (Hindi), Tiếng Việt (Vietnamese), Türkçe (Turkish)
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). - Deutsch, Français, Español, Português (Brasil)
- Русский, Українська, Italiano, Polski
Laravel is accessible, powerful, and provides tools required for large, robust applications. Language preferences persist for both authenticated and guest users via cookies.
## Learning Laravel ### Responsive Design
- **Mobile-first** interface with hamburger menu
- **Tablet and desktop** optimized layouts
- **Touch-friendly** navigation
- All features work seamlessly across devices
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. ### User Management
- **Role-based access** - Admin and regular user roles
- **User authentication** - Laravel Breeze integration
- **Profile management** - Update name, email, password, and language preference
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. ## Technology Stack
## Laravel Sponsors - **Backend**: Laravel 12.0 (PHP 8.2+)
- **Frontend**: Livewire v3.7.0, Alpine.js, Tailwind CSS
- **Database**: MySQL 8.0 with FULLTEXT indexing
- **Markdown**: league/commonmark for rendering
- **Editor**: EasyMDE (markdown editor)
- **Docker**: Custom containerized environment
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). ## Prerequisites
### Premium Partners - Docker and Docker Compose
- Node.js 18+ (for asset compilation)
- Git
- **[Vehikl](https://vehikl.com)** ## Installation
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** ### 1. Clone the repository
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)** ```bash
- **[DevSquad](https://devsquad.com/hire-laravel-developers)** git clone <repository-url>
- **[Redberry](https://redberry.international/laravel-development)** cd knowledge-base
- **[Active Logic](https://activelogic.com)** ```
### 2. Start Docker services
```bash
docker compose up -d
```
This starts:
- Nginx: `http://localhost:9700`
- phpMyAdmin: `http://localhost:9701`
- MySQL: `localhost:9702`
- MailHog: `http://localhost:9725`
### 3. Install dependencies
```bash
# Inside the src/ directory
cd src
# Install PHP dependencies
docker compose exec php composer install
# Install Node dependencies
npm install
```
### 4. Configure environment
```bash
# Copy environment file
cp .env.example .env
# Generate application key
docker compose exec php php artisan key:generate
```
### 5. Set up database
```bash
# Run migrations
docker compose exec php php artisan migrate
# Seed initial user (admin@example.com / password)
docker compose exec php php artisan db:seed --class=UserSeeder
# Initialize sample documents (optional)
docker compose exec php php artisan docs:init
```
### 6. Build frontend assets
```bash
# Development mode with hot reload
npm run dev
# Or production build
npm run build
```
### 7. Access the application
Open `http://localhost:9700` in your browser.
**Default credentials**:
- Email: `admin@example.com`
- Password: `password`
## Development
### Running the dev environment
```bash
# Start all services (server, queue, logs, Vite)
docker compose exec php composer dev
```
### Running tests
```bash
docker compose exec php php artisan test
```
### Common commands
```bash
# Access PHP container shell
docker compose exec php bash
# Clear caches
docker compose exec php php artisan config:clear
docker compose exec php php artisan cache:clear
docker compose exec php php artisan view:clear
# Publish Livewire assets (after updates)
docker compose exec php php artisan livewire:publish --assets
```
## Project Structure
```
src/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ └── LocaleController.php # Language switching
│ │ └── Middleware/
│ │ └── SetLocale.php # Multi-language support
│ ├── Livewire/
│ │ ├── DocumentEditor.php # Create/edit documents
│ │ ├── DocumentViewer.php # Display documents
│ │ ├── QuickSwitcher.php # Ctrl+K search modal
│ │ └── SidebarTree.php # Folder tree navigation
│ ├── Models/
│ │ ├── Document.php # Document model
│ │ ├── DocumentLink.php # Wiki-style links
│ │ └── RecentDocument.php # Access history
│ └── Services/
│ └── DocumentService.php # Document business logic
├── database/
│ └── migrations/ # Database schema
├── lang/ # Translation files (16 languages)
├── resources/
│ ├── css/
│ │ └── app.css # Tailwind + custom styles
│ ├── js/
│ │ └── app.js # Alpine.js initialization
│ └── views/
│ ├── layouts/
│ │ └── knowledge-base.blade.php # Main layout
│ └── livewire/ # Livewire component views
└── routes/
└── web.php # Application routes
```
## Key Concepts
### Document Organization
Documents are organized using **virtual paths** derived from titles:
```php
Title: "Laravel/Livewire/Components"
→ Path: "Laravel/Livewire/Components.md"
→ Slug: "components"
→ Sidebar: Nested under Laravel → Livewire → Components
```
No manual directory field needed - just use `/` in the title!
### Wiki-Style Links
Create links between documents using double brackets:
```markdown
See [[Getting Started]] for more information.
Links to [[Uncreated Pages]] appear in red.
```
Links are automatically:
- Extracted and stored in the `document_links` table
- Rendered as clickable HTML anchors
- Displayed as backlinks on target documents
### ID-Based Routing
URLs use document IDs instead of slugs:
```
/documents/123 (instead of /documents/my-document-slug)
```
Benefits:
- Guaranteed uniqueness
- Title changes don't break URLs
- Simpler route model binding
### Folder State Persistence
Sidebar folder expanded/collapsed state is stored in `localStorage`:
```javascript
// Managed by Alpine.js
localStorage.getItem('kb_expanded_folders')
// ["Laravel", "Laravel/Livewire", "Docker"]
```
Survives page navigation and browser sessions.
## Customization
### Adding new languages
1. Add to `SetLocale::SUPPORTED_LOCALES` in `app/Http/Middleware/SetLocale.php`
2. Create translation file at `lang/{code}/messages.php`
3. Copy structure from existing language file
### Changing default locale
Edit `config/app.php`:
```php
'locale' => 'en', // Change to your preferred language code
```
### Customizing markdown styles
Edit `resources/css/app.css`:
```css
@layer components {
.prose .wiki-link {
@apply text-indigo-600 hover:text-indigo-800 underline;
}
}
```
## Troubleshooting
### Livewire assets not loading
```bash
docker compose exec php php artisan livewire:publish --assets
```
### Frontend changes not reflecting
```bash
npm run build
docker compose exec php php artisan view:clear
```
### Database connection errors
Check `.env` file matches Docker Compose settings:
```env
DB_CONNECTION=mysql
DB_HOST=kb_mysql
DB_PORT=3306
DB_DATABASE=knowledge_base
DB_USERNAME=kb_user
DB_PASSWORD=kb_password
```
### Alpine.js errors in console
Ensure scripts are loaded in correct order in `knowledge-base.blade.php`:
1. Livewire scripts first
2. Alpine.js initialization (via Vite)
3. Custom Alpine components
## Contributing ## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). Contributions are welcome! Please ensure:
- Code follows Laravel and PSR-12 conventions
## Code of Conduct - All existing tests pass
- New features include tests
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). - UI changes maintain responsive design
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License ## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
## Credits
Built with:
- [Laravel](https://laravel.com) - The PHP Framework
- [Livewire](https://livewire.laravel.com) - Full-stack framework for Laravel
- [Alpine.js](https://alpinejs.dev) - Lightweight JavaScript framework
- [Tailwind CSS](https://tailwindcss.com) - Utility-first CSS framework
- [EasyMDE](https://github.com/Ionaru/easy-markdown-editor) - Markdown editor
- [league/commonmark](https://commonmark.thephpleague.com) - Markdown parser
@@ -33,7 +33,7 @@ public function store(LoginRequest $request): RedirectResponse
return redirect()->intended(route('dashboard', absolute: false)); return redirect()->intended(route('dashboard', absolute: false));
} }
return redirect()->intended('/'); return redirect()->intended(url('/'));
} }
/** /**
@@ -47,6 +47,6 @@ public function destroy(Request $request): RedirectResponse
$request->session()->regenerateToken(); $request->session()->regenerateToken();
return redirect('/'); return redirect(url('/'));
} }
} }
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers;
use App\Http\Middleware\SetLocale;
use App\Models\Document;
use App\Services\DocumentService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class DocumentTranslationController extends Controller
{
public function __construct(private DocumentService $service) {}
public function store(Request $request, Document $document)
{
$validated = $request->validate([
'locale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
try {
$this->service->addTranslation(
$document,
$validated['locale'],
$validated['title'],
$validated['content'],
Auth::id(),
);
} catch (\InvalidArgumentException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return redirect()->route('documents.show', $document)
->with('message', __('messages.documents.translation_added'));
}
public function destroy(Document $document, string $locale)
{
if (!array_key_exists($locale, SetLocale::SUPPORTED_LOCALES)) {
abort(404);
}
try {
$this->service->deleteTranslation($document, $locale);
} catch (\InvalidArgumentException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return redirect()->route('documents.show', $document)
->with('message', __('messages.documents.translation_deleted'));
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImageUploadController extends Controller
{
/**
* Handle image upload from EasyMDE editor
*/
public function upload(Request $request)
{
$request->validate([
'image' => [
'required',
'file',
'mimes:jpeg,jpg,png,gif,webp',
'max:2048', // 2MB
],
]);
$file = $request->file('image');
// Get original filename without extension for alt text
$originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
// Generate unique filename: YYYY/MM/uuid.extension
$year = date('Y');
$month = date('m');
$extension = $file->getClientOriginalExtension();
$filename = Str::uuid() . '.' . $extension;
$path = "images/{$year}/{$month}/{$filename}";
// Store to public disk
Storage::disk('public')->putFileAs(
"images/{$year}/{$month}",
$file,
$filename
);
// Return URL for EasyMDE (use APP_URL)
$url = asset('storage/' . $path);
return response()->json([
'data' => [
'filePath' => $url,
'altText' => $originalName,
],
]);
}
}
+11 -5
View File
@@ -10,6 +10,7 @@ class LocaleController extends Controller
{ {
/** /**
* Update the user's locale preference. * Update the user's locale preference.
* Works for both authenticated and guest users.
*/ */
public function update(Request $request) public function update(Request $request)
{ {
@@ -19,12 +20,17 @@ public function update(Request $request)
$locale = $validated['locale']; $locale = $validated['locale'];
// Save to user record // Save to session for immediate effect
Auth::user()->update(['locale' => $locale]);
// Also save to session for immediate effect
$request->session()->put('locale', $locale); $request->session()->put('locale', $locale);
return redirect()->route('profile.edit')->with('success', __('messages.settings.language_updated')); // If authenticated, also save to user record for persistence
if (Auth::check()) {
Auth::user()->update(['locale' => $locale]);
}
// Set long-lived cookie (1 year) for guest users
cookie()->queue('locale', $locale, 525600); // 525600 minutes = 1 year
return redirect()->back()->with('success', __('messages.settings.language_updated'));
} }
} }
@@ -55,6 +55,6 @@ public function destroy(Request $request): RedirectResponse
$request->session()->invalidate(); $request->session()->invalidate();
$request->session()->regenerateToken(); $request->session()->regenerateToken();
return Redirect::to('/'); return Redirect::to(url('/'));
} }
} }
+34 -6
View File
@@ -11,36 +11,64 @@
class SetLocale class SetLocale
{ {
/** /**
* Supported locales * Supported locales (LTR languages only)
* Order: English, Japanese, Chinese, Korean, Other Asian, European
*/ */
public const SUPPORTED_LOCALES = [ public const SUPPORTED_LOCALES = [
// Primary languages
'en' => 'English', 'en' => 'English',
'ja' => '日本語', 'ja' => '日本語',
// Chinese variants
'zh-CN' => '简体中文',
'zh-TW' => '繁體中文',
// Korean
'ko' => '한국어',
// Other Asian languages
'hi' => 'हिन्दी',
'vi' => 'Tiếng Việt',
'tr' => 'Türkçe',
// European languages
'de' => 'Deutsch', 'de' => 'Deutsch',
'fr' => 'Français', 'fr' => 'Français',
'es' => 'Español', 'es' => 'Español',
'zh-CN' => '简体中文', 'pt-BR' => 'Português (Brasil)',
'zh-TW' => '繁體中文', 'ru' => 'Русский',
'ko' => '한국어', 'uk' => 'Українська',
'it' => 'Italiano',
'pl' => 'Polski',
]; ];
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* Priority order:
* 1. Authenticated user's database preference
* 2. Session (for immediate effect after changing)
* 3. Cookie (long-term storage for guest users)
* 4. Default locale from config
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$locale = config('app.locale', 'en'); $locale = config('app.locale', 'en');
// Check authenticated user's preference // Priority 1: Check authenticated user's preference
if (Auth::check() && Auth::user()->locale) { if (Auth::check() && Auth::user()->locale) {
$locale = Auth::user()->locale; $locale = Auth::user()->locale;
} }
// Check session (for immediate effect after changing) // Priority 2: Check session (for immediate effect after changing)
elseif ($request->session()->has('locale')) { elseif ($request->session()->has('locale')) {
$locale = $request->session()->get('locale'); $locale = $request->session()->get('locale');
} }
// Priority 3: Check cookie (long-term storage for guest users)
elseif ($request->cookie('locale')) {
$locale = $request->cookie('locale');
}
// Validate locale // Validate locale
if (!array_key_exists($locale, self::SUPPORTED_LOCALES)) { if (!array_key_exists($locale, self::SUPPORTED_LOCALES)) {
+91 -26
View File
@@ -2,28 +2,44 @@
namespace App\Livewire; namespace App\Livewire;
use App\Http\Middleware\SetLocale;
use App\Models\Document; use App\Models\Document;
use App\Services\DocumentService; use App\Services\DocumentService;
use Livewire\Component; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentEditor extends Component class DocumentEditor extends Component
{ {
public ?Document $document = null; public ?Document $document = null;
public $title = ''; public string $title = '';
public $content = ''; public string $content = '';
public $directory = ''; public string $editingLocale = '';
public $isEditMode = false; public bool $isEditMode = false;
public bool $isNewLocale = false;
public array $availableLocales = [];
public function mount(?Document $document = null) public function mount(?Document $document = null, ?string $locale = null)
{ {
if ($document) { if ($document) {
$this->document = $document; $this->authorize('update', $document);
$this->title = $document->title; $this->document = $document->load('translations');
$this->content = $document->content;
$this->directory = $document->directory;
$this->isEditMode = true; $this->isEditMode = true;
$this->availableLocales = $document->availableLocales();
$this->editingLocale = $locale ?: ($document->default_locale ?? App::getLocale());
$translation = $document->translations->firstWhere('locale', $this->editingLocale);
if ($translation) {
$this->title = $translation->title;
$this->content = $translation->content;
$this->isNewLocale = false;
} else {
$this->title = '';
$this->content = '';
$this->isNewLocale = true;
}
} else { } else {
$this->editingLocale = App::getLocale();
$titleParam = request()->query('title'); $titleParam = request()->query('title');
if ($titleParam) { if ($titleParam) {
$this->title = $titleParam; $this->title = $titleParam;
@@ -33,54 +49,101 @@ public function mount(?Document $document = null)
public function save(DocumentService $documentService) public function save(DocumentService $documentService)
{ {
$this->validate([ $validated = $this->validate([
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'content' => 'required|string', 'content' => 'required|string',
'editingLocale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
]); ]);
try { try {
if ($this->isEditMode && $this->document) { if ($this->isEditMode && $this->document) {
$this->document = $documentService->updateDocument( $this->authorize('update', $this->document);
$this->document,
$this->title,
$this->content,
Auth::id()
);
session()->flash('message', 'Document updated successfully!'); if ($this->isNewLocale) {
$documentService->addTranslation(
$this->document,
$this->editingLocale,
$this->title,
$this->content,
Auth::id(),
);
$this->document->refresh()->load('translations');
} else {
$this->document = $documentService->updateDocument(
$this->document,
$this->title,
$this->content,
Auth::id(),
$this->editingLocale,
);
}
session()->flash('message', __('messages.documents.update_success'));
return $this->redirect(route('documents.show', $this->document)); return $this->redirect(route('documents.show', $this->document));
} else { } else {
$this->document = $documentService->createDocument( $this->document = $documentService->createDocument(
$this->title, $this->title,
$this->content, $this->content,
Auth::id(), Auth::id(),
$this->directory ?: null $this->editingLocale,
); );
session()->flash('message', __('messages.documents.create_success'));
session()->flash('message', 'Document created successfully!');
return $this->redirect(route('documents.show', $this->document)); return $this->redirect(route('documents.show', $this->document));
} }
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
} catch (\Exception $e) { } catch (\Exception $e) {
session()->flash('error', 'Error saving document: ' . $e->getMessage()); session()->flash('error', 'Error saving document: ' . $e->getMessage());
} }
} }
public function deleteTranslation(DocumentService $documentService)
{
if (!$this->isEditMode || !$this->document || $this->isNewLocale) {
return;
}
$this->authorize('update', $this->document);
try {
$documentService->deleteTranslation($this->document, $this->editingLocale);
session()->flash('message', __('messages.documents.translation_deleted'));
return $this->redirect(route('documents.show', $this->document));
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
}
}
public function setAsDefault(DocumentService $documentService)
{
if (!$this->isEditMode || !$this->document) {
return;
}
$this->authorize('update', $this->document);
try {
$this->document = $documentService->setDefaultLocale($this->document, $this->editingLocale);
session()->flash('message', __('messages.documents.update_success'));
return $this->redirect(route('documents.show', $this->document));
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
}
}
public function delete(DocumentService $documentService) public function delete(DocumentService $documentService)
{ {
if (!$this->isEditMode || !$this->document) { if (!$this->isEditMode || !$this->document) {
return; return;
} }
$this->authorize('delete', $this->document);
try { try {
$documentService->deleteDocument($this->document); $documentService->deleteDocument($this->document);
session()->flash('message', 'Document deleted successfully!'); session()->flash('message', __('messages.documents.delete_success'));
// Try to redirect to home document, or root if not found
$homeDocument = Document::where('slug', 'home')->first(); $homeDocument = Document::where('slug', 'home')->first();
if ($homeDocument) { if ($homeDocument) {
return redirect()->route('documents.show', $homeDocument); return redirect()->route('documents.show', $homeDocument);
} }
return redirect('/'); return redirect(url('/'));
} catch (\Exception $e) { } catch (\Exception $e) {
session()->flash('error', 'Error deleting document: ' . $e->getMessage()); session()->flash('error', 'Error deleting document: ' . $e->getMessage());
} }
@@ -90,7 +153,9 @@ public function render()
{ {
return view('livewire.document-editor') return view('livewire.document-editor')
->layout('layouts.knowledge-base', [ ->layout('layouts.knowledge-base', [
'title' => $this->isEditMode ? 'Edit: ' . $this->title : 'New Document' 'title' => $this->isEditMode
? __('messages.documents.edit_document') . ': ' . $this->title
: __('messages.documents.new_document'),
]); ]);
} }
} }
+13 -6
View File
@@ -4,25 +4,32 @@
use App\Models\Document; use App\Models\Document;
use App\Services\DocumentService; use App\Services\DocumentService;
use Livewire\Component; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentViewer extends Component class DocumentViewer extends Component
{ {
public Document $document; public Document $document;
public $backlinks = []; public $backlinks = [];
public $renderedContent = ''; public string $renderedContent = '';
public string $viewLocale = '';
public bool $isFallback = false;
public function mount(Document $document, DocumentService $documentService) public function mount(Document $document, DocumentService $documentService)
{ {
$this->document = $document; $this->document = $document->load('translations');
$this->renderedContent = $this->document->processLinks(); $current = App::getLocale();
$translation = $document->translationFor($current, fallback: true);
$this->backlinks = $documentService->getBacklinks($this->document); $this->viewLocale = $translation?->locale ?? $document->default_locale;
$this->isFallback = ($current !== $this->viewLocale);
$this->renderedContent = $document->processLinks();
$this->backlinks = $documentService->getBacklinks($document);
if (Auth::check()) { if (Auth::check()) {
$documentService->recordDocumentAccess($this->document, Auth::id()); $documentService->recordDocumentAccess($document, Auth::id());
} }
} }
+15 -50
View File
@@ -4,65 +4,32 @@
use App\Models\Document; use App\Models\Document;
use App\Services\DocumentService; use App\Services\DocumentService;
use Livewire\Component;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Component;
class QuickSwitcher extends Component class QuickSwitcher extends Component
{ {
public $search = ''; public string $search = '';
public $selectedIndex = 0; public int $selectedIndex = 0;
#[Computed] #[Computed]
public function results() public function results()
{ {
if (empty($this->search)) { if (empty($this->search)) {
return Document::select('id', 'title', 'slug', 'path', 'updated_at') $documents = Document::with('translations')
->orderBy('updated_at', 'desc') ->orderBy('updated_at', 'desc')
->limit(10) ->limit(10)
->get() ->get();
->map(fn($doc) => [ } else {
'id' => $doc->id, $documents = app(DocumentService::class)->search($this->search, 10);
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
} }
// FULLTEXT検索を使用(日本語対応) return $documents->map(fn ($doc) => [
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at') 'id' => $doc->id,
->whereRaw('MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', [$this->search]) 'title' => $doc->title,
->orderBy('updated_at', 'desc') 'slug' => $doc->slug,
->limit(10) 'directory' => dirname($doc->path),
->get() ])->values()->toArray();
->map(fn($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
// FULLTEXT検索で結果がない場合は LIKE 検索にフォールバック
if (empty($results)) {
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
->where(function($query) {
$query->where('title', 'like', '%' . $this->search . '%')
->orWhere('content', 'like', '%' . $this->search . '%');
})
->orderBy('updated_at', 'desc')
->limit(10)
->get()
->map(fn($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
}
return $results;
} }
public function updated($propertyName) public function updated($propertyName)
@@ -92,10 +59,8 @@ public function selectDocument()
$results = $this->results; $results = $this->results;
if (isset($results[$this->selectedIndex])) { if (isset($results[$this->selectedIndex])) {
$document = $results[$this->selectedIndex]; $document = $results[$this->selectedIndex];
if (!empty($document['slug'])) {
// id が存在することを確認 return $this->redirect(route('documents.show', $document['slug']));
if (!empty($document['id'])) {
return $this->redirect(route('documents.show', $document['id']));
} }
} }
} }
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Markdown;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
class MediaEmbedExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$listener = new MediaEmbedListener(new MediaUrlResolver());
$environment->addEventListener(DocumentParsedEvent::class, [$listener, 'handle']);
$environment->addRenderer(MediaEmbedNode::class, new MediaEmbedNodeRenderer());
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Markdown;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
class MediaEmbedListener
{
public function __construct(private readonly MediaUrlResolver $resolver)
{
}
public function handle(DocumentParsedEvent $event): void
{
$imagesToReplace = [];
foreach ($event->getDocument()->iterator() as $node) {
if ($node instanceof Image) {
$imagesToReplace[] = $node;
}
}
foreach ($imagesToReplace as $image) {
$html = $this->resolver->resolve($image->getUrl());
if ($html !== null) {
$image->replaceWith(new MediaEmbedNode($html));
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Markdown;
use League\CommonMark\Node\Inline\AbstractStringContainer;
/**
* A custom inline node for programmatically generated media embeds.
*
* Unlike HtmlInline, this node does NOT implement RawMarkupContainerInterface,
* so its renderer bypasses the html_input filter entirely, allowing us to emit
* safe, programmatically constructed HTML even when html_input is set to 'strip'.
*/
class MediaEmbedNode extends AbstractStringContainer
{
}
@@ -0,0 +1,28 @@
<?php
namespace App\Markdown;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
/**
* Renders a MediaEmbedNode by emitting its literal content directly,
* without going through any html_input filtering.
*/
class MediaEmbedNodeRenderer implements NodeRendererInterface
{
/**
* @param MediaEmbedNode $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
MediaEmbedNode::assertInstanceOf($node);
return $node->getLiteral();
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Markdown;
class MediaUrlResolver
{
private const VIDEO_EXT = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
private const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'm4a'];
public function resolve(string $url): ?string
{
if ($url === '') {
return null;
}
return $this->detectVideo($url)
?? $this->detectAudio($url)
?? $this->detectYouTube($url)
?? $this->detectVimeo($url);
}
private function detectVideo(string $url): ?string
{
if (!in_array($this->getPathExtension($url), self::VIDEO_EXT, true)) {
return null;
}
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
return "<video src=\"{$safe}\" controls class=\"kb-video\"></video>";
}
private function detectAudio(string $url): ?string
{
if (!in_array($this->getPathExtension($url), self::AUDIO_EXT, true)) {
return null;
}
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
return "<audio src=\"{$safe}\" controls class=\"kb-audio\"></audio>";
}
private function detectYouTube(string $url): ?string
{
$patterns = [
'~^https?://youtu\.be/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#]*&)?v=([A-Za-z0-9_-]{11})(?:[&#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/shorts/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/embed/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
];
$videoId = null;
foreach ($patterns as $p) {
if (preg_match($p, $url, $m)) {
$videoId = $m[1];
break;
}
}
if ($videoId === null) {
return null;
}
$src = "https://www.youtube-nocookie.com/embed/{$videoId}";
$start = $this->extractYouTubeStart($url);
if ($start !== null) {
$src .= "?start={$start}";
}
return $this->iframeHtml($src, 'youtube');
}
private function extractYouTubeStart(string $url): ?int
{
if (preg_match('/[?&]t=([^&#]+)/', $url, $m)) {
$seconds = $this->parseTimestamp($m[1]);
if ($seconds !== null) {
return $seconds;
}
}
if (preg_match('/[?&]start=(\d+)/', $url, $m)) {
return (int) $m[1];
}
return null;
}
private function parseTimestamp(string $t): ?int
{
if (ctype_digit($t)) {
return (int) $t;
}
$total = 0;
$matched = false;
if (preg_match('/(\d+)h/', $t, $m)) {
$total += (int) $m[1] * 3600;
$matched = true;
}
if (preg_match('/(\d+)m/', $t, $m)) {
$total += (int) $m[1] * 60;
$matched = true;
}
if (preg_match('/(\d+)s/', $t, $m)) {
$total += (int) $m[1];
$matched = true;
}
return $matched ? $total : null;
}
private function detectVimeo(string $url): ?string
{
if (!preg_match('~^https?://(?:www\.|player\.)?vimeo\.com/(?:video/)?(\d+)(?![A-Za-z0-9])~', $url, $m)) {
return null;
}
$videoId = $m[1];
$src = "https://player.vimeo.com/video/{$videoId}?dnt=1";
$hash = $this->extractVimeoHash($url);
if ($hash !== null) {
$src .= '#' . $hash;
}
return $this->iframeHtml($src, 'vimeo');
}
private function extractVimeoHash(string $url): ?string
{
if (preg_match('/#(t=[^&]+)/', $url, $m)) {
return $m[1];
}
if (preg_match('/[?&](t=[^&#]+)/', $url, $m)) {
return $m[1];
}
return null;
}
private function iframeHtml(string $src, string $provider): string
{
$safe = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
return '<iframe src="' . $safe . '" '
. 'width="560" height="315" '
. 'loading="lazy" '
. 'referrerpolicy="strict-origin-when-cross-origin" '
. 'allow="autoplay; encrypted-media; picture-in-picture" '
. 'allowfullscreen frameborder="0" '
. 'class="kb-embed kb-embed-' . $provider . '"></iframe>';
}
private function getPathExtension(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
if ($path === null || $path === false) {
return '';
}
return strtolower(pathinfo($path, PATHINFO_EXTENSION));
}
}
+144 -190
View File
@@ -3,33 +3,25 @@
namespace App\Models; namespace App\Models;
use App\Helpers\SlugHelper; use App\Helpers\SlugHelper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
class Document extends Model class Document extends Model
{ {
use SoftDeletes; use HasFactory, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [ protected $fillable = [
'path', 'path',
'title',
'slug', 'slug',
'content', 'default_locale',
'rendered_html',
'frontmatter', 'frontmatter',
'file_size', 'file_size',
'file_hash', 'file_hash',
@@ -38,11 +30,6 @@ class Document extends Model
'updated_by', 'updated_by',
]; ];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -51,191 +38,64 @@ protected function casts(): array
]; ];
} }
/** public function getRouteKeyName(): string
* Frontmatterをパース(互換性のため残す)
*
* @param string $content
* @return array{frontmatter: array, content: string}
*/
protected static function parseFrontmatter(string $content): array
{ {
$frontmatter = []; return 'slug';
$bodyContent = $content; }
// Frontmatterの検出(--- で囲まれた部分) public function resolveRouteBinding($value, $field = null)
if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)/s', $content, $matches)) { {
$frontmatterText = $matches[1]; $document = $this->where('slug', $value)->first();
$bodyContent = $matches[2];
// 簡易的なYAMLパース(key: value形式のみ) if (!$document && is_numeric($value)) {
$lines = explode("\n", $frontmatterText); $document = $this->where('id', $value)->first();
foreach ($lines as $line) {
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $lineMatches)) {
$frontmatter[trim($lineMatches[1])] = trim($lineMatches[2]);
}
}
} }
return [ return $document;
'frontmatter' => $frontmatter,
'content' => trim($bodyContent),
];
} }
/** /**
* Markdownをレンダリング * Backward-compatible static delegate so existing callers and tests
* * (e.g. MediaEmbedExtensionTest) keep working.
* @param string $markdown
* @return string
*/ */
public static function renderMarkdown(string $markdown): string public static function renderMarkdown(string $markdown): string
{ {
$converter = new CommonMarkConverter([ return DocumentTranslation::renderMarkdown($markdown);
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
return $converter->convert($markdown)->getContent();
} }
/** // ----- Relations -----
* [[wiki-link]]を抽出してリンクテーブルに同期
* public function translations(): HasMany
* @return void
*/
public function syncLinks(): void
{ {
// 既存のリンクを削除 return $this->hasMany(DocumentTranslation::class);
$this->outgoingLinks()->delete();
// [[wiki-link]]を抽出
preg_match_all('/\[\[([^\]]+)\]\]/', $this->content, $matches);
if (empty($matches[1])) {
return;
}
$position = 0;
foreach ($matches[1] as $linkTitle) {
$linkTitle = trim($linkTitle);
// リンク先のドキュメントを検索
$targetDocument = static::where('title', $linkTitle)
->orWhere('slug', SlugHelper::generate($linkTitle))
->first();
DocumentLink::create([
'source_document_id' => $this->id,
'target_document_id' => $targetDocument?->id,
'target_title' => $linkTitle,
'position' => $position++,
]);
}
} }
/** public function defaultTranslation(): HasOne
* [[wiki-link]]をHTMLリンクに変換
*
* @return string
*/
public function processLinks(): string
{ {
return preg_replace_callback( return $this->hasOne(DocumentTranslation::class)
'/\[\[([^\]]+)\]\]/', ->whereColumn('locale', 'documents.default_locale');
function ($matches) {
$linkTitle = trim($matches[1]);
$slug = SlugHelper::generate($linkTitle);
// リンク先のドキュメントを検索
$targetDocument = static::where('title', $linkTitle)
->orWhere('slug', $slug)
->first();
if ($targetDocument) {
return '<a href="/documents/' . $targetDocument->slug . '" class="wiki-link">' . e($linkTitle) . '</a>';
} else {
return '<a href="/documents/create?title=' . urlencode($linkTitle) . '" class="wiki-link wiki-link-new">' . e($linkTitle) . '</a>';
}
},
$this->rendered_html
);
} }
/**
* 全文検索スコープ
*
* @param Builder $query
* @param string $searchTerm
* @return Builder
*/
public function scopeSearch(Builder $query, string $searchTerm): Builder
{
return $query->whereRaw(
'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)',
[$searchTerm]
);
}
/**
* ディレクトリ内検索スコープ
*
* @param Builder $query
* @param string $directory
* @return Builder
*/
public function scopeInDirectory(Builder $query, string $directory): Builder
{
$directory = rtrim($directory, '/') . '/';
return $query->where('path', 'like', $directory . '%');
}
/**
* 作成者リレーション
*
* @return BelongsTo
*/
public function creator(): BelongsTo public function creator(): BelongsTo
{ {
return $this->belongsTo(User::class, 'created_by'); return $this->belongsTo(User::class, 'created_by');
} }
/**
* 更新者リレーション
*
* @return BelongsTo
*/
public function updater(): BelongsTo public function updater(): BelongsTo
{ {
return $this->belongsTo(User::class, 'updated_by'); return $this->belongsTo(User::class, 'updated_by');
} }
/**
* 発リンク(このドキュメントから他へのリンク)
*
* @return HasMany
*/
public function outgoingLinks(): HasMany public function outgoingLinks(): HasMany
{ {
return $this->hasMany(DocumentLink::class, 'source_document_id'); return $this->hasMany(DocumentLink::class, 'source_document_id');
} }
/**
* 被リンク(他のドキュメントからこのドキュメントへのリンク)
*
* @return HasMany
*/
public function incomingLinks(): HasMany public function incomingLinks(): HasMany
{ {
return $this->hasMany(DocumentLink::class, 'target_document_id'); return $this->hasMany(DocumentLink::class, 'target_document_id');
} }
/**
* このドキュメントを最近閲覧したユーザー
*
* @return HasManyThrough
*/
public function recentByUsers(): HasManyThrough public function recentByUsers(): HasManyThrough
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
@@ -248,48 +108,142 @@ public function recentByUsers(): HasManyThrough
); );
} }
// ----- Translation helpers -----
public function translationFor(string $locale, bool $fallback = true): ?DocumentTranslation
{
$translation = $this->translations->firstWhere('locale', $locale);
if (!$translation && $fallback) {
$translation = $this->translations->firstWhere('locale', $this->default_locale);
}
return $translation;
}
public function isFallback(string $requestedLocale): bool
{
return $this->translations->firstWhere('locale', $requestedLocale) === null;
}
/** /**
* ディレクトリパスを取得 * @return array<int, string>
*
* @return string
*/ */
public function availableLocales(): array
{
return $this->translations->pluck('locale')->all();
}
// ----- Accessors (current-locale → fallback) -----
public function getTitleAttribute(): string
{
return $this->translationFor(App::getLocale())?->title ?? '';
}
public function getContentAttribute(): string
{
return $this->translationFor(App::getLocale())?->content ?? '';
}
public function getRenderedHtmlAttribute(): ?string
{
return $this->translationFor(App::getLocale())?->rendered_html;
}
// ----- Path helpers -----
public function getDirectoryAttribute(): string public function getDirectoryAttribute(): string
{ {
return dirname($this->path); return dirname($this->path);
} }
/**
* ファイル名を取得
*
* @return string
*/
public function getFilenameAttribute(): string public function getFilenameAttribute(): string
{ {
return basename($this->path); return basename($this->path);
} }
/**
* 絶対パスを取得
*
* @return string
*/
public function getAbsolutePathAttribute(): string public function getAbsolutePathAttribute(): string
{ {
return Storage::disk('markdown')->path($this->path); return Storage::disk('markdown')->path($this->path);
} }
/** // ----- Search scope (delegates to translations) -----
* タイトルセット時にslugも自動生成
*
* @param string $value
* @return void
*/
public function setTitleAttribute(string $value): void
{
$this->attributes['title'] = $value;
if (empty($this->attributes['slug'])) { public function scopeSearch(Builder $query, string $term): Builder
$this->attributes['slug'] = SlugHelper::generate($value); {
return $query->whereHas('translations', function (Builder $q) use ($term) {
DocumentTranslation::scopeSearch($q, $term);
});
}
public function scopeInDirectory(Builder $query, string $directory): Builder
{
$directory = rtrim($directory, '/') . '/';
return $query->where('path', 'like', $directory . '%');
}
/**
* Extract [[wiki-links]] from the default-locale translation's content
* and persist them via DocumentLink.
*/
public function syncLinks(): void
{
$this->outgoingLinks()->delete();
$translation = $this->translationFor($this->default_locale, fallback: false);
if (!$translation || !$translation->content) {
return;
}
preg_match_all('/\[\[([^\]]+)\]\]/', $translation->content, $matches);
if (empty($matches[1])) {
return;
}
$resolver = new \App\Services\WikiLinkResolver();
$position = 0;
foreach ($matches[1] as $linkTitle) {
$linkTitle = trim($linkTitle);
$target = $resolver->resolve($linkTitle, $this->default_locale);
DocumentLink::create([
'source_document_id' => $this->id,
'target_document_id' => $target?->id,
'target_title' => $linkTitle,
'position' => $position++,
]);
} }
} }
/**
* Convert [[wiki-links]] in the current-locale rendered_html to anchor tags.
* Link labels stay in the original language; the destination document is
* resolved against the current locale (with fallback).
*/
public function processLinks(): string
{
$html = $this->rendered_html ?? '';
if ($html === '') {
return '';
}
$resolver = new \App\Services\WikiLinkResolver();
$currentLocale = App::getLocale();
return preg_replace_callback(
'/\[\[([^\]]+)\]\]/',
function ($matches) use ($resolver, $currentLocale) {
$linkText = trim($matches[1]);
$target = $resolver->resolve($linkText, $currentLocale);
if ($target) {
return '<a href="' . route('documents.show', $target->slug) . '" class="wiki-link">' . e($linkText) . '</a>';
}
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkText) . '" class="wiki-link wiki-link-new">' . e($linkText) . '</a>';
},
$html
);
}
} }
+2 -2
View File
@@ -57,9 +57,9 @@ public function isBroken(): bool
public function getUrlAttribute(): string public function getUrlAttribute(): string
{ {
if ($this->isBroken()) { if ($this->isBroken()) {
return '/documents/create?title=' . urlencode($this->target_title); return route('documents.create') . '?title=' . urlencode($this->target_title);
} }
return '/documents/' . $this->targetDocument->slug; return route('documents.show', $this->targetDocument->slug);
} }
} }
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
class DocumentTranslation extends Model
{
use HasFactory;
protected $fillable = [
'document_id',
'locale',
'title',
'content',
'rendered_html',
'created_by',
'updated_by',
];
public function document(): BelongsTo
{
return $this->belongsTo(Document::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* Full-text search scope. Falls back to LIKE on non-MySQL drivers
* (notably SQLite in tests, which lacks FULLTEXT).
*/
public function scopeSearch(Builder $query, string $term): Builder
{
if ($query->getConnection()->getDriverName() === 'mysql') {
return $query->whereRaw(
'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)',
[$term]
);
}
return $query->where(function (Builder $q) use ($term) {
$like = '%' . $term . '%';
$q->where('title', 'like', $like)->orWhere('content', 'like', $like);
});
}
public static function renderMarkdown(string $markdown): string
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
return $converter->convert($markdown)->getContent();
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Policies;
use App\Models\Document;
use App\Models\User;
class DocumentPolicy
{
public function before(User $user): ?bool
{
return $user->isAdmin() ? true : null;
}
public function view(User $user, Document $document): bool
{
return true;
}
public function create(User $user): bool
{
return true;
}
public function update(User $user, Document $document): bool
{
return $document->created_by === $user->id;
}
public function delete(User $user, Document $document): bool
{
return $document->created_by === $user->id;
}
}
+128 -180
View File
@@ -2,143 +2,185 @@
namespace App\Services; namespace App\Services;
use App\Models\Document;
use App\Models\RecentDocument;
use App\Helpers\SlugHelper; use App\Helpers\SlugHelper;
use Illuminate\Support\Facades\Storage; use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\RecentDocument;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DocumentService class DocumentService
{ {
/**
* 新しいドキュメントを作成
*
* @param string $title
* @param string $content
* @param int|null $userId
* @param string|null $directory (deprecated - path is now auto-generated from title)
* @return Document
*/
public function createDocument( public function createDocument(
string $title, string $title,
string $content, string $content,
?int $userId = null, ?int $userId = null,
?string $directory = null ?string $locale = null,
): Document { ): Document {
// タイトルからパスとスラッグを自動生成 $locale = $locale ?: App::getLocale();
// 例: "Laravel/Livewire/Components" → path="Laravel/Livewire/Components.md", slug="components"
[$path, $slug] = $this->generatePathAndSlug($title); [$path, $slug] = $this->generatePathAndSlug($title);
// ドキュメントをDBに作成 return DB::transaction(function () use ($title, $content, $userId, $locale, $path, $slug) {
$document = Document::create([ $document = Document::create([
'path' => $path, 'path' => $path,
'title' => $title, 'slug' => $slug,
'slug' => $slug, 'default_locale' => $locale,
'content' => $content, 'created_by' => $userId,
'rendered_html' => Document::renderMarkdown($content), 'updated_by' => $userId,
'created_by' => $userId, ]);
'updated_by' => $userId,
]);
// リンクを同期 DocumentTranslation::create([
$document->syncLinks(); 'document_id' => $document->id,
'locale' => $locale,
'title' => $title,
'content' => $content,
'rendered_html' => DocumentTranslation::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
return $document; $document->load('translations');
$document->syncLinks();
return $document;
});
} }
/**
* ドキュメントを更新
*
* @param Document $document
* @param string $title
* @param string $content
* @param int|null $userId
* @return Document
*/
public function updateDocument( public function updateDocument(
Document $document, Document $document,
string $title, string $title,
string $content, string $content,
?int $userId = null ?int $userId = null,
?string $locale = null,
): Document { ): Document {
// タイトルが変更された場合はパスとスラッグを再生成 $locale = $locale ?: App::getLocale();
if ($document->title !== $title) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
$document->title = $title; return DB::transaction(function () use ($document, $title, $content, $userId, $locale) {
$document->content = $content; $translation = $document->translations()->firstOrNew(['locale' => $locale]);
$document->rendered_html = Document::renderMarkdown($content); $translation->title = $title;
$document->updated_by = $userId; $translation->content = $content;
$translation->rendered_html = DocumentTranslation::renderMarkdown($content);
$translation->updated_by = $userId;
if (!$translation->exists) {
$translation->created_by = $userId;
}
$translation->save();
// DBに保存 $document->updated_by = $userId;
$document->save();
// リンクを再同期 // Path/slug regenerate only when editing the default-locale translation
$document->syncLinks(); if ($locale === $document->default_locale) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
return $document; $document->save();
$document->load('translations');
$document->syncLinks();
return $document;
});
}
public function addTranslation(
Document $document,
string $locale,
string $title,
string $content,
?int $userId = null,
): DocumentTranslation {
if ($document->translations()->where('locale', $locale)->exists()) {
throw new \InvalidArgumentException("Translation for locale '$locale' already exists");
}
return DocumentTranslation::create([
'document_id' => $document->id,
'locale' => $locale,
'title' => $title,
'content' => $content,
'rendered_html' => DocumentTranslation::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
}
public function deleteTranslation(Document $document, string $locale): void
{
if ($locale === $document->default_locale) {
throw new \InvalidArgumentException("Cannot delete default-locale translation '$locale'");
}
$document->translations()->where('locale', $locale)->delete();
}
public function setDefaultLocale(Document $document, string $locale): Document
{
$translation = $document->translations()->where('locale', $locale)->first();
if (!$translation) {
throw new \InvalidArgumentException("Cannot set default to '$locale': translation does not exist");
}
return DB::transaction(function () use ($document, $locale, $translation) {
$document->default_locale = $locale;
[$path, $slug] = $this->generatePathAndSlug($translation->title, $document->id);
$document->path = $path;
$document->slug = $slug;
$document->save();
return $document->fresh('translations');
});
} }
/**
* ドキュメントを削除
*
* @param Document $document
* @return bool
*/
public function deleteDocument(Document $document): bool public function deleteDocument(Document $document): bool
{ {
// DBから削除(ソフトデリート)
return $document->delete(); return $document->delete();
} }
/** /**
* 全文検索 * Locale-agnostic full-text search; returns distinct documents.
*
* @param string $query
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
*/ */
public function search(string $query, int $limit = 20) public function search(string $query, int $limit = 20)
{ {
return Document::search($query) $documentIds = DocumentTranslation::query()
->limit($limit) ->search($query)
->get(); ->limit($limit * 5) // overscan to allow distinct collapse
->pluck('document_id')
->unique()
->values()
->take($limit);
if ($documentIds->isEmpty()) {
return Document::query()->whereRaw('1 = 0')->get();
}
return Document::with('translations')
->whereIn('id', $documentIds)
->get()
->sortBy(fn ($d) => $documentIds->search($d->id))
->values();
}
public function findByTitle(string $title, ?string $locale = null): ?Document
{
return (new WikiLinkResolver())->resolve($title, $locale ?: App::getLocale());
} }
/**
* ディレクトリツリーを生成
*
* @return array
*/
public function getDirectoryTree(): array public function getDirectoryTree(): array
{ {
$documents = Document::orderBy('path')->get(); $documents = Document::with('translations')->orderBy('path')->get();
$tree = []; $tree = [];
foreach ($documents as $document) { foreach ($documents as $document) {
$parts = explode('/', $document->path); $parts = explode('/', $document->path);
$current = &$tree; $current = &$tree;
foreach ($parts as $index => $part) { foreach ($parts as $index => $part) {
$isFile = ($index === count($parts) - 1); $isFile = ($index === count($parts) - 1);
if ($isFile) { if ($isFile) {
// ファイル
if (!isset($current['_files'])) {
$current['_files'] = [];
}
$current['_files'][] = [ $current['_files'][] = [
'name' => $part, 'name' => $part,
'document' => $document, 'document' => $document,
]; ];
} else { } else {
// ディレクトリ
if (!isset($current[$part])) { if (!isset($current[$part])) {
$current[$part] = []; $current[$part] = [];
} }
@@ -146,67 +188,28 @@ public function getDirectoryTree(): array
} }
} }
} }
return $tree; return $tree;
} }
/**
* ユーザーの最近閲覧したドキュメントを取得
*
* @param int $userId
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getRecentDocuments(int $userId, int $limit = 10) public function getRecentDocuments(int $userId, int $limit = 10)
{ {
return RecentDocument::getRecentForUser($userId, $limit); return RecentDocument::getRecentForUser($userId, $limit);
} }
/**
* ドキュメント閲覧を記録
*
* @param Document $document
* @param int $userId
* @return void
*/
public function recordDocumentAccess(Document $document, int $userId): void public function recordDocumentAccess(Document $document, int $userId): void
{ {
RecentDocument::recordAccess($userId, $document->id); RecentDocument::recordAccess($userId, $document->id);
} }
/**
* 指定タイトルのドキュメントを検索
*
* @param string $title
* @return Document|null
*/
public function findByTitle(string $title): ?Document
{
return Document::where('title', $title)
->orWhere('slug', SlugHelper::generate($title))
->first();
}
/**
* 被リンク(バックリンク)を取得
*
* @param Document $document
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBacklinks(Document $document) public function getBacklinks(Document $document)
{ {
return $document->incomingLinks() return $document->incomingLinks()
->with('sourceDocument') ->with('sourceDocument.translations')
->get() ->get()
->pluck('sourceDocument') ->pluck('sourceDocument')
->filter(); ->filter();
} }
/**
* 壊れたリンク(未作成ページへのリンク)を取得
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBrokenLinks() public function getBrokenLinks()
{ {
return DB::table('document_links') return DB::table('document_links')
@@ -217,39 +220,13 @@ public function getBrokenLinks()
->get(); ->get();
} }
/**
* タイトルからパスとスラッグを生成
* タイトルに含まれる / をディレクトリ区切りとして扱う
*
* : "Laravel/Livewire/Components"
* path = "Laravel/Livewire/Components.md"
* slug = "components" (最後のコンポーネントから生成)
*
* @param string $title
* @param int|null $excludeDocumentId 更新時に除外するドキュメントID
* @return array [path, slug]
*/
private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array
{ {
// タイトルをそのままパスとして使用(.md拡張子を追加)
$basePath = $title . '.md'; $basePath = $title . '.md';
$baseSlug = SlugHelper::generate(basename($title));
// 最後のパスコンポーネント(スラッシュで区切られた最後の部分)からスラッグを生成
$lastComponent = basename($title);
$baseSlug = SlugHelper::generate($lastComponent);
// ユニークなパスとスラッグを生成
return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId); return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId);
} }
/**
* パスとスラッグがユニークになるように調整
*
* @param string $basePath
* @param string $baseSlug
* @param int|null $excludeDocumentId
* @return array [path, slug]
*/
private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array
{ {
$path = $basePath; $path = $basePath;
@@ -261,46 +238,17 @@ private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excl
->where(function ($q) use ($path, $slug) { ->where(function ($q) use ($path, $slug) {
$q->where('path', $path)->orWhere('slug', $slug); $q->where('path', $path)->orWhere('slug', $slug);
}); });
if ($excludeDocumentId) { if ($excludeDocumentId) {
$query->where('id', '!=', $excludeDocumentId); $query->where('id', '!=', $excludeDocumentId);
} }
if (!$query->exists()) { if (!$query->exists()) {
break; break;
} }
$counter++; $counter++;
// パス: "title.md" → "title-2.md"
$path = preg_replace('/\.md$/', "-{$counter}.md", $basePath); $path = preg_replace('/\.md$/', "-{$counter}.md", $basePath);
// スラッグ: "title" → "title-2"
$slug = $baseSlug . '-' . $counter; $slug = $baseSlug . '-' . $counter;
} }
return [$path, $slug]; return [$path, $slug];
} }
/**
* 初期ドキュメントを作成
*
* @return void
*/
public function createInitialDocuments(): void
{
// ホームページ
$this->createDocument(
'Home',
"# Welcome to Knowledge Base\n\nThis is your personal knowledge base powered by Markdown.\n\n## Getting Started\n\n- Create new documents using [[wiki-links]]\n- Use Ctrl+K for quick switching\n- Full-text search is available\n\n## Example Links\n\n- [[Getting Started]]\n- [[Documentation]]\n- [[Notes]]",
null,
null
);
// Getting Startedページ
$this->createDocument(
'Getting Started',
"# Getting Started\n\nLearn how to use this knowledge base.\n\n## Creating Documents\n\nClick on any [[wiki-link]] to create a new document.\n\n## Editing\n\nClick the edit button to modify content.\n\nBack to [[Home]]",
null,
null
);
}
} }
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Services;
use App\Helpers\SlugHelper;
use App\Models\Document;
use App\Models\DocumentTranslation;
class WikiLinkResolver
{
/**
* Resolve [[wiki-link]] text to a Document, preferring the current locale.
*
* Resolution order:
* 1. translations WHERE locale = $currentLocale AND title = $linkText
* 2. translations WHERE locale = document.default_locale AND title = $linkText
* 3. translations WHERE title = $linkText (lowest document_id wins)
* 4. documents WHERE slug = SlugHelper::generate($linkText)
* 5. null
*/
public function resolve(string $linkText, string $currentLocale): ?Document
{
$linkText = trim($linkText);
// 1. Current-locale exact title match
$byCurrent = DocumentTranslation::where('locale', $currentLocale)
->where('title', $linkText)
->orderBy('document_id')
->first();
if ($byCurrent) {
return $byCurrent->document;
}
// 2. Document's default-locale title match
$byDefault = DocumentTranslation::query()
->join('documents', 'documents.id', '=', 'document_translations.document_id')
->whereColumn('document_translations.locale', 'documents.default_locale')
->where('document_translations.title', $linkText)
->orderBy('document_translations.document_id')
->select('document_translations.*')
->first();
if ($byDefault) {
return $byDefault->document;
}
// 3. Any-locale title match (lowest document_id wins)
$byAny = DocumentTranslation::where('title', $linkText)
->orderBy('document_id')
->first();
if ($byAny) {
return $byAny->document;
}
// 4. Slug match (legacy)
$slug = SlugHelper::generate($linkText);
$bySlug = Document::where('slug', $slug)->first();
if ($bySlug) {
return $bySlug;
}
// 5. Nothing
return null;
}
}
+4 -4
View File
@@ -6,10 +6,10 @@
"keywords": ["laravel", "framework"], "keywords": ["laravel", "framework"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.3",
"cocur/slugify": "^4.7", "cocur/slugify": "^4.7",
"laravel/framework": "^12.0", "laravel/framework": "^13.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^3.0",
"league/commonmark": "^2.8", "league/commonmark": "^2.8",
"livewire/livewire": "^3.7" "livewire/livewire": "^3.7"
}, },
@@ -21,7 +21,7 @@
"laravel/sail": "^1.41", "laravel/sail": "^1.41",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3" "phpunit/phpunit": "^12.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+867 -865
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -65,7 +65,7 @@
| |
*/ */
'timezone' => 'UTC', 'timezone' => env('APP_TIMEZONE', 'Asia/Tokyo'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
+125
View File
@@ -0,0 +1,125 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Livewire Assets URL
|--------------------------------------------------------------------------
|
| This value sets the path to Livewire JavaScript assets, for cases where
| your app's domain root is not the correct path. By default, Livewire
| will load its JavaScript assets from the app's "relative root".
|
| Examples: "/assets", "myurl.com/app".
|
*/
'asset_url' => null,
/*
|--------------------------------------------------------------------------
| Livewire App URL
|--------------------------------------------------------------------------
|
| This value should be used if livewire assets are served from CDN.
| Livewire will communicate with an app through this url.
|
| Examples: "https://my-app.com", "myurl.com/app".
|
*/
'app_url' => env('APP_URL'),
/*
|--------------------------------------------------------------------------
| Livewire Update Endpoint
|--------------------------------------------------------------------------
|
| The endpoint for Livewire AJAX requests.
|
*/
'update_endpoint' => '/livewire/update',
/*
|--------------------------------------------------------------------------
| Livewire Endpoint Middleware Group
|--------------------------------------------------------------------------
|
| This value sets the middleware group that will be applied to the main
| Livewire "message" endpoint (the endpoint that gets hit everytime
| a Livewire component updates). It is set to "web" by default.
|
*/
'middleware_group' => 'web',
/*
|--------------------------------------------------------------------------
| Livewire Temporary File Uploads Endpoint Configuration
|--------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed
| to a global endpoint for temporary storage. Here you may configure.
|
*/
'temporary_file_upload' => [
'disk' => null,
'rules' => null,
'directory' => null,
'middleware' => null,
'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 5,
'cleanup' => true,
],
/*
|--------------------------------------------------------------------------
| Render On Redirect
|--------------------------------------------------------------------------
|
| This value determines if Livewire will render before it's redirected
| or not. Setting it to "false" will mean the render method is skipped
| when performing a redirect, potentially improving performances.
|
*/
'render_on_redirect' => false,
/*
|--------------------------------------------------------------------------
| Navigate (SPA mode)
|--------------------------------------------------------------------------
|
| By default, Livewire uses the "replace" strategy for SPA navigation
| which can sometimes cause issues with subdirectory deployments.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|--------------------------------------------------------------------------
| Inject Assets
|--------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and styles
| into the <head> and <body> of your pages. If you want to disable
| this behavior and manually include assets, set this to false.
|
*/
'inject_assets' => true,
];
@@ -0,0 +1,65 @@
<?php
namespace Database\Factories;
use App\Helpers\SlugHelper;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Document>
*/
class DocumentFactory extends Factory
{
protected $model = Document::class;
public function definition(): array
{
$title = rtrim(fake()->unique()->words(3, true), '.');
return [
'path' => $title . '.md',
'slug' => SlugHelper::generate($title),
'default_locale' => 'en',
'file_size' => 0,
'file_hash' => str_repeat('0', 64),
'file_modified_at' => now(),
];
}
/**
* After creating, attach a translation in the document's default_locale
* (skipped if a translation was already created via state, or if the
* caller used withoutTranslations()).
*/
public function configure(): static
{
return $this->afterCreating(function (Document $document) {
if ($document->translations()->count() === 0) {
DocumentTranslation::factory()->create([
'document_id' => $document->id,
'locale' => $document->default_locale,
]);
}
});
}
/**
* Override the default_locale (auto-translation will be created in this locale).
*/
public function defaultLocale(string $locale): static
{
return $this->state(['default_locale' => $locale]);
}
/**
* Suppress automatic translation creation. Uses Laravel's built-in
* withoutAfterCreating() to clear callbacks rather than appending a no-op
* (afterCreating appends, so a no-op closure does NOT override the configure() callback).
*/
public function withoutTranslations(): static
{
return $this->withoutAfterCreating();
}
}
@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<DocumentTranslation>
*/
class DocumentTranslationFactory extends Factory
{
protected $model = DocumentTranslation::class;
public function definition(): array
{
$title = fake()->sentence(3);
$content = fake()->paragraphs(3, true);
return [
'document_id' => Document::factory()->withoutTranslations(),
'locale' => 'en',
'title' => $title,
'content' => $content,
'rendered_html' => '<p>' . e($content) . '</p>',
];
}
}
@@ -2,6 +2,7 @@
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
@@ -27,7 +28,9 @@ public function up(): void
// FULLTEXT検索インデックス(MySQL 5.7以降) // FULLTEXT検索インデックス(MySQL 5.7以降)
// ngramトークナイザーは日本語対応に必要だが、設定が必要 // ngramトークナイザーは日本語対応に必要だが、設定が必要
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram'); if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
}
// 検索パフォーマンス向上用インデックス // 検索パフォーマンス向上用インデックス
Schema::table('documents', function (Blueprint $table) { Schema::table('documents', function (Blueprint $table) {
@@ -0,0 +1,115 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Add default_locale to documents
Schema::table('documents', function (Blueprint $table) {
$table->string('default_locale', 10)
->default(config('app.locale', 'en'))
->after('slug');
});
// 2. Create document_translations
Schema::create('document_translations', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained('documents')->cascadeOnDelete();
$table->string('locale', 10);
$table->string('title');
$table->text('content');
$table->text('rendered_html')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['document_id', 'locale']);
$table->index(['locale', 'title']);
});
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE document_translations ADD FULLTEXT INDEX document_translations_search_index (title, content) WITH PARSER ngram');
}
// 3. Migrate existing data
// Note: step 4 (ALTER TABLE … DROP INDEX) must remain OUTSIDE this
// transaction because MySQL's ALTER TABLE causes an implicit commit.
$defaultLocale = config('app.locale', 'en');
$now = now();
DB::transaction(function () use ($defaultLocale, $now) {
DB::table('documents')->orderBy('id')->chunkById(500, function ($rows) use ($defaultLocale, $now) {
foreach ($rows as $row) {
DB::table('document_translations')->insert([
'document_id' => $row->id,
'locale' => $defaultLocale,
'title' => $row->title ?? '',
'content' => $row->content ?? '',
'rendered_html' => $row->rendered_html,
'created_by' => $row->created_by ?? null,
'updated_by' => $row->updated_by ?? null,
'created_at' => $row->created_at ?? $now,
'updated_at' => $row->updated_at ?? $now,
]);
DB::table('documents')->where('id', $row->id)->update(['default_locale' => $defaultLocale]);
}
});
});
// 4. Drop the old FULLTEXT index on documents (MySQL only)
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents DROP INDEX documents_search_index');
}
// 5. Drop translatable columns from documents
// Drop the title index first (SQLite requires this before dropping the column)
Schema::table('documents', function (Blueprint $table) {
$table->dropIndex(['title']);
$table->dropColumn(['title', 'content', 'rendered_html']);
});
}
public function down(): void
{
// IRREVERSIBLE for non-default-locale translations: only the row matching
// each document's default_locale is restored to the legacy columns; any
// other-locale translations are dropped along with document_translations.
// Re-add columns (with the title index that up() expects to drop)
Schema::table('documents', function (Blueprint $table) {
$table->string('title')->nullable()->index()->after('default_locale');
$table->text('content')->nullable()->after('title');
$table->text('rendered_html')->nullable()->after('content');
});
// Restore data from default_locale translation
$rows = DB::table('document_translations as t')
->join('documents as d', 'd.id', '=', 't.document_id')
->whereColumn('t.locale', 'd.default_locale')
->select('t.document_id', 't.title', 't.content', 't.rendered_html')
->get();
foreach ($rows as $row) {
DB::table('documents')->where('id', $row->document_id)->update([
'title' => $row->title,
'content' => $row->content,
'rendered_html' => $row->rendered_html,
]);
}
// Restore FULLTEXT on documents (MySQL only)
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
}
Schema::dropIfExists('document_translations');
Schema::table('documents', function (Blueprint $table) {
$table->dropColumn('default_locale');
});
}
};
+11 -32
View File
@@ -2,7 +2,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Document;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DocumentSeeder extends Seeder class DocumentSeeder extends Seeder
@@ -12,43 +11,23 @@ class DocumentSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// 既存のドキュメントがある場合はスキップ if (\App\Models\Document::count() > 0) {
if (Document::count() > 0) {
$this->command->info('Documents already exist. Skipping...'); $this->command->info('Documents already exist. Skipping...');
return; return;
} }
$documents = [ $service = app(\App\Services\DocumentService::class);
[ $defaultLocale = config('app.locale', 'en');
'title' => 'Home',
'path' => 'Home.md', $docs = [
'slug' => 'home', ['title' => 'Home', 'content' => $this->getHomeContent()],
'content' => $this->getHomeContent(), ['title' => 'Getting Started', 'content' => $this->getGettingStartedContent()],
], ['title' => 'Markdown Guide', 'content' => $this->getMarkdownGuideContent()],
[
'title' => 'Getting Started',
'path' => 'Getting Started.md',
'slug' => 'getting-started',
'content' => $this->getGettingStartedContent(),
],
[
'title' => 'Markdown Guide',
'path' => 'Markdown Guide.md',
'slug' => 'markdown-guide',
'content' => $this->getMarkdownGuideContent(),
],
]; ];
foreach ($documents as $doc) { foreach ($docs as $d) {
Document::create([ $service->createDocument($d['title'], $d['content'], null, $defaultLocale);
'title' => $doc['title'], $this->command->info("Created: {$d['title']}");
'path' => $doc['path'],
'slug' => $doc['slug'],
'content' => $doc['content'],
'rendered_html' => Document::renderMarkdown($doc['content']),
]);
$this->command->info("Created: {$doc['title']}");
} }
$this->command->info('Initial documents created successfully!'); $this->command->info('Initial documents created successfully!');
+35
View File
@@ -39,6 +39,14 @@
'content_label' => 'Inhalt', 'content_label' => 'Inhalt',
'content_placeholder' => 'Schreiben Sie hier Ihren Markdown...', 'content_placeholder' => 'Schreiben Sie hier Ihren Markdown...',
'saving' => 'Speichern...', 'saving' => 'Speichern...',
'fallback_notice' => 'Eine Übersetzung dieses Artikels in der von Ihnen gewählten Sprache ist nicht verfügbar. Die Originalsprachversion wird angezeigt.',
'add_translation' => 'Übersetzung hinzufügen',
'translation_added' => 'Übersetzung hinzugefügt.',
'translation_deleted' => 'Übersetzung gelöscht.',
'set_as_default' => 'Als Standard festlegen',
'delete_translation' => 'Übersetzung löschen',
'delete_translation_blocked' => 'Die Übersetzung der Standardsprache kann nicht gelöscht werden.',
'translation_tabs_label' => 'Sprachen',
], ],
// Quick Switcher // Quick Switcher
@@ -115,6 +123,33 @@
'already_registered' => 'Bereits registriert?', 'already_registered' => 'Bereits registriert?',
], ],
// Errors
'errors' => [
'404_title' => 'Seite nicht gefunden',
'page_not_found' => 'Seite nicht gefunden',
'page_not_found_description' => 'Die gesuchte Seite konnte nicht gefunden werden.',
'back_to_home' => 'Zurück zur Startseite',
],
'locale_names' => [
'en' => 'Englisch',
'ja' => 'Japanisch',
'zh-CN' => 'Chinesisch (vereinfacht)',
'zh-TW' => 'Chinesisch (traditionell)',
'ko' => 'Koreanisch',
'hi' => 'Hindi',
'vi' => 'Vietnamesisch',
'tr' => 'Türkisch',
'de' => 'Deutsch',
'fr' => 'Französisch',
'es' => 'Spanisch',
'pt-BR' => 'Portugiesisch (Brasilien)',
'ru' => 'Russisch',
'uk' => 'Ukrainisch',
'it' => 'Italienisch',
'pl' => 'Polnisch',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profil', 'title' => 'Profil',
+35
View File
@@ -39,6 +39,14 @@
'content_label' => 'Content', 'content_label' => 'Content',
'content_placeholder' => 'Write your markdown here...', 'content_placeholder' => 'Write your markdown here...',
'saving' => 'Saving...', 'saving' => 'Saving...',
'translation_added' => 'Translation added.',
'translation_deleted' => 'Translation deleted.',
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
'add_translation' => 'Add translation',
'set_as_default' => 'Set as default',
'delete_translation' => 'Delete translation',
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
'translation_tabs_label' => 'Languages',
], ],
// Quick Switcher // Quick Switcher
@@ -115,6 +123,33 @@
'already_registered' => 'Already registered?', 'already_registered' => 'Already registered?',
], ],
// Errors
'errors' => [
'404_title' => 'Page Not Found',
'page_not_found' => 'Page Not Found',
'page_not_found_description' => 'The page you are looking for could not be found.',
'back_to_home' => 'Back to Home',
],
'locale_names' => [
'en' => 'English',
'ja' => 'Japanese',
'zh-CN' => 'Simplified Chinese',
'zh-TW' => 'Traditional Chinese',
'ko' => 'Korean',
'hi' => 'Hindi',
'vi' => 'Vietnamese',
'tr' => 'Turkish',
'de' => 'German',
'fr' => 'French',
'es' => 'Spanish',
'pt-BR' => 'Portuguese (Brazil)',
'ru' => 'Russian',
'uk' => 'Ukrainian',
'it' => 'Italian',
'pl' => 'Polish',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profile', 'title' => 'Profile',
+35
View File
@@ -39,6 +39,14 @@
'content_label' => 'Contenido', 'content_label' => 'Contenido',
'content_placeholder' => 'Escriba su markdown aquí...', 'content_placeholder' => 'Escriba su markdown aquí...',
'saving' => 'Guardando...', 'saving' => 'Guardando...',
'fallback_notice' => 'Este artículo no está traducido al idioma seleccionado. Se muestra la versión en el idioma original.',
'add_translation' => 'Añadir traducción',
'translation_added' => 'Traducción añadida.',
'translation_deleted' => 'Traducción eliminada.',
'set_as_default' => 'Establecer como predeterminado',
'delete_translation' => 'Eliminar traducción',
'delete_translation_blocked' => 'No se puede eliminar la traducción del idioma predeterminado.',
'translation_tabs_label' => 'Idiomas',
], ],
// Quick Switcher // Quick Switcher
@@ -115,6 +123,33 @@
'already_registered' => '¿Ya está registrado?', 'already_registered' => '¿Ya está registrado?',
], ],
// Errors
'errors' => [
'404_title' => 'Página no encontrada',
'page_not_found' => 'Página no encontrada',
'page_not_found_description' => 'No se pudo encontrar la página que está buscando.',
'back_to_home' => 'Volver al inicio',
],
'locale_names' => [
'en' => 'Inglés',
'ja' => 'Japonés',
'zh-CN' => 'Chino (simplificado)',
'zh-TW' => 'Chino (tradicional)',
'ko' => 'Coreano',
'hi' => 'Hindi',
'vi' => 'Vietnamita',
'tr' => 'Turco',
'de' => 'Alemán',
'fr' => 'Francés',
'es' => 'Español',
'pt-BR' => 'Portugués (Brasil)',
'ru' => 'Ruso',
'uk' => 'Ucraniano',
'it' => 'Italiano',
'pl' => 'Polaco',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Perfil', 'title' => 'Perfil',
+35
View File
@@ -39,6 +39,14 @@
'content_label' => 'Contenu', 'content_label' => 'Contenu',
'content_placeholder' => 'Écrivez votre markdown ici...', 'content_placeholder' => 'Écrivez votre markdown ici...',
'saving' => 'Enregistrement...', 'saving' => 'Enregistrement...',
'fallback_notice' => 'Cet article n\'est pas traduit dans la langue sélectionnée. La version dans la langue d\'origine est affichée.',
'add_translation' => 'Ajouter une traduction',
'translation_added' => 'Traduction ajoutée.',
'translation_deleted' => 'Traduction supprimée.',
'set_as_default' => 'Définir par défaut',
'delete_translation' => 'Supprimer la traduction',
'delete_translation_blocked' => 'La traduction de la langue par défaut ne peut pas être supprimée.',
'translation_tabs_label' => 'Langues',
], ],
// Quick Switcher // Quick Switcher
@@ -115,6 +123,33 @@
'already_registered' => 'Déjà inscrit ?', 'already_registered' => 'Déjà inscrit ?',
], ],
// Errors
'errors' => [
'404_title' => 'Page non trouvée',
'page_not_found' => 'Page non trouvée',
'page_not_found_description' => 'La page que vous recherchez est introuvable.',
'back_to_home' => 'Retour à l\'accueil',
],
'locale_names' => [
'en' => 'Anglais',
'ja' => 'Japonais',
'zh-CN' => 'Chinois (simplifié)',
'zh-TW' => 'Chinois (traditionnel)',
'ko' => 'Coréen',
'hi' => 'Hindi',
'vi' => 'Vietnamien',
'tr' => 'Turc',
'de' => 'Allemand',
'fr' => 'Français',
'es' => 'Espagnol',
'pt-BR' => 'Portugais (Brésil)',
'ru' => 'Russe',
'uk' => 'Ukrainien',
'it' => 'Italien',
'pl' => 'Polonais',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profil', 'title' => 'Profil',
+175
View File
@@ -0,0 +1,175 @@
<?php
return [
// Navigation
'nav' => [
'dashboard' => 'डैशबोर्ड',
'knowledge_base' => 'ज्ञान आधार',
'profile' => 'प्रोफ़ाइल',
'user_management' => 'उपयोगकर्ता प्रबंधन',
'logout' => 'लॉग आउट',
'login' => 'लॉगिन',
'register' => 'रजिस्टर',
],
// Documents
'documents' => [
'title' => 'दस्तावेज़',
'new_document' => 'नया दस्तावेज़',
'edit_document' => 'दस्तावेज़ संपादित करें',
'edit' => 'संपादित करें',
'delete' => 'हटाएं',
'save' => 'सहेजें',
'cancel' => 'रद्द करें',
'created_by' => 'द्वारा बनाया गया',
'modified_by' => 'द्वारा',
'updated' => 'अपडेट किया गया',
'path' => 'पथ',
'last_modified' => 'अंतिम संशोधन',
'no_documents' => 'कोई दस्तावेज़ नहीं मिला',
'search_placeholder' => 'दस्तावेज़ खोजें...',
'create_success' => 'दस्तावेज़ सफलतापूर्वक बनाया गया!',
'update_success' => 'दस्तावेज़ सफलतापूर्वक अपडेट किया गया!',
'delete_success' => 'दस्तावेज़ सफलतापूर्वक हटाया गया!',
'delete_confirm' => 'क्या आप वाकई इस दस्तावेज़ को हटाना चाहते हैं?',
'linked_references' => 'लिंक किए गए संदर्भ',
'title_label' => 'शीर्षक',
'title_placeholder' => 'दस्तावेज़ शीर्षक (फ़ोल्डर के लिए / का उपयोग करें, जैसे Laravel/Livewire/Components)',
'title_hint' => 'सुझाव: दस्तावेज़ों को स्वचालित रूप से फ़ोल्डर में व्यवस्थित करने के लिए शीर्षक में स्लैश (/) का उपयोग करें',
'content_label' => 'सामग्री',
'content_placeholder' => 'यहां अपना मार्कडाउन लिखें...',
'saving' => 'सहेजा जा रहा है...',
'fallback_notice' => 'इस लेख का आपकी चुनी गई भाषा में अनुवाद उपलब्ध नहीं है। मूल भाषा का संस्करण दिखाया जा रहा है।',
'add_translation' => 'अनुवाद जोड़ें',
'translation_added' => 'अनुवाद जोड़ा गया।',
'translation_deleted' => 'अनुवाद हटाया गया।',
'set_as_default' => 'डिफ़ॉल्ट के रूप में सेट करें',
'delete_translation' => 'अनुवाद हटाएं',
'delete_translation_blocked' => 'डिफ़ॉल्ट भाषा का अनुवाद हटाया नहीं जा सकता।',
'translation_tabs_label' => 'भाषाएँ',
],
// Quick Switcher
'quick_switcher' => [
'title' => 'त्वरित स्विच',
'placeholder' => 'दस्तावेज़ खोजें...',
'no_results' => 'कोई दस्तावेज़ नहीं मिला',
'navigate' => 'नेविगेट करने के लिए',
'select' => 'चुनने के लिए',
'close' => 'बंद करने के लिए',
],
// Admin
'admin' => [
'user_management' => 'उपयोगकर्ता प्रबंधन',
'new_user' => 'नया उपयोगकर्ता',
'edit_user' => 'उपयोगकर्ता संपादित करें',
'create_user' => 'उपयोगकर्ता बनाएं',
'users' => 'उपयोगकर्ता',
'name' => 'नाम',
'email' => 'ईमेल',
'password' => 'पासवर्ड',
'password_confirmation' => 'पासवर्ड की पुष्टि करें',
'password_hint' => 'वर्तमान पासवर्ड रखने के लिए खाली छोड़ दें।',
'role' => 'भूमिका',
'admin' => 'प्रशासक',
'user' => 'उपयोगकर्ता',
'grant_admin' => 'प्रशासक विशेषाधिकार प्रदान करें',
'created_at' => 'बनाया गया',
'actions' => 'क्रियाएं',
'edit' => 'संपादित करें',
'delete' => 'हटाएं',
'no_users' => 'कोई उपयोगकर्ता नहीं मिला।',
'create_success' => 'उपयोगकर्ता सफलतापूर्वक बनाया गया।',
'update_success' => 'उपयोगकर्ता सफलतापूर्वक अपडेट किया गया।',
'delete_success' => 'उपयोगकर्ता सफलतापूर्वक हटाया गया।',
'cannot_delete_self' => 'आप स्वयं को हटा नहीं सकते।',
'self_admin_warning' => 'अपने स्वयं के प्रशासक विशेषाधिकारों को हटाने से आपका प्रशासन पैनल तक पहुंच अवरुद्ध हो जाएगी।',
],
// Settings
'settings' => [
'language' => 'भाषा',
'select_language' => 'भाषा चुनें',
'language_updated' => 'भाषा सफलतापूर्वक अपडेट की गई।',
'change_language' => 'भाषा बदलें',
],
// Common
'common' => [
'save' => 'सहेजें',
'cancel' => 'रद्द करें',
'delete' => 'हटाएं',
'edit' => 'संपादित करें',
'create' => 'बनाएं',
'update' => 'अपडेट करें',
'back' => 'वापस',
'confirm' => 'पुष्टि करें',
'yes' => 'हां',
'no' => 'नहीं',
'loading' => 'लोड हो रहा है...',
'error' => 'त्रुटि',
'success' => 'सफलता',
],
// Auth
'auth' => [
'login' => 'लॉगिन',
'register' => 'रजिस्टर',
'email' => 'ईमेल',
'password' => 'पासवर्ड',
'remember_me' => 'मुझे याद रखें',
'forgot_password' => 'पासवर्ड भूल गए?',
'confirm_password' => 'पासवर्ड की पुष्टि करें',
'already_registered' => 'पहले से रजिस्टर्ड?',
],
// Errors
'errors' => [
'404_title' => 'पेज नहीं मिला',
'page_not_found' => 'पेज नहीं मिला',
'page_not_found_description' => 'आप जिस पेज की तलाश कर रहे हैं वह नहीं मिला।',
'back_to_home' => 'होम पर वापस जाएं',
],
'locale_names' => [
'en' => 'अंग्रेज़ी',
'ja' => 'जापानी',
'zh-CN' => 'चीनी (सरलीकृत)',
'zh-TW' => 'चीनी (पारंपरिक)',
'ko' => 'कोरियाई',
'hi' => 'हिन्दी',
'vi' => 'वियतनामी',
'tr' => 'तुर्की',
'de' => 'जर्मन',
'fr' => 'फ़्रेंच',
'es' => 'स्पेनिश',
'pt-BR' => 'पुर्तगाली (ब्राज़ील)',
'ru' => 'रूसी',
'uk' => 'यूक्रेनी',
'it' => 'इतालवी',
'pl' => 'पोलिश',
],
// Profile
'profile' => [
'title' => 'प्रोफ़ाइल',
'information' => 'प्रोफ़ाइल जानकारी',
'information_description' => 'अपने खाते की प्रोफ़ाइल जानकारी और ईमेल पता अपडेट करें।',
'name' => 'नाम',
'email' => 'ईमेल',
'email_unverified' => 'आपका ईमेल पता सत्यापित नहीं है।',
'resend_verification' => 'सत्यापन ईमेल फिर से भेजने के लिए यहां क्लिक करें।',
'verification_sent' => 'आपके ईमेल पते पर एक नया सत्यापन लिंक भेजा गया है।',
'saved' => 'सहेजा गया।',
'update_password' => 'पासवर्ड अपडेट करें',
'update_password_description' => 'सुनिश्चित करें कि आपका खाता सुरक्षित रहने के लिए लंबे, यादृच्छिक पासवर्ड का उपयोग कर रहा है।',
'current_password' => 'वर्तमान पासवर्ड',
'new_password' => 'नया पासवर्ड',
'confirm_password' => 'पासवर्ड की पुष्टि करें',
'delete_account' => 'खाता हटाएं',
'delete_account_description' => 'एक बार आपका खाता हटा दिए जाने के बाद, इसके सभी संसाधन और डेटा स्थायी रूप से हटा दिए जाएंगे। अपना खाता हटाने से पहले, कृपया कोई भी डेटा या जानकारी डाउनलोड करें जिसे आप बनाए रखना चाहते हैं।',
'delete_account_confirm' => 'क्या आप वाकई अपना खाता हटाना चाहते हैं?',
'delete_account_confirm_description' => 'एक बार आपका खाता हटा दिए जाने के बाद, इसके सभी संसाधन और डेटा स्थायी रूप से हटा दिए जाएंगे। कृपया पुष्टि करने के लिए अपना पासवर्ड दर्ज करें कि आप स्थायी रूप से अपना खाता हटाना चाहते हैं।',
],
];
+175
View File
@@ -0,0 +1,175 @@
<?php
return [
// Navigation
'nav' => [
'dashboard' => 'Pannello',
'knowledge_base' => 'Base di Conoscenza',
'profile' => 'Profilo',
'user_management' => 'Gestione Utenti',
'logout' => 'Esci',
'login' => 'Accedi',
'register' => 'Registrati',
],
// Documents
'documents' => [
'title' => 'Documenti',
'new_document' => 'Nuovo Documento',
'edit_document' => 'Modifica Documento',
'edit' => 'Modifica',
'delete' => 'Elimina',
'save' => 'Salva',
'cancel' => 'Annulla',
'created_by' => 'Creato da',
'modified_by' => 'da',
'updated' => 'Aggiornato',
'path' => 'Percorso',
'last_modified' => 'Ultima modifica',
'no_documents' => 'Nessun documento trovato',
'search_placeholder' => 'Cerca documenti...',
'create_success' => 'Documento creato con successo!',
'update_success' => 'Documento aggiornato con successo!',
'delete_success' => 'Documento eliminato con successo!',
'delete_confirm' => 'Sei sicuro di voler eliminare questo documento?',
'linked_references' => 'Riferimenti Collegati',
'title_label' => 'Titolo',
'title_placeholder' => 'Titolo del Documento (usa / per le cartelle, es: Laravel/Livewire/Components)',
'title_hint' => 'Suggerimento: Usa le barre (/) nel titolo per organizzare automaticamente i documenti in cartelle',
'content_label' => 'Contenuto',
'content_placeholder' => 'Scrivi il tuo markdown qui...',
'saving' => 'Salvataggio...',
'fallback_notice' => 'Questo articolo non è tradotto nella lingua selezionata. Viene mostrata la versione nella lingua originale.',
'add_translation' => 'Aggiungi traduzione',
'translation_added' => 'Traduzione aggiunta.',
'translation_deleted' => 'Traduzione eliminata.',
'set_as_default' => 'Imposta come predefinita',
'delete_translation' => 'Elimina traduzione',
'delete_translation_blocked' => 'La traduzione della lingua predefinita non può essere eliminata.',
'translation_tabs_label' => 'Lingue',
],
// Quick Switcher
'quick_switcher' => [
'title' => 'Cambio Rapido',
'placeholder' => 'Cerca documenti...',
'no_results' => 'Nessun documento trovato',
'navigate' => 'per navigare',
'select' => 'per selezionare',
'close' => 'per chiudere',
],
// Admin
'admin' => [
'user_management' => 'Gestione Utenti',
'new_user' => 'Nuovo Utente',
'edit_user' => 'Modifica Utente',
'create_user' => 'Crea Utente',
'users' => 'Utenti',
'name' => 'Nome',
'email' => 'Email',
'password' => 'Password',
'password_confirmation' => 'Conferma Password',
'password_hint' => 'Lascia vuoto per mantenere la password attuale.',
'role' => 'Ruolo',
'admin' => 'Amministratore',
'user' => 'Utente',
'grant_admin' => 'Concedi privilegi di amministratore',
'created_at' => 'Creato',
'actions' => 'Azioni',
'edit' => 'Modifica',
'delete' => 'Elimina',
'no_users' => 'Nessun utente trovato.',
'create_success' => 'Utente creato con successo.',
'update_success' => 'Utente aggiornato con successo.',
'delete_success' => 'Utente eliminato con successo.',
'cannot_delete_self' => 'Non puoi eliminare te stesso.',
'self_admin_warning' => 'Rimuovere i propri privilegi di amministratore bloccherà il tuo accesso al pannello di amministrazione.',
],
// Settings
'settings' => [
'language' => 'Lingua',
'select_language' => 'Seleziona Lingua',
'language_updated' => 'Lingua aggiornata con successo.',
'change_language' => 'Cambia lingua',
],
// Common
'common' => [
'save' => 'Salva',
'cancel' => 'Annulla',
'delete' => 'Elimina',
'edit' => 'Modifica',
'create' => 'Crea',
'update' => 'Aggiorna',
'back' => 'Indietro',
'confirm' => 'Conferma',
'yes' => 'Sì',
'no' => 'No',
'loading' => 'Caricamento...',
'error' => 'Errore',
'success' => 'Successo',
],
// Auth
'auth' => [
'login' => 'Accedi',
'register' => 'Registrati',
'email' => 'Email',
'password' => 'Password',
'remember_me' => 'Ricordami',
'forgot_password' => 'Password dimenticata?',
'confirm_password' => 'Conferma Password',
'already_registered' => 'Già registrato?',
],
// Errors
'errors' => [
'404_title' => 'Pagina Non Trovata',
'page_not_found' => 'Pagina Non Trovata',
'page_not_found_description' => 'La pagina che stai cercando non è stata trovata.',
'back_to_home' => 'Torna alla Home',
],
'locale_names' => [
'en' => 'Inglese',
'ja' => 'Giapponese',
'zh-CN' => 'Cinese (semplificato)',
'zh-TW' => 'Cinese (tradizionale)',
'ko' => 'Coreano',
'hi' => 'Hindi',
'vi' => 'Vietnamita',
'tr' => 'Turco',
'de' => 'Tedesco',
'fr' => 'Francese',
'es' => 'Spagnolo',
'pt-BR' => 'Portoghese (Brasile)',
'ru' => 'Russo',
'uk' => 'Ucraino',
'it' => 'Italiano',
'pl' => 'Polacco',
],
// Profile
'profile' => [
'title' => 'Profilo',
'information' => 'Informazioni Profilo',
'information_description' => 'Aggiorna le informazioni del profilo e l\'indirizzo email del tuo account.',
'name' => 'Nome',
'email' => 'Email',
'email_unverified' => 'Il tuo indirizzo email non è verificato.',
'resend_verification' => 'Clicca qui per inviare nuovamente l\'email di verifica.',
'verification_sent' => 'Un nuovo link di verifica è stato inviato al tuo indirizzo email.',
'saved' => 'Salvato.',
'update_password' => 'Aggiorna Password',
'update_password_description' => 'Assicurati che il tuo account utilizzi una password lunga e casuale per rimanere sicuro.',
'current_password' => 'Password Attuale',
'new_password' => 'Nuova Password',
'confirm_password' => 'Conferma Password',
'delete_account' => 'Elimina Account',
'delete_account_description' => 'Una volta eliminato il tuo account, tutte le sue risorse e dati saranno cancellati permanentemente. Prima di eliminare il tuo account, scarica eventuali dati o informazioni che desideri conservare.',
'delete_account_confirm' => 'Sei sicuro di voler eliminare il tuo account?',
'delete_account_confirm_description' => 'Una volta eliminato il tuo account, tutte le sue risorse e dati saranno cancellati permanentemente. Inserisci la tua password per confermare che desideri eliminare permanentemente il tuo account.',
],
];
+24
View File
@@ -39,6 +39,14 @@
'content_label' => '本文', 'content_label' => '本文',
'content_placeholder' => 'Markdownで記述してください...', 'content_placeholder' => 'Markdownで記述してください...',
'saving' => '保存中...', 'saving' => '保存中...',
'translation_added' => '翻訳を追加しました。',
'translation_deleted' => '翻訳を削除しました。',
'fallback_notice' => 'この記事には選択した言語の翻訳がありません。元の言語版を表示しています。',
'add_translation' => '翻訳を追加',
'set_as_default' => 'デフォルトに設定',
'delete_translation' => '翻訳を削除',
'delete_translation_blocked' => 'デフォルト言語の翻訳は削除できません。',
'translation_tabs_label' => '言語',
], ],
// Quick Switcher // Quick Switcher
@@ -115,6 +123,22 @@
'already_registered' => '既にアカウントをお持ちですか?', 'already_registered' => '既にアカウントをお持ちですか?',
], ],
// Errors
'errors' => [
'404_title' => 'ページが見つかりません',
'page_not_found' => 'ページが見つかりません',
'page_not_found_description' => 'お探しのページは見つかりませんでした。',
'back_to_home' => 'ホームに戻る',
],
'locale_names' => [
'en' => '英語', 'ja' => '日本語',
'zh-CN' => '簡体字中国語', 'zh-TW' => '繁体字中国語',
'ko' => '韓国語', 'hi' => 'ヒンディー語', 'vi' => 'ベトナム語', 'tr' => 'トルコ語',
'de' => 'ドイツ語', 'fr' => 'フランス語', 'es' => 'スペイン語', 'pt-BR' => 'ポルトガル語(ブラジル)',
'ru' => 'ロシア語', 'uk' => 'ウクライナ語', 'it' => 'イタリア語', 'pl' => 'ポーランド語',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'プロフィール', 'title' => 'プロフィール',
+35
View File
@@ -39,6 +39,14 @@
'content_label' => '내용', 'content_label' => '내용',
'content_placeholder' => '여기에 마크다운을 작성하세요...', 'content_placeholder' => '여기에 마크다운을 작성하세요...',
'saving' => '저장 중...', 'saving' => '저장 중...',
'fallback_notice' => '이 문서에는 선택한 언어의 번역이 없습니다. 원본 언어 버전을 표시합니다.',
'add_translation' => '번역 추가',
'translation_added' => '번역이 추가되었습니다.',
'translation_deleted' => '번역이 삭제되었습니다.',
'set_as_default' => '기본값으로 설정',
'delete_translation' => '번역 삭제',
'delete_translation_blocked' => '기본 언어의 번역은 삭제할 수 없습니다.',
'translation_tabs_label' => '언어',
], ],
// Quick Switcher // Quick Switcher
@@ -115,6 +123,33 @@
'already_registered' => '이미 계정이 있으신가요?', 'already_registered' => '이미 계정이 있으신가요?',
], ],
// Errors
'errors' => [
'404_title' => '페이지를 찾을 수 없습니다',
'page_not_found' => '페이지를 찾을 수 없습니다',
'page_not_found_description' => '찾고 계신 페이지를 찾을 수 없습니다.',
'back_to_home' => '홈으로 돌아가기',
],
'locale_names' => [
'en' => '영어',
'ja' => '일본어',
'zh-CN' => '중국어 간체',
'zh-TW' => '중국어 번체',
'ko' => '한국어',
'hi' => '힌디어',
'vi' => '베트남어',
'tr' => '터키어',
'de' => '독일어',
'fr' => '프랑스어',
'es' => '스페인어',
'pt-BR' => '포르투갈어 (브라질)',
'ru' => '러시아어',
'uk' => '우크라이나어',
'it' => '이탈리아어',
'pl' => '폴란드어',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => '프로필', 'title' => '프로필',
+175
View File
@@ -0,0 +1,175 @@
<?php
return [
// Navigation
'nav' => [
'dashboard' => 'Panel',
'knowledge_base' => 'Baza wiedzy',
'profile' => 'Profil',
'user_management' => 'Zarządzanie użytkownikami',
'logout' => 'Wyloguj',
'login' => 'Zaloguj',
'register' => 'Zarejestruj',
],
// Documents
'documents' => [
'title' => 'Dokumenty',
'new_document' => 'Nowy dokument',
'edit_document' => 'Edytuj dokument',
'edit' => 'Edytuj',
'delete' => 'Usuń',
'save' => 'Zapisz',
'cancel' => 'Anuluj',
'created_by' => 'Utworzony przez',
'modified_by' => 'przez',
'updated' => 'Zaktualizowano',
'path' => 'Ścieżka',
'last_modified' => 'Ostatnia modyfikacja',
'no_documents' => 'Nie znaleziono dokumentów',
'search_placeholder' => 'Szukaj dokumentów...',
'create_success' => 'Dokument został utworzony!',
'update_success' => 'Dokument został zaktualizowany!',
'delete_success' => 'Dokument został usunięty!',
'delete_confirm' => 'Czy na pewno chcesz usunąć ten dokument?',
'linked_references' => 'Powiązane odniesienia',
'title_label' => 'Tytuł',
'title_placeholder' => 'Tytuł dokumentu (użyj / dla folderów, np. Laravel/Livewire/Components)',
'title_hint' => 'Wskazówka: Użyj ukośników (/) w tytule, aby automatycznie organizować dokumenty w foldery',
'content_label' => 'Treść',
'content_placeholder' => 'Napisz swój markdown tutaj...',
'saving' => 'Zapisywanie...',
'fallback_notice' => 'Ten artykuł nie został przetłumaczony na wybrany język. Wyświetlana jest wersja w języku oryginału.',
'add_translation' => 'Dodaj tłumaczenie',
'translation_added' => 'Tłumaczenie dodane.',
'translation_deleted' => 'Tłumaczenie usunięte.',
'set_as_default' => 'Ustaw jako domyślny',
'delete_translation' => 'Usuń tłumaczenie',
'delete_translation_blocked' => 'Nie można usunąć tłumaczenia w języku domyślnym.',
'translation_tabs_label' => 'Języki',
],
// Quick Switcher
'quick_switcher' => [
'title' => 'Szybkie przełączanie',
'placeholder' => 'Szukaj dokumentów...',
'no_results' => 'Nie znaleziono dokumentów',
'navigate' => 'aby nawigować',
'select' => 'aby wybrać',
'close' => 'aby zamknąć',
],
// Admin
'admin' => [
'user_management' => 'Zarządzanie użytkownikami',
'new_user' => 'Nowy użytkownik',
'edit_user' => 'Edytuj użytkownika',
'create_user' => 'Utwórz użytkownika',
'users' => 'Użytkownicy',
'name' => 'Nazwa',
'email' => 'E-mail',
'password' => 'Hasło',
'password_confirmation' => 'Potwierdź hasło',
'password_hint' => 'Pozostaw puste, aby zachować obecne hasło.',
'role' => 'Rola',
'admin' => 'Administrator',
'user' => 'Użytkownik',
'grant_admin' => 'Przyznaj uprawnienia administratora',
'created_at' => 'Utworzono',
'actions' => 'Działania',
'edit' => 'Edytuj',
'delete' => 'Usuń',
'no_users' => 'Nie znaleziono użytkowników.',
'create_success' => 'Użytkownik został utworzony.',
'update_success' => 'Użytkownik został zaktualizowany.',
'delete_success' => 'Użytkownik został usunięty.',
'cannot_delete_self' => 'Nie możesz usunąć samego siebie.',
'self_admin_warning' => 'Usunięcie własnych uprawnień administratora zablokuje Twój dostęp do panelu administratora.',
],
// Settings
'settings' => [
'language' => 'Język',
'select_language' => 'Wybierz język',
'language_updated' => 'Język został zaktualizowany.',
'change_language' => 'Zmień język',
],
// Common
'common' => [
'save' => 'Zapisz',
'cancel' => 'Anuluj',
'delete' => 'Usuń',
'edit' => 'Edytuj',
'create' => 'Utwórz',
'update' => 'Aktualizuj',
'back' => 'Wstecz',
'confirm' => 'Potwierdź',
'yes' => 'Tak',
'no' => 'Nie',
'loading' => 'Ładowanie...',
'error' => 'Błąd',
'success' => 'Sukces',
],
// Auth
'auth' => [
'login' => 'Zaloguj',
'register' => 'Zarejestruj',
'email' => 'E-mail',
'password' => 'Hasło',
'remember_me' => 'Zapamiętaj mnie',
'forgot_password' => 'Zapomniałeś hasła?',
'confirm_password' => 'Potwierdź hasło',
'already_registered' => 'Już zarejestrowany?',
],
// Errors
'errors' => [
'404_title' => 'Strona nie znaleziona',
'page_not_found' => 'Strona nie znaleziona',
'page_not_found_description' => 'Strona, której szukasz, nie została znaleziona.',
'back_to_home' => 'Wróć do strony głównej',
],
'locale_names' => [
'en' => 'Angielski',
'ja' => 'Japoński',
'zh-CN' => 'Chiński (uproszczony)',
'zh-TW' => 'Chiński (tradycyjny)',
'ko' => 'Koreański',
'hi' => 'Hindi',
'vi' => 'Wietnamski',
'tr' => 'Turecki',
'de' => 'Niemiecki',
'fr' => 'Francuski',
'es' => 'Hiszpański',
'pt-BR' => 'Portugalski (Brazylia)',
'ru' => 'Rosyjski',
'uk' => 'Ukraiński',
'it' => 'Włoski',
'pl' => 'Polski',
],
// Profile
'profile' => [
'title' => 'Profil',
'information' => 'Informacje o profilu',
'information_description' => 'Zaktualizuj informacje o profilu i adres e-mail swojego konta.',
'name' => 'Nazwa',
'email' => 'E-mail',
'email_unverified' => 'Twój adres e-mail nie został zweryfikowany.',
'resend_verification' => 'Kliknij tutaj, aby ponownie wysłać e-mail weryfikacyjny.',
'verification_sent' => 'Nowy link weryfikacyjny został wysłany na Twój adres e-mail.',
'saved' => 'Zapisano.',
'update_password' => 'Zaktualizuj hasło',
'update_password_description' => 'Upewnij się, że Twoje konto używa długiego, losowego hasła, aby pozostać bezpiecznym.',
'current_password' => 'Obecne hasło',
'new_password' => 'Nowe hasło',
'confirm_password' => 'Potwierdź hasło',
'delete_account' => 'Usuń konto',
'delete_account_description' => 'Po usunięciu konta wszystkie jego zasoby i dane zostaną trwale usunięte. Przed usunięciem konta pobierz wszelkie dane lub informacje, które chcesz zachować.',
'delete_account_confirm' => 'Czy na pewno chcesz usunąć swoje konto?',
'delete_account_confirm_description' => 'Po usunięciu konta wszystkie jego zasoby i dane zostaną trwale usunięte. Wprowadź swoje hasło, aby potwierdzić, że chcesz trwale usunąć swoje konto.',
],
];
+175
View File
@@ -0,0 +1,175 @@
<?php
return [
// Navigation
'nav' => [
'dashboard' => 'Painel',
'knowledge_base' => 'Base de Conhecimento',
'profile' => 'Perfil',
'user_management' => 'Gerenciamento de Usuários',
'logout' => 'Sair',
'login' => 'Entrar',
'register' => 'Registrar',
],
// Documents
'documents' => [
'title' => 'Documentos',
'new_document' => 'Novo Documento',
'edit_document' => 'Editar Documento',
'edit' => 'Editar',
'delete' => 'Excluir',
'save' => 'Salvar',
'cancel' => 'Cancelar',
'created_by' => 'Criado por',
'modified_by' => 'por',
'updated' => 'Atualizado',
'path' => 'Caminho',
'last_modified' => 'Última modificação',
'no_documents' => 'Nenhum documento encontrado',
'search_placeholder' => 'Pesquisar documentos...',
'create_success' => 'Documento criado com sucesso!',
'update_success' => 'Documento atualizado com sucesso!',
'delete_success' => 'Documento excluído com sucesso!',
'delete_confirm' => 'Tem certeza de que deseja excluir este documento?',
'linked_references' => 'Referências Vinculadas',
'title_label' => 'Título',
'title_placeholder' => 'Título do Documento (use / para pastas, ex: Laravel/Livewire/Components)',
'title_hint' => 'Dica: Use barras (/) no título para organizar documentos em pastas automaticamente',
'content_label' => 'Conteúdo',
'content_placeholder' => 'Escreva seu markdown aqui...',
'saving' => 'Salvando...',
'fallback_notice' => 'Este artigo não possui tradução para o idioma selecionado. Exibindo a versão no idioma original.',
'add_translation' => 'Adicionar tradução',
'translation_added' => 'Tradução adicionada.',
'translation_deleted' => 'Tradução excluída.',
'set_as_default' => 'Definir como padrão',
'delete_translation' => 'Excluir tradução',
'delete_translation_blocked' => 'A tradução do idioma padrão não pode ser excluída.',
'translation_tabs_label' => 'Idiomas',
],
// Quick Switcher
'quick_switcher' => [
'title' => 'Troca Rápida',
'placeholder' => 'Pesquisar documentos...',
'no_results' => 'Nenhum documento encontrado',
'navigate' => 'para navegar',
'select' => 'para selecionar',
'close' => 'para fechar',
],
// Admin
'admin' => [
'user_management' => 'Gerenciamento de Usuários',
'new_user' => 'Novo Usuário',
'edit_user' => 'Editar Usuário',
'create_user' => 'Criar Usuário',
'users' => 'Usuários',
'name' => 'Nome',
'email' => 'E-mail',
'password' => 'Senha',
'password_confirmation' => 'Confirmar Senha',
'password_hint' => 'Deixe em branco para manter a senha atual.',
'role' => 'Função',
'admin' => 'Administrador',
'user' => 'Usuário',
'grant_admin' => 'Conceder privilégios de administrador',
'created_at' => 'Criado',
'actions' => 'Ações',
'edit' => 'Editar',
'delete' => 'Excluir',
'no_users' => 'Nenhum usuário encontrado.',
'create_success' => 'Usuário criado com sucesso.',
'update_success' => 'Usuário atualizado com sucesso.',
'delete_success' => 'Usuário excluído com sucesso.',
'cannot_delete_self' => 'Você não pode excluir a si mesmo.',
'self_admin_warning' => 'Remover seus próprios privilégios de administrador bloqueará seu acesso ao painel de administração.',
],
// Settings
'settings' => [
'language' => 'Idioma',
'select_language' => 'Selecionar Idioma',
'language_updated' => 'Idioma atualizado com sucesso.',
'change_language' => 'Alterar idioma',
],
// Common
'common' => [
'save' => 'Salvar',
'cancel' => 'Cancelar',
'delete' => 'Excluir',
'edit' => 'Editar',
'create' => 'Criar',
'update' => 'Atualizar',
'back' => 'Voltar',
'confirm' => 'Confirmar',
'yes' => 'Sim',
'no' => 'Não',
'loading' => 'Carregando...',
'error' => 'Erro',
'success' => 'Sucesso',
],
// Auth
'auth' => [
'login' => 'Entrar',
'register' => 'Registrar',
'email' => 'E-mail',
'password' => 'Senha',
'remember_me' => 'Lembrar-me',
'forgot_password' => 'Esqueceu sua senha?',
'confirm_password' => 'Confirmar Senha',
'already_registered' => 'Já registrado?',
],
// Errors
'errors' => [
'404_title' => 'Página Não Encontrada',
'page_not_found' => 'Página Não Encontrada',
'page_not_found_description' => 'A página que você está procurando não pôde ser encontrada.',
'back_to_home' => 'Voltar para Início',
],
'locale_names' => [
'en' => 'Inglês',
'ja' => 'Japonês',
'zh-CN' => 'Chinês (simplificado)',
'zh-TW' => 'Chinês (tradicional)',
'ko' => 'Coreano',
'hi' => 'Híndi',
'vi' => 'Vietnamita',
'tr' => 'Turco',
'de' => 'Alemão',
'fr' => 'Francês',
'es' => 'Espanhol',
'pt-BR' => 'Português (Brasil)',
'ru' => 'Russo',
'uk' => 'Ucraniano',
'it' => 'Italiano',
'pl' => 'Polonês',
],
// Profile
'profile' => [
'title' => 'Perfil',
'information' => 'Informações do Perfil',
'information_description' => 'Atualize as informações do perfil e endereço de e-mail da sua conta.',
'name' => 'Nome',
'email' => 'E-mail',
'email_unverified' => 'Seu endereço de e-mail não está verificado.',
'resend_verification' => 'Clique aqui para reenviar o e-mail de verificação.',
'verification_sent' => 'Um novo link de verificação foi enviado para seu endereço de e-mail.',
'saved' => 'Salvo.',
'update_password' => 'Atualizar Senha',
'update_password_description' => 'Certifique-se de que sua conta esteja usando uma senha longa e aleatória para se manter segura.',
'current_password' => 'Senha Atual',
'new_password' => 'Nova Senha',
'confirm_password' => 'Confirmar Senha',
'delete_account' => 'Excluir Conta',
'delete_account_description' => 'Uma vez que sua conta seja excluída, todos os seus recursos e dados serão permanentemente excluídos. Antes de excluir sua conta, faça o download de quaisquer dados ou informações que deseja reter.',
'delete_account_confirm' => 'Tem certeza de que deseja excluir sua conta?',
'delete_account_confirm_description' => 'Uma vez que sua conta seja excluída, todos os seus recursos e dados serão permanentemente excluídos. Por favor, insira sua senha para confirmar que deseja excluir permanentemente sua conta.',
],
];
+175
View File
@@ -0,0 +1,175 @@
<?php
return [
// Navigation
'nav' => [
'dashboard' => 'Панель управления',
'knowledge_base' => 'База знаний',
'profile' => 'Профиль',
'user_management' => 'Управление пользователями',
'logout' => 'Выйти',
'login' => 'Войти',
'register' => 'Регистрация',
],
// Documents
'documents' => [
'title' => 'Документы',
'new_document' => 'Новый документ',
'edit_document' => 'Редактировать документ',
'edit' => 'Редактировать',
'delete' => 'Удалить',
'save' => 'Сохранить',
'cancel' => 'Отмена',
'created_by' => 'Создано',
'modified_by' => '',
'updated' => 'Обновлено',
'path' => 'Путь',
'last_modified' => 'Последнее изменение',
'no_documents' => 'Документы не найдены',
'search_placeholder' => 'Поиск документов...',
'create_success' => 'Документ успешно создан!',
'update_success' => 'Документ успешно обновлён!',
'delete_success' => 'Документ успешно удалён!',
'delete_confirm' => 'Вы уверены, что хотите удалить этот документ?',
'linked_references' => 'Связанные ссылки',
'title_label' => 'Название',
'title_placeholder' => 'Название документа (используйте / для папок, например Laravel/Livewire/Components)',
'title_hint' => 'Совет: Используйте слэши (/) в названии для автоматической организации документов в папки',
'content_label' => 'Содержимое',
'content_placeholder' => 'Напишите здесь ваш markdown...',
'saving' => 'Сохранение...',
'fallback_notice' => 'Эта статья не переведена на выбранный язык. Отображается версия на языке оригинала.',
'add_translation' => 'Добавить перевод',
'translation_added' => 'Перевод добавлен.',
'translation_deleted' => 'Перевод удалён.',
'set_as_default' => 'Установить по умолчанию',
'delete_translation' => 'Удалить перевод',
'delete_translation_blocked' => 'Перевод языка по умолчанию нельзя удалить.',
'translation_tabs_label' => 'Языки',
],
// Quick Switcher
'quick_switcher' => [
'title' => 'Быстрый переход',
'placeholder' => 'Поиск документов...',
'no_results' => 'Документы не найдены',
'navigate' => 'для навигации',
'select' => 'для выбора',
'close' => 'для закрытия',
],
// Admin
'admin' => [
'user_management' => 'Управление пользователями',
'new_user' => 'Новый пользователь',
'edit_user' => 'Редактировать пользователя',
'create_user' => 'Создать пользователя',
'users' => 'Пользователи',
'name' => 'Имя',
'email' => 'Email',
'password' => 'Пароль',
'password_confirmation' => 'Подтверждение пароля',
'password_hint' => 'Оставьте пустым, чтобы сохранить текущий пароль.',
'role' => 'Роль',
'admin' => 'Администратор',
'user' => 'Пользователь',
'grant_admin' => 'Предоставить права администратора',
'created_at' => 'Создан',
'actions' => 'Действия',
'edit' => 'Редактировать',
'delete' => 'Удалить',
'no_users' => 'Пользователи не найдены.',
'create_success' => 'Пользователь успешно создан.',
'update_success' => 'Пользователь успешно обновлён.',
'delete_success' => 'Пользователь успешно удалён.',
'cannot_delete_self' => 'Вы не можете удалить себя.',
'self_admin_warning' => 'Снятие собственных прав администратора заблокирует вам доступ к панели администратора.',
],
// Settings
'settings' => [
'language' => 'Язык',
'select_language' => 'Выбрать язык',
'language_updated' => 'Язык успешно обновлён.',
'change_language' => 'Изменить язык',
],
// Common
'common' => [
'save' => 'Сохранить',
'cancel' => 'Отмена',
'delete' => 'Удалить',
'edit' => 'Редактировать',
'create' => 'Создать',
'update' => 'Обновить',
'back' => 'Назад',
'confirm' => 'Подтвердить',
'yes' => 'Да',
'no' => 'Нет',
'loading' => 'Загрузка...',
'error' => 'Ошибка',
'success' => 'Успех',
],
// Auth
'auth' => [
'login' => 'Войти',
'register' => 'Регистрация',
'email' => 'Email',
'password' => 'Пароль',
'remember_me' => 'Запомнить меня',
'forgot_password' => 'Забыли пароль?',
'confirm_password' => 'Подтверждение пароля',
'already_registered' => 'Уже зарегистрированы?',
],
// Errors
'errors' => [
'404_title' => 'Страница не найдена',
'page_not_found' => 'Страница не найдена',
'page_not_found_description' => 'Страница, которую вы ищете, не найдена.',
'back_to_home' => 'Вернуться на главную',
],
'locale_names' => [
'en' => 'Английский',
'ja' => 'Японский',
'zh-CN' => 'Китайский (упрощённый)',
'zh-TW' => 'Китайский (традиционный)',
'ko' => 'Корейский',
'hi' => 'Хинди',
'vi' => 'Вьетнамский',
'tr' => 'Турецкий',
'de' => 'Немецкий',
'fr' => 'Французский',
'es' => 'Испанский',
'pt-BR' => 'Португальский (Бразилия)',
'ru' => 'Русский',
'uk' => 'Украинский',
'it' => 'Итальянский',
'pl' => 'Польский',
],
// Profile
'profile' => [
'title' => 'Профиль',
'information' => 'Информация профиля',
'information_description' => 'Обновите информацию профиля и адрес электронной почты вашей учётной записи.',
'name' => 'Имя',
'email' => 'Email',
'email_unverified' => 'Ваш адрес электронной почты не подтверждён.',
'resend_verification' => 'Нажмите здесь, чтобы отправить письмо с подтверждением повторно.',
'verification_sent' => 'Новая ссылка для подтверждения отправлена на ваш адрес электронной почты.',
'saved' => 'Сохранено.',
'update_password' => 'Обновить пароль',
'update_password_description' => 'Убедитесь, что ваша учётная запись использует длинный случайный пароль для обеспечения безопасности.',
'current_password' => 'Текущий пароль',
'new_password' => 'Новый пароль',
'confirm_password' => 'Подтверждение пароля',
'delete_account' => 'Удалить учётную запись',
'delete_account_description' => 'После удаления вашей учётной записи все её ресурсы и данные будут удалены безвозвратно. Перед удалением учётной записи загрузите любые данные или информацию, которые вы хотите сохранить.',
'delete_account_confirm' => 'Вы уверены, что хотите удалить свою учётную запись?',
'delete_account_confirm_description' => 'После удаления вашей учётной записи все её ресурсы и данные будут удалены безвозвратно. Пожалуйста, введите свой пароль, чтобы подтвердить, что вы хотите навсегда удалить свою учётную запись.',
],
];
+175
View File
@@ -0,0 +1,175 @@
<?php
return [
// Navigation
'nav' => [
'dashboard' => 'Panel',
'knowledge_base' => 'Bilgi Tabanı',
'profile' => 'Profil',
'user_management' => 'Kullanıcı Yönetimi',
'logout' => 'Çıkış',
'login' => 'Giriş',
'register' => 'Kayıt',
],
// Documents
'documents' => [
'title' => 'Belgeler',
'new_document' => 'Yeni Belge',
'edit_document' => 'Belgeyi Düzenle',
'edit' => 'Düzenle',
'delete' => 'Sil',
'save' => 'Kaydet',
'cancel' => 'İptal',
'created_by' => 'Oluşturan',
'modified_by' => 'tarafından',
'updated' => 'Güncellendi',
'path' => 'Yol',
'last_modified' => 'Son değişiklik',
'no_documents' => 'Belge bulunamadı',
'search_placeholder' => 'Belge ara...',
'create_success' => 'Belge başarıyla oluşturuldu!',
'update_success' => 'Belge başarıyla güncellendi!',
'delete_success' => 'Belge başarıyla silindi!',
'delete_confirm' => 'Bu belgeyi silmek istediğinizden emin misiniz?',
'linked_references' => 'Bağlantılı Referanslar',
'title_label' => 'Başlık',
'title_placeholder' => 'Belge Başlığı (klasörler için / kullanın, örn: Laravel/Livewire/Components)',
'title_hint' => 'İpucu: Belgeleri otomatik olarak klasörlere düzenlemek için başlıkta eğik çizgi (/) kullanın',
'content_label' => 'İçerik',
'content_placeholder' => 'Markdown\'ınızı buraya yazın...',
'saving' => 'Kaydediliyor...',
'fallback_notice' => 'Bu makalenin seçtiğiniz dile çevirisi mevcut değil. Orijinal dil sürümü gösteriliyor.',
'add_translation' => 'Çeviri ekle',
'translation_added' => 'Çeviri eklendi.',
'translation_deleted' => 'Çeviri silindi.',
'set_as_default' => 'Varsayılan olarak ayarla',
'delete_translation' => 'Çeviriyi sil',
'delete_translation_blocked' => 'Varsayılan dilin çevirisi silinemez.',
'translation_tabs_label' => 'Diller',
],
// Quick Switcher
'quick_switcher' => [
'title' => 'Hızlı Geçiş',
'placeholder' => 'Belge ara...',
'no_results' => 'Belge bulunamadı',
'navigate' => 'gezinmek için',
'select' => 'seçmek için',
'close' => 'kapatmak için',
],
// Admin
'admin' => [
'user_management' => 'Kullanıcı Yönetimi',
'new_user' => 'Yeni Kullanıcı',
'edit_user' => 'Kullanıcıyı Düzenle',
'create_user' => 'Kullanıcı Oluştur',
'users' => 'Kullanıcılar',
'name' => 'Ad',
'email' => 'E-posta',
'password' => 'Şifre',
'password_confirmation' => 'Şifre Onayı',
'password_hint' => 'Mevcut şifreyi korumak için boş bırakın.',
'role' => 'Rol',
'admin' => 'Yönetici',
'user' => 'Kullanıcı',
'grant_admin' => 'Yönetici ayrıcalıkları ver',
'created_at' => 'Oluşturuldu',
'actions' => 'İşlemler',
'edit' => 'Düzenle',
'delete' => 'Sil',
'no_users' => 'Kullanıcı bulunamadı.',
'create_success' => 'Kullanıcı başarıyla oluşturuldu.',
'update_success' => 'Kullanıcı başarıyla güncellendi.',
'delete_success' => 'Kullanıcı başarıyla silindi.',
'cannot_delete_self' => 'Kendinizi silemezsiniz.',
'self_admin_warning' => 'Kendi yönetici ayrıcalıklarınızı kaldırmak yönetici paneline erişiminizi engelleyecektir.',
],
// Settings
'settings' => [
'language' => 'Dil',
'select_language' => 'Dil Seç',
'language_updated' => 'Dil başarıyla güncellendi.',
'change_language' => 'Dili değiştir',
],
// Common
'common' => [
'save' => 'Kaydet',
'cancel' => 'İptal',
'delete' => 'Sil',
'edit' => 'Düzenle',
'create' => 'Oluştur',
'update' => 'Güncelle',
'back' => 'Geri',
'confirm' => 'Onayla',
'yes' => 'Evet',
'no' => 'Hayır',
'loading' => 'Yükleniyor...',
'error' => 'Hata',
'success' => 'Başarılı',
],
// Auth
'auth' => [
'login' => 'Giriş',
'register' => 'Kayıt',
'email' => 'E-posta',
'password' => 'Şifre',
'remember_me' => 'Beni hatırla',
'forgot_password' => 'Şifrenizi mi unuttunuz?',
'confirm_password' => 'Şifre Onayı',
'already_registered' => 'Zaten kayıtlı mısınız?',
],
// Errors
'errors' => [
'404_title' => 'Sayfa Bulunamadı',
'page_not_found' => 'Sayfa Bulunamadı',
'page_not_found_description' => 'Aradığınız sayfa bulunamadı.',
'back_to_home' => 'Ana Sayfaya Dön',
],
'locale_names' => [
'en' => 'İngilizce',
'ja' => 'Japonca',
'zh-CN' => 'Basitleştirilmiş Çince',
'zh-TW' => 'Geleneksel Çince',
'ko' => 'Korece',
'hi' => 'Hintçe',
'vi' => 'Vietnamca',
'tr' => 'Türkçe',
'de' => 'Almanca',
'fr' => 'Fransızca',
'es' => 'İspanyolca',
'pt-BR' => 'Portekizce (Brezilya)',
'ru' => 'Rusça',
'uk' => 'Ukraynaca',
'it' => 'İtalyanca',
'pl' => 'Lehçe',
],
// Profile
'profile' => [
'title' => 'Profil',
'information' => 'Profil Bilgileri',
'information_description' => 'Hesabınızın profil bilgilerini ve e-posta adresini güncelleyin.',
'name' => 'Ad',
'email' => 'E-posta',
'email_unverified' => 'E-posta adresiniz doğrulanmamış.',
'resend_verification' => 'Doğrulama e-postasını yeniden göndermek için buraya tıklayın.',
'verification_sent' => 'E-posta adresinize yeni bir doğrulama bağlantısı gönderildi.',
'saved' => 'Kaydedildi.',
'update_password' => 'Şifreyi Güncelle',
'update_password_description' => 'Hesabınızın güvende kalması için uzun, rastgele bir şifre kullandığından emin olun.',
'current_password' => 'Mevcut Şifre',
'new_password' => 'Yeni Şifre',
'confirm_password' => 'Şifre Onayı',
'delete_account' => 'Hesabı Sil',
'delete_account_description' => 'Hesabınız silindikten sonra, tüm kaynakları ve verileri kalıcı olarak silinecektir. Hesabınızı silmeden önce, saklamak istediğiniz tüm verileri veya bilgileri indirin.',
'delete_account_confirm' => 'Hesabınızı silmek istediğinizden emin misiniz?',
'delete_account_confirm_description' => 'Hesabınız silindikten sonra, tüm kaynakları ve verileri kalıcı olarak silinecektir. Hesabınızı kalıcı olarak silmek istediğinizi onaylamak için lütfen şifrenizi girin.',
],
];
+175
View File
@@ -0,0 +1,175 @@
<?php
return [
// Navigation
'nav' => [
'dashboard' => 'Панель керування',
'knowledge_base' => 'База знань',
'profile' => 'Профіль',
'user_management' => 'Управління користувачами',
'logout' => 'Вийти',
'login' => 'Увійти',
'register' => 'Реєстрація',
],
// Documents
'documents' => [
'title' => 'Документи',
'new_document' => 'Новий документ',
'edit_document' => 'Редагувати документ',
'edit' => 'Редагувати',
'delete' => 'Видалити',
'save' => 'Зберегти',
'cancel' => 'Скасувати',
'created_by' => 'Створено',
'modified_by' => '',
'updated' => 'Оновлено',
'path' => 'Шлях',
'last_modified' => 'Остання зміна',
'no_documents' => 'Документи не знайдено',
'search_placeholder' => 'Пошук документів...',
'create_success' => 'Документ успішно створено!',
'update_success' => 'Документ успішно оновлено!',
'delete_success' => 'Документ успішно видалено!',
'delete_confirm' => 'Ви впевнені, що хочете видалити цей документ?',
'linked_references' => "Пов'язані посилання",
'title_label' => 'Назва',
'title_placeholder' => 'Назва документа (використовуйте / для папок, наприклад Laravel/Livewire/Components)',
'title_hint' => 'Порада: Використовуйте слеші (/) у назві для автоматичної організації документів у папки',
'content_label' => 'Вміст',
'content_placeholder' => 'Напишіть тут ваш markdown...',
'saving' => 'Збереження...',
'fallback_notice' => 'Цю статтю не перекладено вибраною мовою. Відображається версія мовою оригіналу.',
'add_translation' => 'Додати переклад',
'translation_added' => 'Переклад додано.',
'translation_deleted' => 'Переклад видалено.',
'set_as_default' => 'Встановити за замовчуванням',
'delete_translation' => 'Видалити переклад',
'delete_translation_blocked' => 'Переклад мови за замовчуванням не можна видалити.',
'translation_tabs_label' => 'Мови',
],
// Quick Switcher
'quick_switcher' => [
'title' => 'Швидкий перехід',
'placeholder' => 'Пошук документів...',
'no_results' => 'Документи не знайдено',
'navigate' => 'для навігації',
'select' => 'для вибору',
'close' => 'для закриття',
],
// Admin
'admin' => [
'user_management' => 'Управління користувачами',
'new_user' => 'Новий користувач',
'edit_user' => 'Редагувати користувача',
'create_user' => 'Створити користувача',
'users' => 'Користувачі',
'name' => "Ім'я",
'email' => 'Email',
'password' => 'Пароль',
'password_confirmation' => 'Підтвердження пароля',
'password_hint' => 'Залиште порожнім, щоб зберегти поточний пароль.',
'role' => 'Роль',
'admin' => 'Адміністратор',
'user' => 'Користувач',
'grant_admin' => 'Надати права адміністратора',
'created_at' => 'Створено',
'actions' => 'Дії',
'edit' => 'Редагувати',
'delete' => 'Видалити',
'no_users' => 'Користувачі не знайдені.',
'create_success' => 'Користувача успішно створено.',
'update_success' => 'Користувача успішно оновлено.',
'delete_success' => 'Користувача успішно видалено.',
'cannot_delete_self' => 'Ви не можете видалити себе.',
'self_admin_warning' => 'Зняття власних прав адміністратора заблокує вам доступ до панелі адміністратора.',
],
// Settings
'settings' => [
'language' => 'Мова',
'select_language' => 'Вибрати мову',
'language_updated' => 'Мову успішно оновлено.',
'change_language' => 'Змінити мову',
],
// Common
'common' => [
'save' => 'Зберегти',
'cancel' => 'Скасувати',
'delete' => 'Видалити',
'edit' => 'Редагувати',
'create' => 'Створити',
'update' => 'Оновити',
'back' => 'Назад',
'confirm' => 'Підтвердити',
'yes' => 'Так',
'no' => 'Ні',
'loading' => 'Завантаження...',
'error' => 'Помилка',
'success' => 'Успіх',
],
// Auth
'auth' => [
'login' => 'Увійти',
'register' => 'Реєстрація',
'email' => 'Email',
'password' => 'Пароль',
'remember_me' => "Запам'ятати мене",
'forgot_password' => 'Забули пароль?',
'confirm_password' => 'Підтвердження пароля',
'already_registered' => 'Вже зареєстровані?',
],
// Errors
'errors' => [
'404_title' => 'Сторінка не знайдена',
'page_not_found' => 'Сторінка не знайдена',
'page_not_found_description' => 'Сторінку, яку ви шукаєте, не знайдено.',
'back_to_home' => 'Повернутися на головну',
],
'locale_names' => [
'en' => 'Англійська',
'ja' => 'Японська',
'zh-CN' => 'Китайська (спрощена)',
'zh-TW' => 'Китайська (традиційна)',
'ko' => 'Корейська',
'hi' => 'Гінді',
'vi' => 'В\'єтнамська',
'tr' => 'Турецька',
'de' => 'Німецька',
'fr' => 'Французька',
'es' => 'Іспанська',
'pt-BR' => 'Португальська (Бразилія)',
'ru' => 'Російська',
'uk' => 'Українська',
'it' => 'Італійська',
'pl' => 'Польська',
],
// Profile
'profile' => [
'title' => 'Профіль',
'information' => 'Інформація профілю',
'information_description' => 'Оновіть інформацію профілю та адресу електронної пошти вашого облікового запису.',
'name' => "Ім'я",
'email' => 'Email',
'email_unverified' => 'Ваша адреса електронної пошти не підтверджена.',
'resend_verification' => 'Натисніть тут, щоб повторно відправити лист з підтвердженням.',
'verification_sent' => 'Нове посилання для підтвердження відправлено на вашу адресу електронної пошти.',
'saved' => 'Збережено.',
'update_password' => 'Оновити пароль',
'update_password_description' => 'Переконайтеся, що ваш обліковий запис використовує довгий випадковий пароль для забезпечення безпеки.',
'current_password' => 'Поточний пароль',
'new_password' => 'Новий пароль',
'confirm_password' => 'Підтвердження пароля',
'delete_account' => 'Видалити обліковий запис',
'delete_account_description' => 'Після видалення вашого облікового запису всі його ресурси та дані будуть видалені назавжди. Перед видаленням облікового запису завантажте будь-які дані або інформацію, які ви хочете зберегти.',
'delete_account_confirm' => 'Ви впевнені, що хочете видалити свій обліковий запис?',
'delete_account_confirm_description' => 'Після видалення вашого облікового запису всі його ресурси та дані будуть видалені назавжди. Будь ласка, введіть свій пароль, щоб підтвердити, що ви хочете назавжди видалити свій обліковий запис.',
],
];
+175
View File
@@ -0,0 +1,175 @@
<?php
return [
// Navigation
'nav' => [
'dashboard' => 'Bảng điều khiển',
'knowledge_base' => 'Cơ sở tri thức',
'profile' => 'Hồ sơ',
'user_management' => 'Quản lý người dùng',
'logout' => 'Đăng xuất',
'login' => 'Đăng nhập',
'register' => 'Đăng ký',
],
// Documents
'documents' => [
'title' => 'Tài liệu',
'new_document' => 'Tài liệu mới',
'edit_document' => 'Chỉnh sửa tài liệu',
'edit' => 'Chỉnh sửa',
'delete' => 'Xóa',
'save' => 'Lưu',
'cancel' => 'Hủy',
'created_by' => 'Được tạo bởi',
'modified_by' => 'bởi',
'updated' => 'Đã cập nhật',
'path' => 'Đường dẫn',
'last_modified' => 'Sửa đổi lần cuối',
'no_documents' => 'Không tìm thấy tài liệu',
'search_placeholder' => 'Tìm kiếm tài liệu...',
'create_success' => 'Tạo tài liệu thành công!',
'update_success' => 'Cập nhật tài liệu thành công!',
'delete_success' => 'Xóa tài liệu thành công!',
'delete_confirm' => 'Bạn có chắc chắn muốn xóa tài liệu này?',
'linked_references' => 'Tham chiếu liên kết',
'title_label' => 'Tiêu đề',
'title_placeholder' => 'Tiêu đề tài liệu (sử dụng / cho thư mục, ví dụ: Laravel/Livewire/Components)',
'title_hint' => 'Mẹo: Sử dụng dấu gạch chéo (/) trong tiêu đề để tự động sắp xếp tài liệu vào các thư mục',
'content_label' => 'Nội dung',
'content_placeholder' => 'Viết markdown của bạn ở đây...',
'saving' => 'Đang lưu...',
'fallback_notice' => 'Bài viết này không có bản dịch sang ngôn ngữ bạn đã chọn. Đang hiển thị phiên bản ngôn ngữ gốc.',
'add_translation' => 'Thêm bản dịch',
'translation_added' => 'Đã thêm bản dịch.',
'translation_deleted' => 'Đã xoá bản dịch.',
'set_as_default' => 'Đặt làm mặc định',
'delete_translation' => 'Xoá bản dịch',
'delete_translation_blocked' => 'Không thể xoá bản dịch của ngôn ngữ mặc định.',
'translation_tabs_label' => 'Ngôn ngữ',
],
// Quick Switcher
'quick_switcher' => [
'title' => 'Chuyển đổi nhanh',
'placeholder' => 'Tìm kiếm tài liệu...',
'no_results' => 'Không tìm thấy tài liệu',
'navigate' => 'để điều hướng',
'select' => 'để chọn',
'close' => 'để đóng',
],
// Admin
'admin' => [
'user_management' => 'Quản lý người dùng',
'new_user' => 'Người dùng mới',
'edit_user' => 'Chỉnh sửa người dùng',
'create_user' => 'Tạo người dùng',
'users' => 'Người dùng',
'name' => 'Tên',
'email' => 'Email',
'password' => 'Mật khẩu',
'password_confirmation' => 'Xác nhận mật khẩu',
'password_hint' => 'Để trống để giữ mật khẩu hiện tại.',
'role' => 'Vai trò',
'admin' => 'Quản trị viên',
'user' => 'Người dùng',
'grant_admin' => 'Cấp quyền quản trị viên',
'created_at' => 'Đã tạo',
'actions' => 'Hành động',
'edit' => 'Chỉnh sửa',
'delete' => 'Xóa',
'no_users' => 'Không tìm thấy người dùng.',
'create_success' => 'Tạo người dùng thành công.',
'update_success' => 'Cập nhật người dùng thành công.',
'delete_success' => 'Xóa người dùng thành công.',
'cannot_delete_self' => 'Bạn không thể xóa chính mình.',
'self_admin_warning' => 'Xóa quyền quản trị viên của chính bạn sẽ khóa quyền truy cập của bạn vào bảng quản trị.',
],
// Settings
'settings' => [
'language' => 'Ngôn ngữ',
'select_language' => 'Chọn ngôn ngữ',
'language_updated' => 'Cập nhật ngôn ngữ thành công.',
'change_language' => 'Thay đổi ngôn ngữ',
],
// Common
'common' => [
'save' => 'Lưu',
'cancel' => 'Hủy',
'delete' => 'Xóa',
'edit' => 'Chỉnh sửa',
'create' => 'Tạo',
'update' => 'Cập nhật',
'back' => 'Quay lại',
'confirm' => 'Xác nhận',
'yes' => 'Có',
'no' => 'Không',
'loading' => 'Đang tải...',
'error' => 'Lỗi',
'success' => 'Thành công',
],
// Auth
'auth' => [
'login' => 'Đăng nhập',
'register' => 'Đăng ký',
'email' => 'Email',
'password' => 'Mật khẩu',
'remember_me' => 'Ghi nhớ tôi',
'forgot_password' => 'Quên mật khẩu?',
'confirm_password' => 'Xác nhận mật khẩu',
'already_registered' => 'Đã đăng ký?',
],
// Errors
'errors' => [
'404_title' => 'Không tìm thấy trang',
'page_not_found' => 'Không tìm thấy trang',
'page_not_found_description' => 'Trang bạn đang tìm kiếm không thể tìm thấy.',
'back_to_home' => 'Quay lại trang chủ',
],
'locale_names' => [
'en' => 'Tiếng Anh',
'ja' => 'Tiếng Nhật',
'zh-CN' => 'Tiếng Trung (Giản thể)',
'zh-TW' => 'Tiếng Trung (Phồn thể)',
'ko' => 'Tiếng Hàn',
'hi' => 'Tiếng Hindi',
'vi' => 'Tiếng Việt',
'tr' => 'Tiếng Thổ Nhĩ Kỳ',
'de' => 'Tiếng Đức',
'fr' => 'Tiếng Pháp',
'es' => 'Tiếng Tây Ban Nha',
'pt-BR' => 'Tiếng Bồ Đào Nha (Brasil)',
'ru' => 'Tiếng Nga',
'uk' => 'Tiếng Ukraine',
'it' => 'Tiếng Ý',
'pl' => 'Tiếng Ba Lan',
],
// Profile
'profile' => [
'title' => 'Hồ sơ',
'information' => 'Thông tin hồ sơ',
'information_description' => 'Cập nhật thông tin hồ sơ và địa chỉ email của tài khoản của bạn.',
'name' => 'Tên',
'email' => 'Email',
'email_unverified' => 'Địa chỉ email của bạn chưa được xác minh.',
'resend_verification' => 'Nhấp vào đây để gửi lại email xác minh.',
'verification_sent' => 'Một liên kết xác minh mới đã được gửi đến địa chỉ email của bạn.',
'saved' => 'Đã lưu.',
'update_password' => 'Cập nhật mật khẩu',
'update_password_description' => 'Đảm bảo tài khoản của bạn đang sử dụng mật khẩu dài, ngẫu nhiên để luôn an toàn.',
'current_password' => 'Mật khẩu hiện tại',
'new_password' => 'Mật khẩu mới',
'confirm_password' => 'Xác nhận mật khẩu',
'delete_account' => 'Xóa tài khoản',
'delete_account_description' => 'Sau khi tài khoản của bạn bị xóa, tất cả tài nguyên và dữ liệu của nó sẽ bị xóa vĩnh viễn. Trước khi xóa tài khoản của bạn, vui lòng tải xuống bất kỳ dữ liệu hoặc thông tin nào mà bạn muốn giữ lại.',
'delete_account_confirm' => 'Bạn có chắc chắn muốn xóa tài khoản của mình?',
'delete_account_confirm_description' => 'Sau khi tài khoản của bạn bị xóa, tất cả tài nguyên và dữ liệu của nó sẽ bị xóa vĩnh viễn. Vui lòng nhập mật khẩu của bạn để xác nhận rằng bạn muốn xóa vĩnh viễn tài khoản của mình.',
],
];
+35
View File
@@ -39,6 +39,14 @@
'content_label' => '内容', 'content_label' => '内容',
'content_placeholder' => '在此输入Markdown内容...', 'content_placeholder' => '在此输入Markdown内容...',
'saving' => '保存中...', 'saving' => '保存中...',
'fallback_notice' => '此文章没有您所选语言的翻译。正在显示原语言版本。',
'add_translation' => '添加翻译',
'translation_added' => '翻译已添加。',
'translation_deleted' => '翻译已删除。',
'set_as_default' => '设为默认',
'delete_translation' => '删除翻译',
'delete_translation_blocked' => '无法删除默认语言的翻译。',
'translation_tabs_label' => '语言',
], ],
// Quick Switcher // Quick Switcher
@@ -115,6 +123,33 @@
'already_registered' => '已有账号?', 'already_registered' => '已有账号?',
], ],
// Errors
'errors' => [
'404_title' => '页面未找到',
'page_not_found' => '页面未找到',
'page_not_found_description' => '您要查找的页面不存在。',
'back_to_home' => '返回首页',
],
'locale_names' => [
'en' => '英语',
'ja' => '日语',
'zh-CN' => '简体中文',
'zh-TW' => '繁体中文',
'ko' => '韩语',
'hi' => '印地语',
'vi' => '越南语',
'tr' => '土耳其语',
'de' => '德语',
'fr' => '法语',
'es' => '西班牙语',
'pt-BR' => '葡萄牙语(巴西)',
'ru' => '俄语',
'uk' => '乌克兰语',
'it' => '意大利语',
'pl' => '波兰语',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => '个人资料', 'title' => '个人资料',
+35
View File
@@ -39,6 +39,14 @@
'content_label' => '內容', 'content_label' => '內容',
'content_placeholder' => '在此輸入Markdown內容...', 'content_placeholder' => '在此輸入Markdown內容...',
'saving' => '儲存中...', 'saving' => '儲存中...',
'fallback_notice' => '此文章沒有您所選語言的翻譯。正在顯示原語言版本。',
'add_translation' => '新增翻譯',
'translation_added' => '翻譯已新增。',
'translation_deleted' => '翻譯已刪除。',
'set_as_default' => '設為預設',
'delete_translation' => '刪除翻譯',
'delete_translation_blocked' => '無法刪除預設語言的翻譯。',
'translation_tabs_label' => '語言',
], ],
// Quick Switcher // Quick Switcher
@@ -115,6 +123,33 @@
'already_registered' => '已有帳號?', 'already_registered' => '已有帳號?',
], ],
// Errors
'errors' => [
'404_title' => '頁面未找到',
'page_not_found' => '頁面未找到',
'page_not_found_description' => '您要查找的頁面不存在。',
'back_to_home' => '返回首頁',
],
'locale_names' => [
'en' => '英語',
'ja' => '日語',
'zh-CN' => '簡體中文',
'zh-TW' => '繁體中文',
'ko' => '韓語',
'hi' => '印地語',
'vi' => '越南語',
'tr' => '土耳其語',
'de' => '德語',
'fr' => '法語',
'es' => '西班牙語',
'pt-BR' => '葡萄牙語(巴西)',
'ru' => '俄語',
'uk' => '烏克蘭語',
'it' => '義大利語',
'pl' => '波蘭語',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => '個人資料', 'title' => '個人資料',
+276 -227
View File
@@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "html",
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"easymde": "^2.20.0" "easymde": "^2.20.0"
@@ -34,9 +35,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -51,9 +52,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -68,9 +69,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -85,9 +86,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -102,9 +103,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -119,9 +120,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -136,9 +137,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -153,9 +154,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -170,9 +171,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -187,9 +188,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -204,9 +205,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -221,9 +222,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -238,9 +239,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -255,9 +256,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -272,9 +273,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -289,9 +290,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -306,9 +307,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -323,9 +324,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -340,9 +341,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -357,9 +358,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -374,9 +375,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -391,9 +392,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -408,9 +409,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -425,9 +426,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -442,9 +443,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -459,9 +460,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -557,9 +558,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -571,9 +572,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -585,9 +586,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -599,9 +600,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -613,9 +614,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -627,9 +628,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -641,9 +642,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -655,9 +656,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -669,9 +670,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -683,9 +684,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -697,9 +698,23 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -711,9 +726,23 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -725,9 +754,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -739,9 +768,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -753,9 +782,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -767,9 +796,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -781,9 +810,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -794,10 +823,24 @@
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -809,9 +852,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -823,9 +866,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -837,9 +880,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -851,9 +894,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1329,15 +1372,15 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.2", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.16.0",
"form-data": "^4.0.4", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
@@ -1765,9 +1808,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -1778,32 +1821,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12", "@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.25.12", "@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.25.12", "@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.25.12", "@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.25.12", "@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.25.12", "@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.25.12", "@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.25.12", "@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.25.12", "@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.25.12", "@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.25.12", "@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.25.12" "@esbuild/win32-x64": "0.27.7"
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
@@ -1866,9 +1909,9 @@
} }
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2621,9 +2664,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -2651,9 +2694,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -2820,11 +2863,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"engines": {
"node": ">=10"
}
}, },
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
@@ -2908,9 +2954,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.53.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2924,28 +2970,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm-eabi": "4.60.3",
"@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-android-arm64": "4.60.3",
"@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.60.3",
"@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-darwin-x64": "4.60.3",
"@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.60.3",
"@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.60.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
"@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3",
"@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3",
"@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.60.3",
"@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3",
"@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-loong64-musl": "4.60.3",
"@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3",
"@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3",
"@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3",
"@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3",
"@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3",
"@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.60.3",
"@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.60.3",
"@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-openbsd-x64": "4.60.3",
"@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.60.3",
"@rollup/rollup-win32-x64-msvc": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3",
"@rollup/rollup-win32-ia32-msvc": "4.60.3",
"@rollup/rollup-win32-x64-gnu": "4.60.3",
"@rollup/rollup-win32-x64-msvc": "4.60.3",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@@ -3210,9 +3259,9 @@
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": { "node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -3300,13 +3349,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.2.4", "version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -3404,9 +3453,9 @@
} }
}, },
"node_modules/vite/node_modules/picomatch": { "node_modules/vite/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('messages.errors.404_title') }} - {{ config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100 flex flex-col items-center justify-center">
<div class="text-center">
<h1 class="text-9xl font-bold text-gray-300">404</h1>
<h2 class="text-2xl font-semibold text-gray-700 mt-4">
{{ __('messages.errors.page_not_found') }}
</h2>
<p class="text-gray-500 mt-2">
{{ __('messages.errors.page_not_found_description') }}
</p>
<div class="mt-8">
<a href="{{ url('/') }}" class="inline-flex items-center px-6 py-3 bg-indigo-600 text-white font-medium rounded-md hover:bg-indigo-700 transition">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
{{ __('messages.errors.back_to_home') }}
</a>
</div>
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -17,7 +17,7 @@
<body class="font-sans text-gray-900 antialiased"> <body class="font-sans text-gray-900 antialiased">
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100"> <div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
<div> <div>
<a href="/"> <a href="{{ url('/') }}">
<x-application-logo class="w-20 h-20 fill-current text-gray-500" /> <x-application-logo class="w-20 h-20 fill-current text-gray-500" />
</a> </a>
</div> </div>
@@ -27,38 +27,118 @@
@livewireStyles @livewireStyles
@stack('styles') @stack('styles')
</head> </head>
<body class="font-sans antialiased"> <body class="font-sans antialiased" x-data="{
mobileMenuOpen: false,
sidebarWidth: localStorage.getItem('kb_sidebar_width') || 320,
isResizing: false,
startResize(e) {
if (window.innerWidth < 1024) return; // lg breakpoint
this.isResizing = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
resize(e) {
if (!this.isResizing) return;
const newWidth = Math.max(200, Math.min(600, e.clientX));
this.sidebarWidth = newWidth;
localStorage.setItem('kb_sidebar_width', newWidth);
},
stopResize() {
if (this.isResizing) {
this.isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
}
}" @mousemove.window="resize($event)" @mouseup.window="stopResize()">
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<!-- Header --> <!-- Header -->
<header class="bg-white border-b border-gray-200 sticky top-0 z-10"> <header class="bg-white border-b border-gray-200 sticky top-0 z-20">
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<a href="/" class="flex items-center space-x-3"> <!-- Mobile Menu Toggle -->
<x-application-logo class="block h-8 w-auto fill-current text-gray-800" /> <button
<h1 class="text-xl font-semibold text-gray-900"> @click="mobileMenuOpen = !mobileMenuOpen"
class="lg:hidden p-2 rounded-md text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
aria-label="Toggle menu"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
<path x-show="mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<a href="{{ url('/') }}" class="flex items-center space-x-2 sm:space-x-3">
<x-application-logo class="block h-6 sm:h-8 w-auto fill-current text-gray-800" />
<h1 class="text-lg sm:text-xl font-semibold text-gray-900 hidden xs:block">
{{ config('app.name', 'Knowledge Base') }} {{ config('app.name', 'Knowledge Base') }}
</h1> </h1>
</a> </a>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-2 sm:space-x-4">
<!-- Quick Switcher Trigger --> <!-- Quick Switcher Trigger -->
<button <button
type="button" type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="inline-flex items-center px-2 sm:px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
x-data x-data
@click.prevent="$dispatch('open-quick-switcher')" @click.prevent="$dispatch('open-quick-switcher')"
> >
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg> </svg>
{{ __('messages.quick_switcher.title') }} <span class="hidden sm:inline">{{ __('messages.quick_switcher.title') }}</span>
<kbd class="ml-2 px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded"> <kbd class="hidden md:inline-flex ml-2 px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">
Ctrl+K Ctrl+K
</kbd> </kbd>
</button> </button>
<!-- Language Switcher (for all users) -->
<div x-data="{ open: false }" @click.away="open = false" class="relative">
<button
@click="open = !open"
class="flex items-center px-2 sm:px-3 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none"
title="{{ __('messages.settings.change_language') }}"
>
<svg class="w-4 h-4 sm:w-5 sm:h-5 sm:mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"></path>
</svg>
@php
$currentLocale = app()->getLocale();
$locales = \App\Http\Middleware\SetLocale::SUPPORTED_LOCALES;
@endphp
<span class="hidden sm:inline">{{ $locales[$currentLocale] ?? 'English' }}</span>
<svg class="ml-1 h-4 w-4 hidden sm:block" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
<div
x-show="open"
x-transition
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5 max-h-96 overflow-y-auto z-50"
>
@foreach($locales as $code => $name)
<form method="POST" action="{{ route('locale.update') }}" class="inline-block w-full">
@csrf
<input type="hidden" name="locale" value="{{ $code }}">
<button
type="submit"
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 {{ $currentLocale === $code ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-gray-700' }}"
>
{{ $name }}
@if($currentLocale === $code)
<svg class="inline-block w-4 h-4 ml-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
@endif
</button>
</form>
@endforeach
</div>
</div>
@auth @auth
<!-- User Dropdown --> <!-- User Dropdown -->
<div x-data="{ open: false }" @click.away="open = false" class="relative"> <div x-data="{ open: false }" @click.away="open = false" class="relative">
@@ -66,8 +146,11 @@ class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-
@click="open = !open" @click="open = !open"
class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none" class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none"
> >
{{ Auth::user()->name }} <span class="hidden md:inline">{{ Auth::user()->name }}</span>
<svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <span class="md:hidden w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center text-indigo-700 font-semibold">
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
</span>
<svg class="ml-1 h-4 w-4 hidden md:block" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path> <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg> </svg>
</button> </button>
@@ -75,7 +158,7 @@ class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 f
<div <div
x-show="open" x-show="open"
x-transition x-transition
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5" class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5 z-50"
> >
<a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"> <a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
{{ __('messages.nav.profile') }} {{ __('messages.nav.profile') }}
@@ -99,9 +182,14 @@ class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring
</div> </div>
</div> </div>
@else @else
<a href="{{ route('login') }}" class="text-sm text-gray-700 hover:text-gray-900"> <a href="{{ route('login') }}" class="text-sm text-gray-700 hover:text-gray-900 hidden sm:block">
{{ __('messages.nav.login') }} {{ __('messages.nav.login') }}
</a> </a>
<a href="{{ route('login') }}" class="sm:hidden p-2 text-gray-700 hover:bg-gray-100 rounded-md" title="{{ __('messages.nav.login') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
</a>
@endauth @endauth
</div> </div>
</div> </div>
@@ -110,8 +198,50 @@ class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring
<!-- Main Content --> <!-- Main Content -->
<div class="flex h-[calc(100vh-4rem)]"> <div class="flex h-[calc(100vh-4rem)]">
<!-- Sidebar --> <!-- Sidebar - Desktop -->
<aside class="w-64 bg-white border-r border-gray-200 overflow-y-auto"> <aside
id="kb-sidebar"
class="hidden lg:block bg-white border-r border-gray-200 overflow-y-auto relative"
:style="'width: ' + sidebarWidth + 'px'"
>
@livewire('sidebar-tree')
<!-- Resize Handle -->
<div
@mousedown="startResize($event)"
class="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-indigo-500 transition-colors group"
title="ドラッグして幅を変更"
>
<div class="absolute top-1/2 right-0 transform translate-x-1/2 -translate-y-1/2 w-1.5 h-12 bg-gray-300 rounded-full group-hover:bg-indigo-500 transition-colors"></div>
</div>
</aside>
<!-- Sidebar - Mobile Overlay -->
<div
x-show="mobileMenuOpen"
@click="mobileMenuOpen = false"
x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-gray-600 bg-opacity-75 z-30 lg:hidden"
style="display: none;"
></div>
<aside
x-show="mobileMenuOpen"
@click.away="mobileMenuOpen = false"
x-transition:enter="transition ease-in-out duration-300 transform"
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in-out duration-300 transform"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="fixed inset-y-0 left-0 top-16 w-64 bg-white border-r border-gray-200 overflow-y-auto z-40 lg:hidden"
style="display: none;"
>
@livewire('sidebar-tree') @livewire('sidebar-tree')
</aside> </aside>
@@ -129,6 +259,91 @@ class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring
<!-- Global Keyboard Shortcuts --> <!-- Global Keyboard Shortcuts -->
<script> <script>
// Preserve sidebar scroll position during navigation
document.addEventListener('click', function(e) {
const sidebar = document.getElementById('kb-sidebar');
if (!sidebar) return;
const link = e.target.closest('a');
if (link && sidebar.contains(link)) {
const scrollPos = sidebar.scrollTop;
sessionStorage.setItem('kb_sidebar_scroll', scrollPos);
}
}, true);
// Restore scroll position after page load
function restoreSidebarScroll() {
const sidebar = document.getElementById('kb-sidebar');
if (!sidebar) return;
const savedPos = sessionStorage.getItem('kb_sidebar_scroll');
if (savedPos !== null && savedPos !== '0') {
sidebar.scrollTop = parseInt(savedPos, 10);
}
}
// Highlight current document in sidebar
function highlightCurrentDocument() {
const sidebar = document.getElementById('kb-sidebar');
if (!sidebar) {
console.log('Sidebar not found for highlighting');
return;
}
const currentPath = window.location.pathname;
const links = sidebar.querySelectorAll('a');
console.log('Current path:', currentPath);
console.log('Found links in sidebar:', links.length);
links.forEach(link => {
const href = link.getAttribute('href');
// Remove previous highlighting
link.classList.remove('bg-indigo-50', 'text-indigo-700', 'font-semibold');
link.classList.add('text-gray-700');
const icon = link.querySelector('svg');
if (icon) {
icon.classList.remove('text-indigo-600');
icon.classList.add('text-gray-400', 'group-hover:text-gray-600');
}
// Check if this is the current page
if (href === currentPath || href === window.location.href ||
(href && currentPath && href.endsWith(currentPath))) {
console.log('Matched link:', href, 'with current path:', currentPath);
link.classList.add('bg-indigo-50', 'text-indigo-700', 'font-semibold');
link.classList.remove('text-gray-700');
if (icon) {
icon.classList.remove('text-gray-400', 'group-hover:text-gray-600');
icon.classList.add('text-indigo-600');
}
}
});
}
// Restore on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
restoreSidebarScroll();
highlightCurrentDocument();
});
} else {
restoreSidebarScroll();
highlightCurrentDocument();
}
// Also restore on window load (for safety)
window.addEventListener('load', () => {
restoreSidebarScroll();
highlightCurrentDocument();
});
// Update highlight after Alpine navigation
document.addEventListener('alpine:navigated', highlightCurrentDocument);
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault(); e.preventDefault();
@@ -15,7 +15,7 @@
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('messages.nav.dashboard') }} {{ __('messages.nav.dashboard') }}
</x-nav-link> </x-nav-link>
<x-nav-link href="/" :active="false"> <x-nav-link :href="url('/')" :active="false">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"></path>
</svg> </svg>
@@ -82,7 +82,7 @@
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('messages.nav.dashboard') }} {{ __('messages.nav.dashboard') }}
</x-responsive-nav-link> </x-responsive-nav-link>
<x-responsive-nav-link href="/" :active="false"> <x-responsive-nav-link :href="url('/')" :active="false">
{{ __('messages.nav.knowledge_base') }} {{ __('messages.nav.knowledge_base') }}
</x-responsive-nav-link> </x-responsive-nav-link>
</div> </div>
@@ -1,4 +1,4 @@
<div class="max-w-5xl mx-auto p-8"> <div class="max-w-5xl mx-auto p-4 sm:p-6 lg:p-8">
<!-- Flash Messages --> <!-- Flash Messages -->
@if (session()->has('message')) @if (session()->has('message'))
<div class="mb-4 p-4 bg-green-100 border border-green-400 text-green-700 rounded"> <div class="mb-4 p-4 bg-green-100 border border-green-400 text-green-700 rounded">
@@ -13,16 +13,16 @@
@endif @endif
<!-- Header --> <!-- Header -->
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-3xl font-bold text-gray-900"> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900">
{{ $isEditMode ? __('messages.documents.edit_document') : __('messages.documents.new_document') }} {{ $isEditMode ? __('messages.documents.edit_document') : __('messages.documents.new_document') }}
</h1> </h1>
<div class="flex space-x-3"> <div class="flex flex-wrap gap-2 sm:gap-3">
@if($isEditMode && $document) @if($isEditMode && $document)
<a <a
href="{{ route('documents.show', $document) }}" href="{{ route('documents.show', $document) }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" class="inline-flex items-center justify-center px-3 sm:px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 flex-1 sm:flex-none"
> >
{{ __('messages.common.cancel') }} {{ __('messages.common.cancel') }}
</a> </a>
@@ -30,14 +30,14 @@ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text
<button <button
wire:click="delete" wire:click="delete"
wire:confirm="{{ __('messages.documents.delete_confirm') }}" wire:confirm="{{ __('messages.documents.delete_confirm') }}"
class="inline-flex items-center px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50" class="inline-flex items-center justify-center px-3 sm:px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50 flex-1 sm:flex-none"
> >
{{ __('messages.documents.delete') }} {{ __('messages.documents.delete') }}
</button> </button>
@else @else
<a <a
href="{{ route('documents.show', 'home') }}" href="{{ route('documents.show', 'home') }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" class="inline-flex items-center justify-center px-3 sm:px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 flex-1 sm:flex-none"
> >
{{ __('messages.common.cancel') }} {{ __('messages.common.cancel') }}
</a> </a>
@@ -45,16 +45,76 @@ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text
<button <button
wire:click="save" wire:click="save"
class="inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md text-sm font-medium text-white hover:bg-indigo-700" class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 border border-transparent rounded-md text-sm font-medium text-white hover:bg-indigo-700 flex-1 sm:flex-none"
> >
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
</svg> </svg>
{{ __('messages.documents.save') }} <span class="hidden sm:inline">{{ __('messages.documents.save') }}</span>
<span class="sm:hidden">{{ __('messages.documents.save') }}</span>
</button> </button>
</div> </div>
</div> </div>
@if($isEditMode && $document)
<div class="mb-4 border-b border-gray-200" role="tablist" aria-label="{{ __('messages.documents.translation_tabs_label') }}">
<nav class="-mb-px flex flex-wrap gap-x-2">
@php $allLocales = \App\Http\Middleware\SetLocale::SUPPORTED_LOCALES; @endphp
@foreach($availableLocales as $loc)
@php $isActive = ($loc === $editingLocale); @endphp
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
class="px-3 py-2 text-sm font-medium border-b-2 {{ $isActive ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
{{ __('messages.locale_names.' . $loc) }}
@if($loc === $document->default_locale)
<span class="ml-1 text-xs text-gray-400"></span>
@endif
</a>
@endforeach
@if($isNewLocale && $editingLocale)
<span class="px-3 py-2 text-sm font-medium border-b-2 border-indigo-500 text-indigo-600">
{{ __('messages.locale_names.' . $editingLocale) }}
<span class="ml-1 text-xs text-gray-400">({{ __('messages.documents.new_document') }})</span>
</span>
@endif
@php $missingLocales = array_diff(array_keys($allLocales), $availableLocales, $isNewLocale ? [$editingLocale] : []); @endphp
@if(!empty($missingLocales))
<div x-data="{ open: false }" class="relative">
<button type="button" @click="open = !open"
class="px-3 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
+ {{ __('messages.documents.add_translation') }}
</button>
<div x-show="open" @click.outside="open = false" x-cloak
class="absolute right-0 z-10 mt-2 w-48 max-h-64 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
@foreach($missingLocales as $loc)
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
{{ $allLocales[$loc] }}
</a>
@endforeach
</div>
</div>
@endif
</nav>
@if($editingLocale !== $document->default_locale && !$isNewLocale)
<div class="mt-2 flex gap-2">
<button wire:click="setAsDefault" type="button"
class="px-2 py-1 text-xs text-indigo-600 border border-indigo-300 rounded hover:bg-indigo-50">
{{ __('messages.documents.set_as_default') }}
</button>
<button wire:click="deleteTranslation"
wire:confirm="{{ __('messages.documents.delete_confirm') }}"
type="button"
class="px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">
{{ __('messages.documents.delete_translation') }}
</button>
</div>
@endif
</div>
@endif
<!-- Form --> <!-- Form -->
<form wire:submit.prevent="save" class="space-y-6"> <form wire:submit.prevent="save" class="space-y-6">
<!-- Title --> <!-- Title -->
@@ -146,6 +206,42 @@ class="w-full"
'guide' 'guide'
], ],
status: ['lines', 'words', 'cursor'], status: ['lines', 'words', 'cursor'],
// Image upload configuration
uploadImage: true,
imageMaxSize: 2 * 1024 * 1024, // 2MB
imageAccept: 'image/png, image/jpeg, image/gif, image/webp',
imageUploadFunction: (file, onSuccess, onError) => {
const formData = new FormData();
formData.append('image', file);
fetch('{{ route("images.upload") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: formData,
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || 'Upload failed');
});
}
return response.json();
})
.then(data => {
// Insert markdown with alt text directly
const cm = this.editor.codemirror;
const altText = data.data.altText || 'image';
const url = data.data.filePath;
const markdown = `![${altText}](${url})`;
cm.replaceSelection(markdown);
})
.catch(error => {
onError(error.message || 'Failed to upload image');
});
},
}); });
this.editor.codemirror.on('change', () => { this.editor.codemirror.on('change', () => {
@@ -1,27 +1,43 @@
<div class="max-w-4xl mx-auto p-8"> <div class="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8">
@if($isFallback)
<div class="mb-4 p-4 bg-amber-50 border border-amber-300 rounded-md flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p class="text-sm text-amber-800">
{{ __('messages.documents.fallback_notice', ['locale' => __('messages.locale_names.' . $viewLocale)]) }}
</p>
@auth
@can('update', $document)
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => app()->getLocale()]) }}"
class="inline-flex items-center justify-center px-3 py-2 bg-amber-600 text-white text-sm font-medium rounded-md hover:bg-amber-700">
{{ __('messages.documents.add_translation') }}
</a>
@endcan
@endauth
</div>
@endif
<!-- Document Header --> <!-- Document Header -->
<div class="mb-8"> <div class="mb-6 sm:mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
<h1 class="text-4xl font-bold text-gray-900"> <h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900 break-words">
{{ $document->title }} {{ $document->title }}
</h1> </h1>
@auth @can('update', $document)
<a <a
href="{{ route('documents.edit', $document) }}" href="{{ route('documents.edit', $document) }}"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 whitespace-nowrap"
> >
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg> </svg>
{{ __('messages.documents.edit') }} {{ __('messages.documents.edit') }}
</a> </a>
@endauth @endcan
</div> </div>
<div class="flex items-center text-sm text-gray-500 space-x-4"> <div class="flex flex-col sm:flex-row sm:items-center text-xs sm:text-sm text-gray-500 gap-2 sm:gap-4">
<span> <span>
{{ __('messages.documents.updated') }} {{ $document->updated_at->diffForHumans() }} {{ __('messages.documents.updated') }} {{ $document->updated_at->diffForHumans() }}{{ config('app.timezone') }}
</span> </span>
@if($document->updated_by && $document->updater) @if($document->updated_by && $document->updater)
@@ -37,7 +53,7 @@ class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-
</div> </div>
<!-- Document Content --> <!-- Document Content -->
<div class="prose prose-lg max-w-none mb-12"> <div class="prose prose-sm sm:prose-base lg:prose-lg max-w-none mb-8 sm:mb-12">
{!! $renderedContent !!} {!! $renderedContent !!}
</div> </div>
@@ -53,7 +69,6 @@ class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-
<a <a
href="{{ route('documents.show', $backlink) }}" href="{{ route('documents.show', $backlink) }}"
class="block p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition" class="block p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition"
wire:navigate
> >
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -70,15 +85,15 @@ class="block p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition"
@endif @endif
<!-- Document Metadata --> <!-- Document Metadata -->
<div class="mt-12 pt-8 border-t border-gray-200"> <div class="mt-8 sm:mt-12 pt-6 sm:pt-8 border-t border-gray-200">
<div class="grid grid-cols-2 gap-4 text-sm text-gray-500"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs sm:text-sm text-gray-500">
<div> <div class="break-all">
<span class="font-medium">{{ __('messages.documents.path') }}:</span> <span class="font-medium">{{ __('messages.documents.path') }}:</span>
<code class="ml-2 text-xs bg-gray-100 px-2 py-1 rounded">{{ $document->path }}</code> <code class="ml-2 text-xs bg-gray-100 px-2 py-1 rounded">{{ $document->path }}</code>
</div> </div>
<div> <div>
<span class="font-medium">{{ __('messages.documents.last_modified') }}:</span> <span class="font-medium">{{ __('messages.documents.last_modified') }}:</span>
<span class="ml-2">{{ $document->updated_at->format('Y-m-d H:i:s') }}</span> <span class="ml-2">{{ $document->updated_at->format('Y-m-d H:i:s') }}{{ config('app.timezone') }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -32,7 +32,7 @@ class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;" style="display: none;"
@click="open = false" @click="open = false"
> >
<div class="flex min-h-full items-start justify-center p-4 pt-[10vh]"> <div class="flex min-h-full items-start justify-center p-2 sm:p-4 pt-[5vh] sm:pt-[10vh]">
<div <div
class="w-full max-w-2xl bg-white rounded-lg shadow-2xl" class="w-full max-w-2xl bg-white rounded-lg shadow-2xl"
@click.stop @click.stop
@@ -41,16 +41,16 @@ class="w-full max-w-2xl bg-white rounded-lg shadow-2xl"
wire:keydown.enter.prevent="selectDocument" wire:keydown.enter.prevent="selectDocument"
> >
<!-- Search Input --> <!-- Search Input -->
<div class="p-4 border-b border-gray-200"> <div class="p-3 sm:p-4 border-b border-gray-200">
<div class="relative"> <div class="relative">
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="absolute left-2 sm:left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 sm:h-5 sm:w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg> </svg>
<input <input
x-ref="searchInput" x-ref="searchInput"
type="text" type="text"
wire:model.live="search" wire:model.live="search"
class="w-full pl-10 pr-4 py-3 border-0 focus:ring-0 text-lg" class="w-full pl-8 sm:pl-10 pr-4 py-2 sm:py-3 border-0 focus:ring-0 text-base sm:text-lg"
placeholder="{{ __('messages.quick_switcher.placeholder') }}" placeholder="{{ __('messages.quick_switcher.placeholder') }}"
autocomplete="off" autocomplete="off"
> >
@@ -58,7 +58,7 @@ class="w-full pl-10 pr-4 py-3 border-0 focus:ring-0 text-lg"
</div> </div>
<!-- Results --> <!-- Results -->
<div class="max-h-96 overflow-y-auto"> <div class="max-h-60 sm:max-h-96 overflow-y-auto">
@if(empty($this->results)) @if(empty($this->results))
<div class="p-8 text-center text-gray-500"> <div class="p-8 text-center text-gray-500">
{{ __('messages.quick_switcher.no_results') }} {{ __('messages.quick_switcher.no_results') }}
@@ -68,9 +68,8 @@ class="w-full pl-10 pr-4 py-3 border-0 focus:ring-0 text-lg"
@foreach($this->results as $index => $result) @foreach($this->results as $index => $result)
<li> <li>
<a <a
href="{{ route('documents.show', $result['id']) }}" href="{{ route('documents.show', $result['slug']) }}"
class="block px-4 py-3 hover:bg-gray-50 transition {{ $index === $selectedIndex ? 'bg-indigo-50' : '' }}" class="block px-4 py-3 hover:bg-gray-50 transition {{ $index === $selectedIndex ? 'bg-indigo-50' : '' }}"
wire:navigate
@click="open = false" @click="open = false"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -103,21 +102,21 @@ class="block px-4 py-3 hover:bg-gray-50 transition {{ $index === $selectedIndex
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="px-4 py-3 bg-gray-50 border-t border-gray-200 text-xs text-gray-500"> <div class="px-3 sm:px-4 py-2 sm:py-3 bg-gray-50 border-t border-gray-200 text-xs text-gray-500">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-2 sm:space-x-4 flex-wrap gap-y-1">
<span class="flex items-center"> <span class="flex items-center">
<kbd class="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1"></kbd> <kbd class="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1"></kbd>
<kbd class="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-2"></kbd> <kbd class="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1 sm:mr-2"></kbd>
{{ __('messages.quick_switcher.navigate') }} <span class="hidden sm:inline">{{ __('messages.quick_switcher.navigate') }}</span>
</span> </span>
<span class="flex items-center"> <span class="flex items-center">
<kbd class="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-2"></kbd> <kbd class="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1 sm:mr-2"></kbd>
{{ __('messages.quick_switcher.select') }} <span class="hidden sm:inline">{{ __('messages.quick_switcher.select') }}</span>
</span> </span>
<span class="flex items-center"> <span class="flex items-center">
<kbd class="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-2">esc</kbd> <kbd class="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1 sm:mr-2">esc</kbd>
{{ __('messages.quick_switcher.close') }} <span class="hidden sm:inline">{{ __('messages.quick_switcher.close') }}</span>
</span> </span>
</div> </div>
</div> </div>
@@ -10,7 +10,6 @@
<a <a
href="{{ route('documents.show', $file['document']) }}" href="{{ route('documents.show', $file['document']) }}"
class="flex items-center px-2 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100 group" class="flex items-center px-2 py-1.5 text-sm text-gray-700 rounded hover:bg-gray-100 group"
wire:navigate
> >
<svg class="w-4 h-4 mr-2 text-gray-400 group-hover:text-gray-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2 text-gray-400 group-hover:text-gray-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
@@ -13,12 +13,6 @@
</div> </div>
</div> </div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-locale-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg"> <div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl"> <div class="max-w-xl">
@include('profile.partials.update-password-form') @include('profile.partials.update-password-form')
@@ -1,46 +0,0 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('messages.settings.language') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('messages.settings.select_language') }}
</p>
</header>
<form method="post" action="{{ route('locale.update') }}" class="mt-6 space-y-6">
@csrf
<div>
<x-input-label for="locale" :value="__('messages.settings.language')" />
<select
id="locale"
name="locale"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
@foreach(\App\Http\Middleware\SetLocale::SUPPORTED_LOCALES as $code => $name)
<option value="{{ $code }}" {{ (auth()->user()->locale ?? 'en') === $code ? 'selected' : '' }}>
{{ $name }}
</option>
@endforeach
</select>
<x-input-error class="mt-2" :messages="$errors->get('locale')" />
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('messages.common.save') }}</x-primary-button>
@if (session('success'))
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ session('success') }}</p>
@endif
</div>
</form>
</section>
-6
View File
@@ -7,16 +7,10 @@
use App\Http\Controllers\Auth\NewPasswordController; use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController; use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController; use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Route::get('register', [RegisteredUserController::class, 'create'])
->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);
Route::get('login', [AuthenticatedSessionController::class, 'create']) Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login'); ->name('login');
+19 -2
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\LocaleController; use App\Http\Controllers\LocaleController;
use App\Http\Controllers\ImageUploadController;
use App\Http\Controllers\Admin\UserController as AdminUserController; use App\Http\Controllers\Admin\UserController as AdminUserController;
use App\Livewire\DocumentViewer; use App\Livewire\DocumentViewer;
use App\Livewire\DocumentEditor; use App\Livewire\DocumentEditor;
@@ -22,11 +23,16 @@
return view('dashboard'); return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard');
// Locale switcher - available for all users (both authenticated and guest)
Route::post('/locale', [LocaleController::class, 'update'])->name('locale.update');
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::post('/locale', [LocaleController::class, 'update'])->name('locale.update');
// Image upload for editor
Route::post('/images/upload', [ImageUploadController::class, 'upload'])->name('images.upload');
}); });
// Admin routes // Admin routes
@@ -38,7 +44,18 @@
// 認証が必要なルート(より具体的なルートを先に定義) // 認証が必要なルート(より具体的なルートを先に定義)
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/create', DocumentEditor::class)->name('create'); Route::get('/create', DocumentEditor::class)->name('create');
Route::get('/{document}/edit', DocumentEditor::class)->name('edit'); Route::get('/{document}/edit', DocumentEditor::class)
->middleware('can:update,document')
->name('edit');
Route::post('/{document}/translations', [\App\Http\Controllers\DocumentTranslationController::class, 'store'])
->middleware('can:update,document')
->name('translations.store');
Route::delete('/{document}/translations/{locale}', [\App\Http\Controllers\DocumentTranslationController::class, 'destroy'])
->middleware('can:update,document')
->name('translations.destroy');
Route::get('/{document}/translations/{locale}/edit', \App\Livewire\DocumentEditor::class)
->middleware('can:update,document')
->name('translations.edit');
}); });
// 公開ルート(動的ルートは最後に) // 公開ルート(動的ルートは最後に)
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class DocumentI18nTest extends TestCase
{
use RefreshDatabase;
public function test_viewer_shows_current_locale_translation(): void
{
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'hello']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => '<p>Hi</p>']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'こんにちは',
'content' => 'やあ',
'rendered_html' => '<p>やあ</p>',
]);
session()->put('locale', 'ja');
$response = $this->get(route('documents.show', $doc));
$response->assertOk();
$response->assertSee('こんにちは');
$response->assertSee('やあ', false);
}
public function test_viewer_falls_back_with_banner_when_locale_missing(): void
{
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'fb']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => '<p>Hi</p>']);
session()->put('locale', 'ja');
$response = $this->get(route('documents.show', $doc));
$response->assertOk();
$response->assertSee('Hello'); // fallback content
// banner present (use the JA translation key value)
$response->assertSeeText(__('messages.documents.fallback_notice', [], 'ja'));
}
public function test_no_banner_when_translation_exists(): void
{
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'nb']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => '<p>Hi</p>']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'こんにちは',
'content' => 'やあ',
'rendered_html' => '<p>やあ</p>',
]);
session()->put('locale', 'ja');
$response = $this->get(route('documents.show', $doc));
$response->assertOk();
$response->assertDontSeeText(__('messages.documents.fallback_notice', [], 'ja'));
}
public function test_editor_loads_existing_translation_for_locale(): void
{
$owner = \App\Models\User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id, 'slug' => 'editor']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'EN body']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'こんにちは',
'content' => 'JA body',
]);
$response = $this->actingAs($owner)->get(route('documents.translations.edit', [
'document' => $doc,
'locale' => 'ja',
]));
$response->assertOk();
$response->assertSee('こんにちは');
$response->assertSee('JA body');
}
public function test_editor_for_missing_locale_shows_empty_form_with_new_locale_state(): void
{
$owner = \App\Models\User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id, 'slug' => 'editor2']);
$response = $this->actingAs($owner)->get(route('documents.translations.edit', [
'document' => $doc,
'locale' => 'ja',
]));
$response->assertOk();
// The blade should render a tab marked active for ja with empty inputs
$response->assertSeeText(__('messages.locale_names.ja', [], 'en'));
}
public function test_quick_switcher_finds_documents_by_any_locale_title(): void
{
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'qs']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started', 'content' => 'EN body']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'はじめに',
'content' => '本文',
]);
$component = \Livewire\Livewire::test(\App\Livewire\QuickSwitcher::class)
->set('search', 'はじめに');
$results = $component->get('results');
$this->assertCount(1, $results);
$this->assertSame($doc->id, $results[0]['id']);
$component2 = \Livewire\Livewire::test(\App\Livewire\QuickSwitcher::class)
->set('search', 'Getting');
$results2 = $component2->get('results');
$this->assertCount(1, $results2);
$this->assertSame($doc->id, $results2[0]['id']);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DocumentMigrationTest extends TestCase
{
use RefreshDatabase;
public function test_documents_table_has_default_locale_after_migration(): void
{
$this->assertTrue(Schema::hasColumn('documents', 'default_locale'));
}
public function test_documents_table_no_longer_has_translatable_columns(): void
{
$this->assertFalse(Schema::hasColumn('documents', 'title'));
$this->assertFalse(Schema::hasColumn('documents', 'content'));
$this->assertFalse(Schema::hasColumn('documents', 'rendered_html'));
}
public function test_document_translations_table_exists_with_required_columns(): void
{
$this->assertTrue(Schema::hasTable('document_translations'));
foreach (['document_id', 'locale', 'title', 'content', 'rendered_html', 'created_by', 'updated_by', 'created_at', 'updated_at'] as $col) {
$this->assertTrue(
Schema::hasColumn('document_translations', $col),
"document_translations missing column: $col"
);
}
}
public function test_document_translations_unique_document_locale(): void
{
DB::table('documents')->insert([
'path' => 'A.md',
'slug' => 'a',
'default_locale' => 'en',
'file_size' => 0,
'file_hash' => str_repeat('0', 64),
'file_modified_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$docId = DB::table('documents')->where('slug', 'a')->value('id');
DB::table('document_translations')->insert([
'document_id' => $docId,
'locale' => 'en',
'title' => 'A',
'content' => '...',
'rendered_html' => '<p>...</p>',
'created_at' => now(),
'updated_at' => now(),
]);
$this->expectException(\Illuminate\Database\QueryException::class);
DB::table('document_translations')->insert([
'document_id' => $docId,
'locale' => 'en',
'title' => 'duplicate',
'content' => '...',
'rendered_html' => '<p>...</p>',
'created_at' => now(),
'updated_at' => now(),
]);
}
public function test_existing_documents_data_is_copied_to_translations(): void
{
// Roll the new migration back so the legacy columns exist again
\Illuminate\Support\Facades\Artisan::call('migrate:rollback', ['--step' => 1]);
$this->assertTrue(\Illuminate\Support\Facades\Schema::hasColumn('documents', 'title'));
// Seed a legacy document row directly
\Illuminate\Support\Facades\DB::table('documents')->insert([
'path' => 'Legacy.md',
'title' => 'Legacy Title',
'slug' => 'legacy-title',
'content' => '# Legacy body',
'rendered_html' => '<h1>Legacy body</h1>',
'file_size' => 0,
'file_hash' => str_repeat('0', 64),
'file_modified_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$docId = \Illuminate\Support\Facades\DB::table('documents')->where('slug', 'legacy-title')->value('id');
// Re-run the migration
\Illuminate\Support\Facades\Artisan::call('migrate');
// Verify the data was copied to document_translations
$translation = \Illuminate\Support\Facades\DB::table('document_translations')
->where('document_id', $docId)
->first();
$this->assertNotNull($translation, 'Translation row should have been created from legacy data');
$this->assertSame('Legacy Title', $translation->title);
$this->assertSame('# Legacy body', $translation->content);
$this->assertSame('<h1>Legacy body</h1>', $translation->rendered_html);
$this->assertSame(config('app.locale', 'en'), $translation->locale);
// Verify documents.default_locale was set
$defaultLocale = \Illuminate\Support\Facades\DB::table('documents')->where('id', $docId)->value('default_locale');
$this->assertSame(config('app.locale', 'en'), $defaultLocale);
}
}
@@ -0,0 +1,94 @@
<?php
namespace Tests\Feature;
use App\Models\Document;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DocumentTranslationCrudTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_add_a_translation(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($owner)->post(
route('documents.translations.store', $doc),
['locale' => 'ja', 'title' => 'こんにちは', 'content' => '本文']
);
$response->assertRedirect();
$this->assertNotNull($doc->fresh()->translationFor('ja', false));
}
public function test_non_owner_cannot_add_translation(): void
{
$owner = User::factory()->create();
$other = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($other)->post(
route('documents.translations.store', $doc),
['locale' => 'ja', 'title' => 'こんにちは', 'content' => '本文']
);
$response->assertForbidden();
}
public function test_invalid_locale_is_rejected(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($owner)->post(
route('documents.translations.store', $doc),
['locale' => 'xx', 'title' => 'X', 'content' => 'Y']
);
$response->assertSessionHasErrors('locale');
}
public function test_duplicate_locale_returns_422(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($owner)->post(
route('documents.translations.store', $doc),
['locale' => 'en', 'title' => 'X', 'content' => 'Y']
);
$response->assertStatus(422);
}
public function test_owner_can_delete_non_default_translation(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
app(\App\Services\DocumentService::class)->addTranslation($doc, 'ja', 'JA', 'body', $owner->id);
$response = $this->actingAs($owner)->delete(
route('documents.translations.destroy', ['document' => $doc, 'locale' => 'ja'])
);
$response->assertRedirect();
$this->assertNull($doc->fresh()->translationFor('ja', false));
}
public function test_default_locale_translation_cannot_be_deleted(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($owner)->delete(
route('documents.translations.destroy', ['document' => $doc, 'locale' => 'en'])
);
$response->assertStatus(422);
$this->assertNotNull($doc->fresh()->translationFor('en', false));
}
}
@@ -0,0 +1,79 @@
<?php
namespace Tests\Unit\Markdown;
use App\Models\Document;
use Tests\TestCase;
class MediaEmbedExtensionTest extends TestCase
{
public function test_normal_image_still_renders_as_img(): void
{
$html = Document::renderMarkdown('![alt](/photo.png)');
$this->assertStringContainsString('<img', $html);
$this->assertStringContainsString('src="/photo.png"', $html);
}
public function test_video_url_renders_as_video_tag(): void
{
$html = Document::renderMarkdown('![](/demo.mp4)');
$this->assertStringContainsString('<video', $html);
$this->assertStringContainsString('src="/demo.mp4"', $html);
$this->assertStringNotContainsString('<img', $html);
}
public function test_youtube_url_renders_as_iframe(): void
{
$html = Document::renderMarkdown('![](https://youtu.be/dQw4w9WgXcQ)');
$this->assertStringContainsString('<iframe', $html);
$this->assertStringContainsString('youtube-nocookie.com', $html);
}
public function test_vimeo_url_renders_as_iframe(): void
{
$html = Document::renderMarkdown('![](https://vimeo.com/123456789)');
$this->assertStringContainsString('<iframe', $html);
$this->assertStringContainsString('player.vimeo.com', $html);
}
public function test_image_and_video_coexist_in_same_document(): void
{
$md = "![photo](/photo.png)\n\n![](/demo.mp4)";
$html = Document::renderMarkdown($md);
$this->assertStringContainsString('<img', $html);
$this->assertStringContainsString('<video', $html);
}
public function test_multiple_media_in_same_paragraph(): void
{
$html = Document::renderMarkdown('![](/a.mp4) and ![](/b.mp4)');
$this->assertSame(2, substr_count($html, '<video'));
}
public function test_video_inside_list_item(): void
{
$html = Document::renderMarkdown("- ![](/demo.mp4)");
$this->assertStringContainsString('<li>', $html);
$this->assertStringContainsString('<video', $html);
}
public function test_wiki_link_unaffected_alongside_media(): void
{
$html = Document::renderMarkdown("![](/demo.mp4)\n\n[[Other Doc]]");
$this->assertStringContainsString('<video', $html);
$this->assertStringContainsString('[[Other Doc]]', $html);
}
public function test_youtube_with_timestamp_in_document(): void
{
$html = Document::renderMarkdown('![](https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s)');
$this->assertStringContainsString('?start=30', $html);
}
public function test_audio_url_renders_as_audio_tag(): void
{
$html = Document::renderMarkdown('![](/clip.mp3)');
$this->assertStringContainsString('<audio', $html);
$this->assertStringContainsString('src="/clip.mp3"', $html);
}
}
@@ -0,0 +1,203 @@
<?php
namespace Tests\Unit\Markdown;
use App\Markdown\MediaUrlResolver;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class MediaUrlResolverTest extends TestCase
{
private MediaUrlResolver $resolver;
protected function setUp(): void
{
parent::setUp();
$this->resolver = new MediaUrlResolver();
}
#[DataProvider('nonMediaUrls')]
public function test_returns_null_for_non_media_urls(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function nonMediaUrls(): array
{
return [
'normal image' => ['/photo.jpg'],
'svg' => ['/icon.svg'],
'png' => ['/avatar.png'],
'no extension' => ['/foo'],
'empty string' => [''],
'javascript scheme' => ['javascript:alert(1)'],
'host-only' => ['http://'],
'youtu.be lookalike host' => ['https://example.com/youtu.be-fake/abc'],
];
}
#[DataProvider('videoUrls')]
public function test_video_urls_produce_video_tag(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<video', $html);
$this->assertStringContainsString('controls', $html);
$this->assertStringContainsString('class="kb-video"', $html);
}
public static function videoUrls(): array
{
return [
'mp4' => ['/demo.mp4'],
'webm' => ['/demo.webm'],
'ogv' => ['/demo.ogv'],
'mov' => ['/demo.mov'],
'm4v' => ['/demo.m4v'],
'uppercase extension' => ['/demo.MP4'],
'with query string' => ['https://example.com/path/demo.mp4?token=abc'],
'absolute http' => ['https://example.com/demo.mp4'],
];
}
public function test_video_url_is_html_escaped(): void
{
$html = $this->resolver->resolve('/path/with"quote.mp4');
$this->assertNotNull($html);
$this->assertStringNotContainsString('"quote.mp4"', $html);
$this->assertStringContainsString('&quot;', $html);
}
#[DataProvider('audioUrls')]
public function test_audio_urls_produce_audio_tag(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<audio', $html);
$this->assertStringContainsString('controls', $html);
$this->assertStringContainsString('class="kb-audio"', $html);
}
public static function audioUrls(): array
{
return [
'mp3' => ['/clip.mp3'],
'wav' => ['/clip.wav'],
'ogg' => ['/clip.ogg'],
'm4a' => ['/clip.m4a'],
];
}
#[DataProvider('youtubeUrls')]
public function test_youtube_urls_produce_iframe(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<iframe', $html);
$this->assertStringContainsString('youtube-nocookie.com/embed/dQw4w9WgXcQ', $html);
$this->assertStringContainsString('class="kb-embed kb-embed-youtube"', $html);
$this->assertStringContainsString('loading="lazy"', $html);
$this->assertStringContainsString('allowfullscreen', $html);
}
public static function youtubeUrls(): array
{
return [
'short youtu.be' => ['https://youtu.be/dQw4w9WgXcQ'],
'watch v=' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
'shorts' => ['https://www.youtube.com/shorts/dQw4w9WgXcQ'],
'embed' => ['https://www.youtube.com/embed/dQw4w9WgXcQ'],
'mobile' => ['https://m.youtube.com/watch?v=dQw4w9WgXcQ'],
'no www watch' => ['https://youtube.com/watch?v=dQw4w9WgXcQ'],
];
}
#[DataProvider('invalidYoutubeUrls')]
public function test_invalid_youtube_urls_return_null(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function invalidYoutubeUrls(): array
{
return [
'too short id' => ['https://youtu.be/short'],
'host mismatch' => ['https://example.com/watch?v=dQw4w9WgXcQ'],
'XSS attempt in id' => ['https://youtu.be/abc"><script>'],
];
}
#[DataProvider('youtubeTimestampUrls')]
public function test_youtube_timestamp_normalizes_to_start(string $url, int $expectedStart): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringContainsString("?start={$expectedStart}", $html);
}
public static function youtubeTimestampUrls(): array
{
return [
't=30s' => ['https://youtu.be/dQw4w9WgXcQ?t=30s', 30],
't=30 (no suffix)' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30', 30],
't=1m20s' => ['https://youtu.be/dQw4w9WgXcQ?t=1m20s', 80],
't=1h2m3s' => ['https://youtu.be/dQw4w9WgXcQ?t=1h2m3s', 3723],
'start=45' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45', 45],
];
}
#[DataProvider('vimeoUrls')]
public function test_vimeo_urls_produce_iframe(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<iframe', $html);
$this->assertStringContainsString('player.vimeo.com/video/123456789', $html);
$this->assertStringContainsString('dnt=1', $html);
$this->assertStringContainsString('class="kb-embed kb-embed-vimeo"', $html);
}
public static function vimeoUrls(): array
{
return [
'vimeo.com' => ['https://vimeo.com/123456789'],
'www.vimeo.com' => ['https://www.vimeo.com/123456789'],
'player.vimeo.com' => ['https://player.vimeo.com/video/123456789'],
];
}
#[DataProvider('vimeoTimestampUrls')]
public function test_vimeo_timestamp_preserved_as_hash(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringContainsString('#t=30s', $html);
}
public static function vimeoTimestampUrls(): array
{
return [
'hash form' => ['https://vimeo.com/123456789#t=30s'],
'query form' => ['https://vimeo.com/123456789?t=30s'],
];
}
public function test_vimeo_invalid_id_returns_null(): void
{
$this->assertNull($this->resolver->resolve('https://vimeo.com/notanumber'));
}
#[DataProvider('vimeoFalsePositives')]
public function test_vimeo_false_positives_return_null(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function vimeoFalsePositives(): array
{
return [
'digits then letter' => ['https://vimeo.com/123abc'],
'digits then x' => ['https://vimeo.com/123x'],
];
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace Tests\Unit\Models;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class DocumentTest extends TestCase
{
use RefreshDatabase;
public function test_title_accessor_returns_current_locale_translation(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'こんにちは',
]);
App::setLocale('ja');
$this->assertSame('こんにちは', $doc->fresh()->title);
App::setLocale('en');
$this->assertSame('Hello', $doc->fresh()->title);
}
public function test_title_accessor_falls_back_to_default_locale(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello']);
App::setLocale('ja');
$this->assertSame('Hello', $doc->fresh()->title);
}
public function test_content_and_rendered_html_accessors_fall_back(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
$doc->translations()->where('locale', 'en')->update([
'content' => 'English body',
'rendered_html' => '<p>English body</p>',
]);
App::setLocale('ja');
$fresh = $doc->fresh();
$this->assertSame('English body', $fresh->content);
$this->assertSame('<p>English body</p>', $fresh->rendered_html);
}
public function test_is_fallback_returns_true_when_locale_missing(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
$this->assertTrue($doc->isFallback('ja'));
$this->assertFalse($doc->isFallback('en'));
}
public function test_translation_for_returns_null_when_fallback_disabled(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
$this->assertNull($doc->translationFor('ja', fallback: false));
$this->assertNotNull($doc->translationFor('ja', fallback: true));
}
public function test_available_locales_lists_existing_translations(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
DocumentTranslation::factory()->create(['document_id' => $doc->id, 'locale' => 'ja']);
DocumentTranslation::factory()->create(['document_id' => $doc->id, 'locale' => 'fr']);
$locales = $doc->fresh()->availableLocales();
sort($locales);
$this->assertSame(['en', 'fr', 'ja'], $locales);
}
public function test_sync_links_creates_outgoing_links_with_resolved_targets(): void
{
$target = Document::factory()->create(['default_locale' => 'en']);
$target->translations()->where('locale', 'en')->update(['title' => 'Target']);
$source = Document::factory()->create(['default_locale' => 'en']);
$source->translations()->where('locale', 'en')->update([
'content' => 'See [[Target]] for details.',
]);
$source->fresh('translations')->syncLinks();
$links = $source->fresh()->outgoingLinks;
$this->assertCount(1, $links);
$this->assertSame($target->id, $links->first()->target_document_id);
$this->assertSame('Target', $links->first()->target_title);
}
public function test_sync_links_records_unresolved_links_with_null_target(): void
{
$source = Document::factory()->create();
$source->translations()->first()->update([
'content' => 'Goes to [[NoSuchPage]].',
]);
$source->fresh('translations')->syncLinks();
$links = $source->fresh()->outgoingLinks;
$this->assertCount(1, $links);
$this->assertNull($links->first()->target_document_id);
}
public function test_process_links_replaces_wiki_link_with_anchor_keeping_label(): void
{
$target = Document::factory()->create(['default_locale' => 'en', 'slug' => 'target-doc']);
$target->translations()->where('locale', 'en')->update(['title' => 'Target']);
\Illuminate\Support\Facades\App::setLocale('ja');
\App\Models\DocumentTranslation::factory()->create([
'document_id' => $target->id,
'locale' => 'ja',
'title' => 'ターゲット',
]);
$source = Document::factory()->create();
$source->translations()->first()->update([
'rendered_html' => '<p>See [[Target]].</p>',
]);
$html = $source->fresh()->processLinks();
$this->assertStringContainsString('href="' . route('documents.show', 'target-doc') . '"', $html);
$this->assertStringContainsString('>Target<', $html); // label preserved
$this->assertStringContainsString('class="wiki-link"', $html);
}
public function test_process_links_marks_unresolved_links_as_new(): void
{
$source = Document::factory()->create();
$source->translations()->first()->update([
'rendered_html' => '<p>Click [[Ghost]].</p>',
]);
$html = $source->fresh()->processLinks();
$this->assertStringContainsString('wiki-link-new', $html);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Models;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DocumentTranslationTest extends TestCase
{
use RefreshDatabase;
public function test_belongs_to_a_document(): void
{
$doc = Document::factory()->create();
$translation = $doc->translations()->first();
$this->assertInstanceOf(Document::class, $translation->document);
$this->assertSame($doc->id, $translation->document->id);
}
public function test_unique_document_locale_constraint(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
$this->expectException(QueryException::class);
DocumentTranslation::create([
'document_id' => $doc->id,
'locale' => 'en',
'title' => 'Duplicate',
'content' => 'x',
'rendered_html' => '<p>x</p>',
]);
}
public function test_cascade_delete_when_document_deleted(): void
{
$doc = Document::factory()->create();
$translationId = $doc->translations()->first()->id;
$doc->forceDelete();
$this->assertNull(DocumentTranslation::find($translationId));
}
public function test_render_markdown_converts_basic_markdown(): void
{
$html = DocumentTranslation::renderMarkdown('# Hello');
$this->assertStringContainsString('<h1>', $html);
$this->assertStringContainsString('Hello', $html);
}
}
@@ -0,0 +1,129 @@
<?php
namespace Tests\Unit\Services;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\User;
use App\Services\DocumentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class DocumentServiceTest extends TestCase
{
use RefreshDatabase;
private DocumentService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new DocumentService();
}
public function test_create_document_creates_one_translation_in_given_locale(): void
{
App::setLocale('en'); // ensure deterministic
$user = User::factory()->create();
$doc = $this->service->createDocument('Hello', '# Hi', $user->id, 'en');
$this->assertSame('en', $doc->default_locale);
$this->assertSame('Hello.md', $doc->path);
$this->assertSame('hello', $doc->slug);
$this->assertCount(1, $doc->translations);
$this->assertSame('Hello', $doc->translations->first()->title);
$this->assertSame('# Hi', $doc->translations->first()->content);
$this->assertStringContainsString('<h1>', $doc->translations->first()->rendered_html);
}
public function test_update_document_in_default_locale_regenerates_path_and_slug(): void
{
$doc = $this->service->createDocument('Old', 'body', null, 'en');
$updated = $this->service->updateDocument($doc, 'New Title', 'body2', null, 'en');
$this->assertSame('New Title.md', $updated->path);
$this->assertSame('new-title', $updated->slug);
}
public function test_update_document_in_non_default_locale_does_not_change_path(): void
{
$doc = $this->service->createDocument('English', 'body', null, 'en');
$originalPath = $doc->path;
$originalSlug = $doc->slug;
$updated = $this->service->updateDocument($doc, '日本語タイトル', '本文', null, 'ja');
$this->assertSame($originalPath, $updated->path);
$this->assertSame($originalSlug, $updated->slug);
$this->assertSame('日本語タイトル', $updated->translationFor('ja', false)->title);
}
public function test_add_translation_creates_new_locale_row(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null);
$this->assertCount(2, $doc->fresh()->translations);
$this->assertSame('こんにちは', $doc->fresh()->translationFor('ja', false)->title);
}
public function test_add_translation_throws_on_duplicate_locale(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->expectException(\InvalidArgumentException::class);
$this->service->addTranslation($doc, 'en', 'X', 'Y', null);
}
public function test_delete_translation_removes_non_default(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null);
$this->service->deleteTranslation($doc, 'ja');
$this->assertNull($doc->fresh()->translationFor('ja', false));
}
public function test_delete_translation_refuses_default_locale(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->expectException(\InvalidArgumentException::class);
$this->service->deleteTranslation($doc, 'en');
}
public function test_set_default_locale_requires_existing_translation(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->expectException(\InvalidArgumentException::class);
$this->service->setDefaultLocale($doc, 'ja');
}
public function test_set_default_locale_regenerates_path_from_new_locale_title(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null);
$updated = $this->service->setDefaultLocale($doc, 'ja');
$this->assertSame('ja', $updated->default_locale);
$this->assertSame('こんにちは.md', $updated->path);
}
public function test_search_returns_distinct_documents_across_locales(): void
{
$doc = $this->service->createDocument('Searchword', 'body', null, 'en');
$this->service->addTranslation($doc, 'ja', 'Searchword JA', 'Searchword body', null);
$results = $this->service->search('Searchword');
$this->assertCount(1, $results); // distinct, even though 2 translations match
$this->assertSame($doc->id, $results->first()->id);
}
}
@@ -0,0 +1,88 @@
<?php
namespace Tests\Unit\Services;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Services\WikiLinkResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WikiLinkResolverTest extends TestCase
{
use RefreshDatabase;
private WikiLinkResolver $resolver;
protected function setUp(): void
{
parent::setUp();
$this->resolver = new WikiLinkResolver();
}
public function test_resolves_via_current_locale_title(): void
{
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'getting-started']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'はじめに',
]);
$resolved = $this->resolver->resolve('はじめに', 'ja');
$this->assertSame($doc->id, $resolved->id);
}
public function test_resolves_via_default_locale_when_current_locale_missing(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started']);
$resolved = $this->resolver->resolve('Getting Started', 'ja');
$this->assertSame($doc->id, $resolved->id);
}
public function test_resolves_via_any_locale_deterministically(): void
{
// Two documents both have a 'fr' translation titled "Bonjour", neither is current/default
$docA = Document::factory()->create(['default_locale' => 'en']);
DocumentTranslation::factory()->create(['document_id' => $docA->id, 'locale' => 'fr', 'title' => 'Bonjour']);
$docB = Document::factory()->create(['default_locale' => 'en']);
DocumentTranslation::factory()->create(['document_id' => $docB->id, 'locale' => 'fr', 'title' => 'Bonjour']);
$resolved = $this->resolver->resolve('Bonjour', 'ja');
// Lower id wins (deterministic)
$this->assertSame($docA->id, $resolved->id);
}
public function test_resolves_via_slug_when_no_title_match(): void
{
$doc = Document::factory()->create(['slug' => 'unique-slug', 'default_locale' => 'en']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Whatever']);
$resolved = $this->resolver->resolve('unique-slug', 'ja');
$this->assertSame($doc->id, $resolved->id);
}
public function test_returns_null_when_nothing_matches(): void
{
$this->assertNull($this->resolver->resolve('Nonexistent', 'en'));
}
public function test_current_locale_wins_over_default(): void
{
// Doc A has en title "Setup"; Doc B has ja title "Setup"
$docA = Document::factory()->create(['default_locale' => 'en']);
$docA->translations()->where('locale', 'en')->update(['title' => 'Setup']);
$docB = Document::factory()->create(['default_locale' => 'en']);
$docB->translations()->where('locale', 'en')->update(['title' => 'Different']);
DocumentTranslation::factory()->create(['document_id' => $docB->id, 'locale' => 'ja', 'title' => 'Setup']);
// Browsing in ja: ja-locale match (Doc B) should win over default-locale match (Doc A)
$resolved = $this->resolver->resolve('Setup', 'ja');
$this->assertSame($docB->id, $resolved->id);
}
}