summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorgrothedev <grothedev@gmail.com>2026-04-11 16:38:45 -0500
committergrothedev <grothedev@gmail.com>2026-04-11 16:38:45 -0500
commit054c19bf65beb43d0dd6137f9bf16cf8ca9f6190 (patch)
tree0c49c212f3331fd53b2659cb4e5b532c190c40b9
parented4b7b02653c6653cad9785fb16f39fcd41eb030 (diff)
pushing changes from server
-rw-r--r--README.md10
-rwxr-xr-xapp/Http/Controllers/Controller.php4
-rw-r--r--app/Http/Controllers/WritingController.php9
-rw-r--r--app/Http/Middleware/Logger.php6
-rw-r--r--database/factories/WritingFactory.php3
-rw-r--r--database/migrations/2026_03_06_181900_fix_writings_user_id_fk_type.php48
-rw-r--r--database/migrations/99999_pivots.php70
-rwxr-xr-xpackage.json4
-rwxr-xr-xphpunit.xml4
-rw-r--r--public/css/home.css4
-rw-r--r--public/css/writings.css1
-rw-r--r--public/js/main.js10
-rw-r--r--public/js/writing_create.js86
-rw-r--r--resources/js/writing_create.js67
-rwxr-xr-xresources/views/template.blade.php6
-rw-r--r--resources/views/writings/create.blade.php5
-rw-r--r--resources/views/writings/edit.blade.php4
-rw-r--r--resources/views/writings/show.blade.php17
-rwxr-xr-xroutes/web.php10
-rw-r--r--tests/Feature/WritingTest.php279
-rwxr-xr-xvite.config.js1
21 files changed, 518 insertions, 130 deletions
diff --git a/README.md b/README.md
index aab7fed..28693d5 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,7 @@
-status 2/13: websocket situation is working. this laravel app hosts javascript client code that connects to an external websocket server, which ideally is run on the same server as this laravel app.
-
here is the websocket app: https://github.com/grothedev/websocket-template
run the server and set the WS_URL env variable of this laravel app appropriately
-https://laravel.com/api/11.x/
-
-https://laravel.com/docs/11.x/blade
-
-#### TODO:
+#### TODO:
- a site statistics page
- total visits, visit frequency, country of recent visits, average location makeup,
- current connections, time spent on site (average, min, max, )
@@ -21,6 +15,7 @@ https://laravel.com/docs/11.x/blade
- make /htmx/{thing}/{param} routes for sub-elements of pages? e.g. /links/# gives a blade page, but /htmx/links/# would give just the single html element (and maybe would be queried by /links/#, though it might be silly to have to require a double request like that, when the php can already do the necessary processing in blade)
- error handling and validation for file upload and everything
- writing
+ - fix the preview which stopped working
- add drag-drop file to upload and then generate appropriate html to embed that file
- auto capitalization https://marked.js.org/using_pro
- index filter and sort
@@ -36,7 +31,6 @@ https://laravel.com/docs/11.x/blade
- general
- import any model via json
- - links
- break out the canvas cursor renderer from main.js
- and more js organizination: treat each js file like a "plugin" that provides some functionality to some page if the page has the appropriate elements on it. define what dom elems are needed and optional for some js file. this way it won't be coupled with the page as much.
- homepage image
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 8677cd5..e7f7c94 100755
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -2,7 +2,9 @@
namespace App\Http\Controllers;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+
abstract class Controller
{
- //
+ use AuthorizesRequests;
}
diff --git a/app/Http/Controllers/WritingController.php b/app/Http/Controllers/WritingController.php
index 67d7b8d..b99d6bb 100644
--- a/app/Http/Controllers/WritingController.php
+++ b/app/Http/Controllers/WritingController.php
@@ -37,14 +37,13 @@ class WritingController extends Controller
'title' => 'required|min:3|max:255',
'content' => 'required|min:10',
]);
-
-
+
$writing = Writing::create([
'title' => $validated['title'],
'content' => $validated['content'],
- 'user_id' => Auth::user() == null ? null : Auth::id(),
+ 'user_id' => Auth::id(),
]);
-
+
return redirect()->route('w.show', $writing)
->with('success', 'Writing created successfully!');
}
@@ -54,7 +53,7 @@ class WritingController extends Controller
*/
public function show($id)
{
- $writing = Writing::findOrFail($id);
+ $writing = Writing::with('user')->findOrFail($id);
return view('writings.show', [
'writing' => $writing
]);
diff --git a/app/Http/Middleware/Logger.php b/app/Http/Middleware/Logger.php
index 988068a..17c2f18 100644
--- a/app/Http/Middleware/Logger.php
+++ b/app/Http/Middleware/Logger.php
@@ -31,7 +31,11 @@ class Logger
$request->header('User-Agent')
);
- Log::channel('requests')->info($logMessage);
+ try {
+ Log::channel('requests')->info($logMessage);
+ } catch (\Exception $e) {
+ // Log channel unavailable (e.g. permission error in test environment)
+ }
// Update visitor log in database
$statusColumn = match ($statusCode) {
diff --git a/database/factories/WritingFactory.php b/database/factories/WritingFactory.php
index 824ac82..a73f8ee 100644
--- a/database/factories/WritingFactory.php
+++ b/database/factories/WritingFactory.php
@@ -2,6 +2,7 @@
namespace Database\Factories;
+use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@@ -19,7 +20,7 @@ class WritingFactory extends Factory
return [
'title' => fake()->sentence(4),
'content' => fake()->paragraphs(3, true),
- 'user_id' => fake()->numberBetween(1, 10),
+ 'user_id' => User::factory(),
];
}
}
diff --git a/database/migrations/2026_03_06_181900_fix_writings_user_id_fk_type.php b/database/migrations/2026_03_06_181900_fix_writings_user_id_fk_type.php
new file mode 100644
index 0000000..6466781
--- /dev/null
+++ b/database/migrations/2026_03_06_181900_fix_writings_user_id_fk_type.php
@@ -0,0 +1,48 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::table('writings', function (Blueprint $table) {
+ // Drop the existing FK before altering the column type
+ try {
+ $table->dropForeign(['user_id']);
+ } catch (\Exception $e) {
+ // FK may not exist on SQLite or fresh DBs
+ }
+
+ // Fix type mismatch: integer -> unsignedBigInteger (to match users.id bigIncrements)
+ $table->unsignedBigInteger('user_id')->nullable()->change();
+
+ // Re-add the FK constraint
+ try {
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+ } catch (\Exception $e) {
+ // SQLite ignores FK constraints by default
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('writings', function (Blueprint $table) {
+ try {
+ $table->dropForeign(['user_id']);
+ } catch (\Exception $e) {}
+
+ $table->integer('user_id')->unsigned()->nullable()->change();
+
+ try {
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+ } catch (\Exception $e) {}
+ });
+ }
+};
diff --git a/database/migrations/99999_pivots.php b/database/migrations/99999_pivots.php
index 64cdbd5..e0649ff 100644
--- a/database/migrations/99999_pivots.php
+++ b/database/migrations/99999_pivots.php
@@ -12,36 +12,46 @@ return new class extends Migration
*/
public function up(): void
{
- Schema::create('link_tag', function (Blueprint $table) {
- $table->unsignedBigInteger('link_id')->index();
- $table->foreign('link_id')->references('id')->on('links')->onDelete('cascade');
- $table->unsignedBigInteger('tag_id')->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
- Schema::create('file_tag', function (Blueprint $table) {
- $table->unsignedBigInteger('file_id')->index();
- $table->foreign('file_id')->references('id')->on('files')->onDelete('cascade');
- $table->unsignedBigInteger('tag_id')->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
- Schema::create('tag_writing', function (Blueprint $table) {
- $table->unsignedBigInteger('writing_id')->index();
- $table->foreign('writing_id')->references('id')->on('writings')->onDelete('cascade');
- $table->unsignedBigInteger('tag_id')->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
- Schema::create('role_user', function (Blueprint $table) {
- $table->unsignedBigInteger('user_id')->index();
- $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
- $table->unsignedBigInteger('role_id')->index();
- $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade');
- });
- Schema::create('tag_quote', function (Blueprint $table) {
- $table->unsignedBigInteger('quote_id')->index();
- $table->foreign('quote_id')->references('id')->on('quotes')->onDelete('cascade');
- $table->unsignedBigInteger('tag_id')->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
+ if (!Schema::hasTable('link_tag')) {
+ Schema::create('link_tag', function (Blueprint $table) {
+ $table->unsignedBigInteger('link_id')->index();
+ $table->foreign('link_id')->references('id')->on('links')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
+ if (!Schema::hasTable('file_tag')) {
+ Schema::create('file_tag', function (Blueprint $table) {
+ $table->unsignedBigInteger('file_id')->index();
+ $table->foreign('file_id')->references('id')->on('files')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
+ if (!Schema::hasTable('tag_writing')) {
+ Schema::create('tag_writing', function (Blueprint $table) {
+ $table->unsignedBigInteger('writing_id')->index();
+ $table->foreign('writing_id')->references('id')->on('writings')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
+ if (!Schema::hasTable('role_user')) {
+ Schema::create('role_user', function (Blueprint $table) {
+ $table->unsignedBigInteger('user_id')->index();
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+ $table->unsignedBigInteger('role_id')->index();
+ $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade');
+ });
+ }
+ if (!Schema::hasTable('tag_quote')) {
+ Schema::create('tag_quote', function (Blueprint $table) {
+ $table->unsignedBigInteger('quote_id')->index();
+ $table->foreign('quote_id')->references('id')->on('quotes')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
}
/**
diff --git a/package.json b/package.json
index 231dffb..e280a32 100755
--- a/package.json
+++ b/package.json
@@ -9,7 +9,6 @@
"@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
- "axios": "^1.7.9",
"concurrently": "^9.0.1",
"laravel-echo": "^1.17.1",
"laravel-vite-plugin": "^1.0",
@@ -20,6 +19,7 @@
},
"dependencies": {
"htmx.org": "^2.0.1",
- "jquery": "^3.7.1"
+ "jquery": "^3.7.1",
+ "marked": "^17.0.4"
}
}
diff --git a/phpunit.xml b/phpunit.xml
index 506b9a3..61c031c 100755
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -22,8 +22,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
- <!-- <env name="DB_CONNECTION" value="sqlite"/> -->
- <!-- <env name="DB_DATABASE" value=":memory:"/> -->
+ <env name="DB_CONNECTION" value="sqlite"/>
+ <env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
diff --git a/public/css/home.css b/public/css/home.css
index 50b6130..ebbbb29 100644
--- a/public/css/home.css
+++ b/public/css/home.css
@@ -17,8 +17,7 @@ header {
text-align: center;
}
main {
- max-width: 800px;
- margin: 2rem auto;
+ margin: .2rem 8rem .5rem 8rem;
padding: 1rem;
background-color: #cecece;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@@ -45,6 +44,7 @@ section h3 {
a {
color: #3f6d87;
text-decoration: none;
+ padding: .5rem;
}
li {
list-style-type: none;
diff --git a/public/css/writings.css b/public/css/writings.css
index 7e23d51..73f89fa 100644
--- a/public/css/writings.css
+++ b/public/css/writings.css
@@ -1,7 +1,6 @@
/* Writing Creation Page Styles */
main .writing-create {
width: 100%;
- max-width: 2000px;
}
.writing-create {
diff --git a/public/js/main.js b/public/js/main.js
index 931cfb2..b7673cc 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -35,7 +35,7 @@ function prepareFileInput(){
console.log(f);
}
//TODO validation
- domElems.buttonFileUpload.enabled = true;
+ //domElems.buttonFileUpload.enabled = true;
}
//APP START HERE
@@ -107,14 +107,14 @@ async function getServerEnvVars(){
function initDOM(){
// temp disabled $('#fileupload')[0].hidden = false;
domElems.numConnected = $('#numFriendsConnected')[0];
- domElems.inputFile = $('#f')[0];
+ //domElems.inputFile = $('#f')[0];
domElems.debugInfo = $('#infolog')[0];
if (domElems.debugInfo) {
domElems.debugInfo.style.display = env.MODE == 'local' ? 'block' : 'none';
}
domElems.fileUploadResult = $('#fileupload_result')[0];
- domElems.inputFile.onchange = prepareFileInput;
- domElems.buttonFileUpload = $('#button_fileupload')[0];
+ //domElems.inputFile.onchange = prepareFileInput;
+ //domElems.buttonFileUpload = $('#button_fileupload')[0];
domElems.body = $('body')[0];
domElems.cnv = $('#bg')[0];
domElems.statusWS = $('#statusWS')[0];
@@ -384,4 +384,4 @@ function log(msg, lvl=1){
domElems.debugInfo.innerHTML = msg; //TODO running log + timestamp
}
console.log(msg);
-} \ No newline at end of file
+}
diff --git a/public/js/writing_create.js b/public/js/writing_create.js
index e1c88c0..dc737f8 100644
--- a/public/js/writing_create.js
+++ b/public/js/writing_create.js
@@ -1,33 +1,25 @@
-let contentPreview;
-let inputText;
let dom = {};
let editorHistory = [];
-
-$(function() {
-
- if (typeof marked == 'undefined'){
+document.addEventListener('DOMContentLoaded', () => {
+ if (typeof marked === 'undefined') {
console.error("marked lib not loaded");
return;
- } else {
- console.log("marked loaded");
- console.log(marked);
}
+
initDOM();
-
- marked.setOptions({
- breaks: true,
- sanitize: true
- })
+ marked.use({ breaks: true });
- dom.inputText.oninput = ()=>{
+ dom.inputText.addEventListener('input', () => {
dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
- };
+ });
- let restoredContent = localStorage.getItem('writing_draft_content');
- if (restoredContent != null){
- if (localStorage.getItem('writing_draft_expiration') < Date.now()){
+ // Restore autosaved draft
+ const restoredContent = localStorage.getItem('writing_draft_content');
+ if (restoredContent !== null) {
+ const expiration = parseInt(localStorage.getItem('writing_draft_expiration') || '0');
+ if (expiration < Date.now()) {
localStorage.removeItem('writing_draft_content');
localStorage.removeItem('writing_draft_expiration');
} else {
@@ -36,47 +28,43 @@ $(function() {
}
}
- //periodically save the contents to client to prevent data loss
- const intervalId = setInterval(() => {
- const content = dom.inputText.value;
- localStorage.setItem('writing_draft_content', content);
- localStorage.setItem('writing_draft_expiration', Date.now() + 1000*3600*96);
+ // Periodically save contents to localStorage to prevent data loss
+ setInterval(() => {
+ localStorage.setItem('writing_draft_content', dom.inputText.value);
+ localStorage.setItem('writing_draft_expiration', Date.now() + 1000 * 3600 * 96);
}, 5000);
+ // Clear draft on successful submit
+ document.getElementById('writing-form')?.addEventListener('submit', () => {
+ localStorage.removeItem('writing_draft_content');
+ localStorage.removeItem('writing_draft_expiration');
+ });
});
-function initDOM(){
- dom.inputText = $('#input_content')[0];
- dom.contentPreview = $('#content_preview')[0];
- dom.findStr = $('#input_findstr')[0];
- dom.replaceStr = $('#input_replacestr')[0];
- dom.btnReplace = $('#button_replace')[0];
- dom.btnUndo = $('#button_undo')[0];
- console.log(dom);
- dom.btnReplace.onclick = performReplace;
- dom.btnUndo.onclick = ()=>{
- if (editorHistory.length > 0){
+function initDOM() {
+ dom.inputText = document.getElementById('input_content');
+ dom.contentPreview = document.getElementById('content_preview');
+ dom.findStr = document.getElementById('input_findstr');
+ dom.replaceStr = document.getElementById('input_replacestr');
+ dom.btnReplace = document.getElementById('button_replace');
+ dom.btnUndo = document.getElementById('button_undo');
+ dom.toggleMultiline = document.getElementById('input_multiline');
+
+ dom.btnReplace.addEventListener('click', performReplace);
+ dom.btnUndo.addEventListener('click', () => {
+ if (editorHistory.length > 0) {
dom.inputText.value = editorHistory.pop();
dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
}
- };
- dom.toggleMultiline = $('#input_multiline')[0];
+ });
}
-function performReplace(){
-
-
- // Save current state to history for undo
- editorHistory.push(dom.inputText.value);
-
- // Perform the replacement
- const regex = new RegExp(dom.findStr.value, dom.toggleMultiline.checked ? 'gm' : 'g');
- dom.inputText.value = dom.inputText.value.replace(regex, dom.replaceStr.value);
- dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
-
-
+function performReplace() {
+ // Save current state for undo
editorHistory.push(dom.inputText.value);
+ const flags = dom.toggleMultiline.checked ? 'gm' : 'g';
+ const regex = new RegExp(dom.findStr.value, flags);
dom.inputText.value = dom.inputText.value.replace(regex, dom.replaceStr.value);
dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
}; \ No newline at end of file
diff --git a/resources/js/writing_create.js b/resources/js/writing_create.js
new file mode 100644
index 0000000..cd44a66
--- /dev/null
+++ b/resources/js/writing_create.js
@@ -0,0 +1,67 @@
+import $ from 'jquery';
+import { marked } from 'marked';
+
+marked.use({ breaks: true });
+
+const dom = {};
+const editorHistory = [];
+
+$(document).ready(() => {
+ initDOM();
+
+ dom.inputText.addEventListener('input', () => {
+ dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
+ });
+
+ // Restore autosaved draft
+ const restoredContent = localStorage.getItem('writing_draft_content');
+ if (restoredContent !== null) {
+ const expiration = parseInt(localStorage.getItem('writing_draft_expiration') || '0');
+ if (expiration < Date.now()) {
+ localStorage.removeItem('writing_draft_content');
+ localStorage.removeItem('writing_draft_expiration');
+ } else {
+ dom.inputText.value = restoredContent;
+ dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
+ }
+ }
+
+ // Periodically save contents to localStorage to prevent data loss
+ setInterval(() => {
+ localStorage.setItem('writing_draft_content', dom.inputText.value);
+ localStorage.setItem('writing_draft_expiration', String(Date.now() + 1000 * 3600 * 96));
+ }, 5000);
+
+ // Clear draft on successful submit
+ document.getElementById('writing-form')?.addEventListener('submit', () => {
+ localStorage.removeItem('writing_draft_content');
+ localStorage.removeItem('writing_draft_expiration');
+ });
+});
+
+function initDOM() {
+ dom.inputText = document.getElementById('input_content');
+ dom.contentPreview = document.getElementById('content_preview');
+ dom.findStr = document.getElementById('input_findstr');
+ dom.replaceStr = document.getElementById('input_replacestr');
+ dom.btnReplace = document.getElementById('button_replace');
+ dom.btnUndo = document.getElementById('button_undo');
+ dom.toggleMultiline = document.getElementById('input_multiline');
+
+ dom.btnReplace.addEventListener('click', performReplace);
+ dom.btnUndo.addEventListener('click', () => {
+ if (editorHistory.length > 0) {
+ dom.inputText.value = editorHistory.pop();
+ dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
+ }
+ });
+}
+
+function performReplace() {
+ editorHistory.push(dom.inputText.value);
+
+ const flags = dom.toggleMultiline.checked ? 'gm' : 'g';
+ const regex = new RegExp(dom.findStr.value, flags);
+ dom.inputText.value = dom.inputText.value.replace(regex, dom.replaceStr.value);
+ dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
+}
diff --git a/resources/views/template.blade.php b/resources/views/template.blade.php
index 09c7389..5d41e61 100755
--- a/resources/views/template.blade.php
+++ b/resources/views/template.blade.php
@@ -20,8 +20,8 @@
<a href = "/">Home</a>
<a href = "/f">Files</a>
<a href = "/l">Resources</a>
- <a href = "/notes">Notes</a>
- <small><a id = "buttonSettings">Settings</a></small>
+ <!--<a href = "/notes">Notes</a>-->
+ <!--<small><a id = "buttonSettings">Settings</a></small>-->
<!-- login ? -->
</nav>
<center>- - -</center>
@@ -33,4 +33,4 @@
@vite('resources/js/app.js')
@yield('scripts')
</body>
-</html> \ No newline at end of file
+</html>
diff --git a/resources/views/writings/create.blade.php b/resources/views/writings/create.blade.php
index 86264b9..c095b5a 100644
--- a/resources/views/writings/create.blade.php
+++ b/resources/views/writings/create.blade.php
@@ -2,9 +2,8 @@
@section('head')
<meta charset="UTF-8">
-<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
-<script type = "module" src = "/js/writing_create.js"></script>
-<link rel="stylesheet" href="/css/style.css">
+@vite('resources/js/writing_create.js')
+<link rel="stylesheet" href="/css/home.css">
<link rel="stylesheet" href="/css/writings.css">
@endsection
diff --git a/resources/views/writings/edit.blade.php b/resources/views/writings/edit.blade.php
index ec51d68..e442b5d 100644
--- a/resources/views/writings/edit.blade.php
+++ b/resources/views/writings/edit.blade.php
@@ -8,7 +8,6 @@
@section('content')
<main>
- @if (Auth::user() != null)
<div class="container">
<h1>Edit Writing</h1>
@@ -40,8 +39,5 @@
<a href="{{ route('w.index') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
- @else
- <section><h3>You must be <a href = "/login">logged in</a> to edit a writing</h3></section>
- @endif
</main>
@endsection
diff --git a/resources/views/writings/show.blade.php b/resources/views/writings/show.blade.php
index ab25974..4b191ad 100644
--- a/resources/views/writings/show.blade.php
+++ b/resources/views/writings/show.blade.php
@@ -16,20 +16,15 @@
<header class="writing-header">
<h1>{{ $writing->title }}</h1>
<div class="writing-meta">
- <span class="author">By {{ $writing->hasUser() ? $writing->user()->get()[0]->name : 'anonymous' }}</span>
+ <span class="author">By {{ $writing->user?->name ?? 'anonymous' }}</span>
</div>
</header>
<div class="writing-content" id="content">
[ loading... ]
</div>
- <script type = "text/javascript">
- let contentMarkup = {{ Js::from($writing->content) }};
- console.log(contentMarkup);
- $('#content')[0].innerHTML = marked.parse(contentMarkup);
- </script>
- @if($writing->hasUser() && Auth::id() === $writing->user_id)
+ @can('update', $writing)
<div class="writing-actions">
<a href="{{ route('w.edit', $writing) }}" class="edit-btn">Edit</a>
<form method="POST" action="{{ route('w.destroy', $writing) }}" class="delete-form">
@@ -38,7 +33,13 @@
<button type="submit" class="delete-btn" onclick="return confirm('Are you sure you want to delete this writing?')">Delete</button>
</form>
</div>
- @endif
+ @endcan
</article>
</main>
@endsection
+
+@section('scripts')
+<script type="text/javascript">
+ document.getElementById('content').innerHTML = marked.parse({{ Js::from($writing->content) }});
+</script>
+@endsection
diff --git a/routes/web.php b/routes/web.php
index d5fe827..a8a14b1 100755
--- a/routes/web.php
+++ b/routes/web.php
@@ -44,11 +44,6 @@ Route::get('/mu/{path}', function($path){
]);
})->middleware('throttle:public');
-// Public read-only routes
-Route::resource('w', WritingController::class)->only(['index', 'show']);
-Route::resource('l', LinkController::class)->only(['index', 'show']);
-Route::resource('f', \App\Http\Controllers\FileController::class)->only(['index', 'show']);
-
Route::get('/test', [SiteController::class, 'test']);
// Auth-protected routes
@@ -71,6 +66,11 @@ Route::middleware(['auth', 'throttle:admin', \App\Http\Middleware\Admin::class])
Route::get('/4', [SiteController::class, 'search4chan']);
});
+// Public read-only routes
+Route::resource('w', WritingController::class)->only(['index', 'show']);
+Route::resource('l', LinkController::class)->only(['index', 'show']);
+Route::resource('f', \App\Http\Controllers\FileController::class)->only(['index', 'show']);
+
// Session updates - auth required, whitelisted keys only
Route::post('/update-session', function(Request $req){
if ($req->input('key') && $req->input('value')){
diff --git a/tests/Feature/WritingTest.php b/tests/Feature/WritingTest.php
new file mode 100644
index 0000000..5a88b17
--- /dev/null
+++ b/tests/Feature/WritingTest.php
@@ -0,0 +1,279 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\User;
+use App\Models\Writing;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class WritingTest extends TestCase
+{
+ use RefreshDatabase;
+
+ // ─── Index ───────────────────────────────────────────────────────────────
+
+ public function test_guests_can_view_index(): void
+ {
+ $response = $this->get(route('w.index'));
+ $response->assertOk();
+ }
+
+ public function test_index_lists_writings(): void
+ {
+ $writing = Writing::factory()->create();
+
+ $response = $this->get(route('w.index'));
+ $response->assertSee($writing->title);
+ }
+
+ // ─── Show ─────────────────────────────────────────────────────────────────
+
+ public function test_guests_can_view_a_writing(): void
+ {
+ $writing = Writing::factory()->create();
+
+ $response = $this->get(route('w.show', $writing));
+ $response->assertOk()->assertSee($writing->title);
+ }
+
+ public function test_show_404s_for_missing_writing(): void
+ {
+ $response = $this->get(route('w.show', 99999));
+ $response->assertNotFound();
+ }
+
+ // ─── Create ───────────────────────────────────────────────────────────────
+
+ public function test_guest_is_redirected_from_create(): void
+ {
+ $response = $this->get(route('w.create'));
+ $response->assertRedirect(route('login'));
+ }
+
+ public function test_authenticated_user_can_see_create_form(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->get(route('w.create'));
+ $response->assertOk();
+ }
+
+ // ─── Store ────────────────────────────────────────────────────────────────
+
+ public function test_guest_cannot_store_writing(): void
+ {
+ $response = $this->post(route('w.store'), [
+ 'title' => 'Guest Writing',
+ 'content' => 'Should not be stored at all.',
+ ]);
+ $response->assertRedirect(route('login'));
+ $this->assertDatabaseMissing('writings', ['title' => 'Guest Writing']);
+ }
+
+ public function test_user_can_store_writing(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => 'My New Writing',
+ 'content' => 'This is content that is long enough to pass validation.',
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('writings', [
+ 'title' => 'My New Writing',
+ 'user_id' => $user->id,
+ ]);
+ }
+
+ public function test_store_requires_title(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => '',
+ 'content' => 'Content long enough to pass.',
+ ]);
+ $response->assertSessionHasErrors('title');
+ }
+
+ public function test_store_requires_title_min_3_chars(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => 'AB',
+ 'content' => 'Content long enough to pass.',
+ ]);
+ $response->assertSessionHasErrors('title');
+ }
+
+ public function test_store_requires_content(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => 'Valid Title',
+ 'content' => '',
+ ]);
+ $response->assertSessionHasErrors('content');
+ }
+
+ public function test_store_requires_content_min_10_chars(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => 'Valid Title',
+ 'content' => 'Too short',
+ ]);
+ $response->assertSessionHasErrors('content');
+ }
+
+ // ─── Edit ─────────────────────────────────────────────────────────────────
+
+ public function test_guest_is_redirected_from_edit(): void
+ {
+ $writing = Writing::factory()->create();
+
+ $response = $this->get(route('w.edit', $writing));
+ $response->assertRedirect(route('login'));
+ }
+
+ public function test_owner_can_access_edit_form(): void
+ {
+ $user = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $user->id]);
+
+ $response = $this->actingAs($user)->get(route('w.edit', $writing));
+ $response->assertOk();
+ }
+
+ public function test_non_owner_cannot_access_edit_form(): void
+ {
+ $owner = User::factory()->create();
+ $other = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($other)->get(route('w.edit', $writing));
+ $response->assertForbidden();
+ }
+
+ public function test_admin_can_access_any_edit_form(): void
+ {
+ $owner = User::factory()->create();
+ $admin = User::factory()->create(['role' => 0]);
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($admin)->get(route('w.edit', $writing));
+ $response->assertOk();
+ }
+
+ // ─── Update ───────────────────────────────────────────────────────────────
+
+ public function test_owner_can_update_writing(): void
+ {
+ $user = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $user->id]);
+
+ $response = $this->actingAs($user)->put(route('w.update', $writing), [
+ 'title' => 'Updated Title',
+ 'content' => 'Updated content that is long enough to pass validation.',
+ ]);
+
+ $response->assertRedirect(route('w.show', $writing));
+ $this->assertDatabaseHas('writings', [
+ 'id' => $writing->id,
+ 'title' => 'Updated Title',
+ ]);
+ }
+
+ public function test_non_owner_cannot_update_writing(): void
+ {
+ $owner = User::factory()->create();
+ $other = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $owner->id, 'title' => 'Original Title']);
+
+ $response = $this->actingAs($other)->put(route('w.update', $writing), [
+ 'title' => 'Hijacked Title',
+ 'content' => 'Hijacked content that is long enough.',
+ ]);
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('writings', ['id' => $writing->id, 'title' => 'Original Title']);
+ }
+
+ public function test_admin_can_update_any_writing(): void
+ {
+ $owner = User::factory()->create();
+ $admin = User::factory()->create(['role' => 0]);
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($admin)->put(route('w.update', $writing), [
+ 'title' => 'Admin Updated Title',
+ 'content' => 'Admin updated content that is long enough.',
+ ]);
+
+ $response->assertRedirect(route('w.show', $writing));
+ $this->assertDatabaseHas('writings', ['id' => $writing->id, 'title' => 'Admin Updated Title']);
+ }
+
+ public function test_update_validates_title(): void
+ {
+ $user = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $user->id]);
+
+ $response = $this->actingAs($user)->put(route('w.update', $writing), [
+ 'title' => 'AB',
+ 'content' => 'Content long enough to pass.',
+ ]);
+ $response->assertSessionHasErrors('title');
+ }
+
+ // ─── Destroy ─────────────────────────────────────────────────────────────
+
+ public function test_guest_cannot_delete_writing(): void
+ {
+ $writing = Writing::factory()->create();
+
+ $response = $this->delete(route('w.destroy', $writing));
+ $response->assertRedirect(route('login'));
+ $this->assertDatabaseHas('writings', ['id' => $writing->id]);
+ }
+
+ public function test_owner_can_delete_writing(): void
+ {
+ $user = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $user->id]);
+
+ $response = $this->actingAs($user)->delete(route('w.destroy', $writing));
+
+ $response->assertRedirect(route('w.index'));
+ $this->assertDatabaseMissing('writings', ['id' => $writing->id]);
+ }
+
+ public function test_non_owner_cannot_delete_writing(): void
+ {
+ $owner = User::factory()->create();
+ $other = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($other)->delete(route('w.destroy', $writing));
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('writings', ['id' => $writing->id]);
+ }
+
+ public function test_admin_can_delete_any_writing(): void
+ {
+ $owner = User::factory()->create();
+ $admin = User::factory()->create(['role' => 0]);
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($admin)->delete(route('w.destroy', $writing));
+
+ $response->assertRedirect(route('w.index'));
+ $this->assertDatabaseMissing('writings', ['id' => $writing->id]);
+ }
+}
diff --git a/vite.config.js b/vite.config.js
index b368fe9..9f67760 100755
--- a/vite.config.js
+++ b/vite.config.js
@@ -6,6 +6,7 @@ export default defineConfig({
laravel({
input: [
'resources/js/app.js',
+ 'resources/js/writing_create.js',
],
refresh: true,
}),