Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ Earlier releases (`v0.1.0-alpha.1` to `v0.1.7-alpha.1`) were experimental and do

## [v0.1.7-alpha.2] - 2025-07-15

### Added
- Storage helper for file uploads, saving to `public/uploads` and generating URLs.
- Modern file access in controllers: `$request->file('avatar')`, `$request->hasFile('avatar')`.
- Unified request data: merges `$_GET`, `$_POST`, and JSON body.
- `mimes` and `image` validation rules for secure file uploads.
- HTMX-powered file upload with progress bar in the main form (no JS required).
- Generic error-clearing script for all form fields.

### Changed
- File uploads are now web-accessible by default.
- Improved documentation for file upload, validation, and request handling.

- **Adopted proper SemVer pre-release flow:** All future releases will follow `alpha.n → beta.n → rc.n → stable`.
- **Stability commitment:** This release marks the beginning of structured, progressive versioning and a focus on stability.
- **Changelog and versioning policy:** Added `CHANGELOG.md` and `VERSIONING.md` to document release history and policy.
- **(List any new features, bug fixes, or improvements here)**
### Fixed
- No more duplicate `/uploads/uploads/...` in file URLs.

---

Expand Down
107 changes: 107 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,3 +503,110 @@ This will run the production build process (minifies, strips dev code, precompil

- The old `build` command is now replaced by `bloom` for clarity and branding.
- Use this command before deploying your app to production.

## File Upload & Storage

SproutPHP now includes a Storage helper for easy file uploads and URL generation, saving files in `public/uploads` for web accessibility.

### Usage Example

```php
use Core\Support\Storage;

// In your controller
if ($request->hasFile('avatar')) {
$path = Storage::put($request->file('avatar'), 'avatars');
$url = Storage::url($path); // /uploads/avatars/filename.jpg
}
```

- Files are saved in `public/uploads/{subdir}`.
- URLs are generated as `/uploads/{subdir}/{filename}`.

---

## Modern Request File Access

You can now access uploaded files in controllers using:

```php
$request->file('avatar'); // Returns the file array or null
$request->hasFile('avatar'); // Returns true if a file was uploaded
```

Request data merges `$_GET`, `$_POST`, and JSON body for unified access.

---

## File Validation: mimes & image

You can validate file uploads with new rules:

- `mimes:jpg,png,gif` — File must have one of the allowed extensions
- `image` — File must be a valid image (checked by MIME type)

**Example:**

```php
$validator = new Validator($request->data, [
'avatar' => 'required|image|mimes:jpg,jpeg,png,gif'
]);
```

---

## HTMX File Upload with

You can use your main form for file upload:

```twig
<form
id="validation-form"
hx-post="/validation-test"
hx-target="#form-container"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
hx-indicator="#form-progress"
method="POST"
enctype="multipart/form-data"
autocomplete="off"
>
<!-- ...other fields... -->
<div>
<label for="avatar">Avatar:</label>
<input type="file" name="avatar" id="avatar" accept="image/*">
{% if errors.avatar %}
<div class="error" for="avatar" style="color: red;">{{ errors.avatar }}</div>
{% endif %}
</div>
<button type="submit">Submit</button>
<progress id="form-progress" value="0" max="100"></progress>
</form>
```

- On success, your server can return a fragment with the uploaded avatar URL and preview.

---

## Error Clearing Script

A generic script clears error messages for any field when focused:

```html
<script>
document.addEventListener("focusin", function (e) {
if (e.target.form && e.target.name) {
const error = e.target.form.querySelector(
`.error[for="${e.target.name}"]`
);
if (error) error.remove();
}
});
</script>
```

- Works for all fields with `.error[for="fieldname"]`.

---

See the rest of this documentation for more on validation, request handling, and UI best practices.
40 changes: 15 additions & 25 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,32 @@
# SproutPHP v0.1.7-alpha.1 Release Notes
# SproutPHP v0.1.7-alpha.2 Release Notes

## 🎉 New Features & Improvements

### Validation System

- **Expanded validation rules**: Now supports numeric, integer, string, boolean, array, in, not_in, same, different, confirmed, regex, url, ip, date, before, after, nullable, present, digits, digits_between, size, starts_with, ends_with, uuid, and more.
- **Improved documentation**: DOCS.md now lists all available rules and usage examples.
- **Better error clearing**: Validation errors are cleared on input focus for a smoother UX.

### Dark/Light Mode Support

- **PicoCSS dark/light mode toggle**: Optional sun/moon icon button in the navbar for instant theme switching.
- **Theme preference**: Saved in localStorage and applied instantly.
- **Post-install option**: Users can choose to auto-include the toggle during installation.

### Other Improvements

- **Post-install script**: Now prompts for dark/light mode toggle inclusion.
- **Code cleanup and bug fixes**: Heredoc indentation, improved scripts, and UI polish.
- **Documentation updates**: More examples and clearer instructions for new features.
- **File Upload & Storage:** New Storage helper for easy file uploads and URL generation, saving files in `public/uploads`.
- **Modern Request API:** Access uploaded files via `$request->file('avatar')` and `$request->hasFile('avatar')`.
- **Unified Input:** Request data now merges `$_GET`, `$_POST`, and JSON body for easier access.
- **Validation:** Added `mimes` and `image` rules for secure file validation.
- **HTMX File Upload:** File upload with progress bar using only HTMX, no custom JS required.
- **Error Handling:** Generic script to clear errors on focus for all fields.
- **Docs:** Updated with new usage examples and best practices.

## 🛠️ Upgrade Guide

- Use the new validation rules in your controllers and forms.
- To add the dark/light mode toggle, re-run the post-install script or add the button and script as shown in DOCS.md.
- See DOCS.md for updated usage examples.
- Use the new Storage helper and request methods for file uploads.
- Update your forms to use the new validation rules and error-clearing script.
- See DOCS.md for updated usage and examples.

## 📅 Release Date

July 2024
2025-07-15

## 📦 Framework Version

v0.1.7-alpha.1
v0.1.7-alpha.2

---

**Release Date**: July 2024
**Framework Version**: v0.1.7-alpha.1
**Release Date**: 2025-07-15
**Framework Version**: v0.1.7-alpha.2
**PHP Version**: 8.1+
**Composer**: 2.0+
33 changes: 31 additions & 2 deletions app/Controllers/ValidationTestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace App\Controllers;

use Core\Http\Request;
use Core\Support\Storage;
use Core\Support\Validator;

class ValidationTestController
Expand All @@ -21,13 +23,39 @@ public function index()

public function handleForm()
{
$data = $_POST;
$request = Request::capture();

$data = [
'name' => $request->input('name'),
'email' => $request->input('email'),
'avatar' => $request->input('avatar'),
];

// Or You can get all input data simply using
// $data = $request->data;

$validator = new Validator($data, [
'email' => 'required|email',
'name' => 'required|min:3',
'avatar' => 'image|mimes:jpg,jpeg,png,gif',
]);

if ($validator->fails()) {
// File Upload Validation
$avatarError = null;
if ($request->hasFile('avatar')) {
$file = $request->file('avatar');
$path = Storage::put($file, 'avatars');
if (!$path) {
$avatarError = 'File upload failed.';
}
} else {
$avatarError = "Please upload an avatar.";
}

if ($validator->fails() || $avatarError) {
$errors = $validator->errors();
if ($avatarError)
$errors['avatar'] = $avatarError;
// Return only the form fragment with errors for HTMX
return view('partials/validation-form', [
'errors' => $validator->errors(),
Expand All @@ -39,6 +67,7 @@ public function handleForm()
return view('partials/validation-success', [
'name' => $data['name'],
'email' => $data['email'],
'avatar_url' => isset($path) ? Storage::url($path) : null,
]);
}
}
16 changes: 10 additions & 6 deletions app/Views/partials/validation-form.twig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
hx-indicator="#spinner"
method="POST"
autocomplete="off"
enctype="multipart/form-data"
>
{# CSRF TOKEN #}
{{ csrf_field()|raw }}
Expand All @@ -23,17 +24,20 @@
<div class="error" for="email" style="color: red;">{{ errors.email }}</div>
{% endif %}
</div>
<div>
<label for="avatar">Avatar:</label>
<input type="file" name="avatar" id="avatar" accept="image/*" />
{% if errors.avatar %}
<div class="error" for="avatar" style="color: red;">{{ errors.avatar }}</div>
{% endif %}
</div>
<button type="submit">Submit
</button>
</form>
<script>
document.addEventListener('focusin', function(e) {
if (e.target.matches('input[name="name"]')) {
const error = e.target.closest('form').querySelector('.error[for="name"]');
if (error) error.remove();
}
if (e.target.matches('input[name="email"]')) {
const error = e.target.closest('form').querySelector('.error[for="email"]');
if (e.target.form && e.target.name) {
const error = e.target.form.querySelector(`.error[for="${e.target.name}"]`);
if (error) error.remove();
}
});
Expand Down
3 changes: 3 additions & 0 deletions app/Views/partials/validation-success.twig
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
<h2>Form Submitted Successfully!</h2>
<p>Name: {{ name|e }}</p>
<p>Email: {{ email|e }}</p>
{% if avatar_url %}
<p>Avatar: <img src="{{ avatar_url }}" alt="Avatar" style="max-width:100px;max-height:100px;"></p>
{% endif %}
<button hx-get="/validation-test" hx-target="#main-content" hx-push-url="true">Submit Another</button>
</div>
30 changes: 27 additions & 3 deletions core/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

class Request
{
public $method, $uri;
public $method, $uri, $data = [], $files = [];

private function __construct($method, $uri)
private function __construct($method, $uri, $data = [], $files = [])
{
$this->method = $method;
$this->uri = $uri;
$this->data = $data;
$this->files = $files;
}

/**
Expand All @@ -20,7 +22,29 @@ public static function capture()
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

return new self($method, $uri);
// If get/post request
$data = array_merge($_GET, $_POST);

// if JSON, decode and merge
if (stripos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === 0) {
$json = file_get_contents('php://input');
$jsonData = json_decode($json, true);
if (is_array($jsonData)) {
$data = array_merge($data, $jsonData);
}
}

$files = $_FILES;

return new self($method, $uri, $data, $files);
}

/**
* To request the input fields
*/
public function input($key, $default = null)
{
return $this->data[$key] ?? $default;
}

/**
Expand Down
8 changes: 4 additions & 4 deletions core/Support/Storage.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

class Storage
{
protected static $baseDir = __DIR__ . '/../../storage/uploads';
protected static $baseDir = __DIR__ . '/../../public/uploads';

/**
* Save uploaded file
Expand All @@ -28,7 +28,7 @@ public static function put($file, $subdir = '')

if (move_uploaded_file($file['tmp_name'], $target)) {
// Return relative path for storage
return 'uploads' . ($subdir ? '/' . trim($subdir, '/') : '') . '/' . $filename;
return ($subdir ? '/' . trim($subdir, '/') : '') . '/' . $filename;
}

return false;
Expand All @@ -43,10 +43,10 @@ public static function path($relative)
}

/**
* Get a public URL for a stored file (assuming /storage is web-accessible/storage is web-accessible)
* Get a public URL for a stored file (assuming /uploads is web-accessible/storage is web-accessible)
*/
public static function url($relative)
{
return '/storage/' . ltrim($relative, '/');
return '/uploads/' . ltrim($relative, '/');
}
}
Loading