Initial commit, framework/frontend assets setup

This commit is contained in:
Maarten 2024-11-25 18:59:11 +01:00
commit 9d9858bb37
32 changed files with 4651 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Vendor
node_modules
vendor
public/dist
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,39 @@
<?php
namespace App\Controllers\Api;
use App\Services\Subnet;
use Core\Controllers\Controller;
use Core\View\Render;
use Exception;
class SubnetController extends Controller
{
/**
* Get all subnet data
*
* @return \Core\View\Render
*/
public function data(): Render
{
// Get the user input
$subnet = $this->request->get('subnet', '');
try {
// Validate the input
if (!str_contains($subnet, '/')) {
throw new Exception('Invalid subnet format.');
}
// Delegate calculation to subnet service
$subnetInfo = Subnet::calculate($subnet);
return $this->response->json()
->with('result', $subnetInfo);
} catch (Exception $e) {
return $this->response->status(403)
->json()
->with('message', $e->getMessage());
}
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Controllers;
use Core\Controllers\Controller;
use Core\View\Render;
class HomeController extends Controller
{
/**
* Render index
*
* @return \Core\View\Render
*/
public function index(): Render
{
return $this->response->view('subnet');
}
}

104
app/Services/Subnet.php Normal file
View file

@ -0,0 +1,104 @@
<?php
namespace App\Services;
use Exception;
class Subnet
{
/**
* Calculate subnet
*
* @param string $subnet
* @return array
* @throws \Exception
*/
public static function calculate(string $subnet): array
{
[$ip, $cidr] = explode('/', $subnet);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return self::calculateIPv4($ip, intval($cidr));
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return self::calculateIPv6($ip, intval($cidr));
} else {
throw new Exception("Invalid IP address.");
}
}
/**
* Calculate IPv4
*
* @param string $ip
* @param int $cidr
* @return array
* @throws \Exception
*/
private static function calculateIPv4(string $ip, int $cidr): array
{
if ($cidr < 0 || $cidr > 32) {
throw new Exception("CIDR must be between 0 and 32.");
}
$hosts = (1 << (32 - $cidr)) - 2; // Excludes network and broadcast
$ipLong = ip2long($ip);
$mask = -1 << (32 - $cidr);
$network = $ipLong & $mask;
$firstIP = $network + 1;
$lastIP = $network + $hosts;
return [
'network' => long2ip($network),
'first' => long2ip($firstIP),
'last' => long2ip($lastIP),
'hosts' => $hosts,
];
}
/**
* Calculate IPv6
*
* @param string $ip
* @param int $cidr
* @return array
* @throws \Exception
*/
private static function calculateIPv6(string $ip, int $cidr): array
{
if ($cidr < 0 || $cidr > 128) {
throw new Exception("CIDR must be between 0 and 128.");
}
// Convert IP to binary representation
$binaryIP = inet_pton($ip);
if ($binaryIP === false) {
throw new Exception("Failed to parse IPv6 address.");
}
// Calculate network address
$network = substr($binaryIP, 0, intval($cidr / 8));
$remainder = $cidr % 8;
if ($remainder > 0) {
$lastByte = ord($binaryIP[$cidr / 8]) & (0xFF << (8 - $remainder));
$network .= chr($lastByte);
}
// Pad the rest with zeros
$network = str_pad($network, 16, "\0");
// Calculate first and last addresses
$networkAddress = inet_ntop($network);
$totalHosts = $cidr == 128 ? 1 : bcpow(2, 128 - $cidr);
$firstHost = ($cidr == 128) ? $networkAddress : inet_ntop($network);
$lastHost = inet_ntop(pack("H*", str_repeat("F", 32)));
return [
'network' => $networkAddress,
'first' => $firstHost,
'last' => $lastHost,
'hosts' => $totalHosts,
];
}
}

31
composer.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "maartenvr98/bit",
"type": "project",
"authors": [
{
"name": "Maarten",
"email": "maarten@vrijssel.nl"
}
],
"require": {
"php": ">=8.3.0",
"filp/whoops": "^2.16"
},
"require-dev": {
"symfony/var-dumper": "^7.1"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Core\\": "src/"
}
},
"config": {
"optimize-autoloader": true
},
"scripts": {
"post-install-cmd": [
"composer dump-autoload -o"
]
}
}

304
composer.lock generated Normal file
View file

@ -0,0 +1,304 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5b92bb8b85e08563f751eb7e477addfe",
"packages": [
{
"name": "filp/whoops",
"version": "2.16.0",
"source": {
"type": "git",
"url": "https://github.com/filp/whoops.git",
"reference": "befcdc0e5dce67252aa6322d82424be928214fa2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filp/whoops/zipball/befcdc0e5dce67252aa6322d82424be928214fa2",
"reference": "befcdc0e5dce67252aa6322d82424be928214fa2",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0",
"psr/log": "^1.0.1 || ^2.0 || ^3.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
"symfony/var-dumper": "^4.0 || ^5.0"
},
"suggest": {
"symfony/var-dumper": "Pretty print complex values better with var-dumper available",
"whoops/soap": "Formats errors as SOAP responses"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Whoops\\": "src/Whoops/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Filipe Dobreira",
"homepage": "https://github.com/filp",
"role": "Developer"
}
],
"description": "php error handling for cool kids",
"homepage": "https://filp.github.io/whoops/",
"keywords": [
"error",
"exception",
"handling",
"library",
"throwable",
"whoops"
],
"support": {
"issues": "https://github.com/filp/whoops/issues",
"source": "https://github.com/filp/whoops/tree/2.16.0"
},
"funding": [
{
"url": "https://github.com/denis-sokolov",
"type": "github"
}
],
"time": "2024-09-25T12:00:00+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
}
],
"packages-dev": [
{
"name": "symfony/polyfill-mbstring",
"version": "v1.31.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-mbstring": "*"
},
"suggest": {
"ext-mbstring": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"name": "symfony/polyfill",
"url": "https://github.com/symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Mbstring\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for the Mbstring extension",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"mbstring",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/var-dumper",
"version": "v7.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "7bb01a47b1b00428d32b5e7b4d3b2d1aa58d3db8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/7bb01a47b1b00428d32b5e7b4d3b2d1aa58d3db8",
"reference": "7bb01a47b1b00428d32b5e7b4d3b2d1aa58d3db8",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/polyfill-mbstring": "~1.0"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"ext-iconv": "*",
"symfony/console": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/process": "^6.4|^7.0",
"symfony/uid": "^6.4|^7.0",
"twig/twig": "^3.0.4"
},
"bin": [
"Resources/bin/var-dump-server"
],
"type": "library",
"autoload": {
"files": [
"Resources/functions/dump.php"
],
"psr-4": {
"Symfony\\Component\\VarDumper\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides mechanisms for walking through any arbitrary PHP variable",
"homepage": "https://symfony.com",
"keywords": [
"debug",
"dump"
],
"support": {
"source": "https://github.com/symfony/var-dumper/tree/v7.1.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-11-08T15:46:42+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

8
config/routes.php Normal file
View file

@ -0,0 +1,8 @@
<?php
use App\Controllers\Api\SubnetController;
use App\Controllers\HomeController;
use Core\Router\Router;
Router::get('/', HomeController::class, 'index');
Router::post('/api/subnet', SubnetController::class, 'data');

4
global.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.scss' {
const content: { [className: string]: string };
export default content;
}

1881
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "bit",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"watch": "vite build --watch & vite preview"
},
"devDependencies": {
"@types/node": "^22.9.3",
"tailwindcss": "^3.4.15",
"typescript": "~5.6.2",
"vite": "^5.4.10",
"vite-plugin-sass-dts": "^1.3.29"
},
"dependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"autoprefixer": "^10.4.20",
"axios": "^1.7.7",
"postcss": "^8.4.49",
"sass": "^1.81.0",
"vue": "^3.5.13",
"vue-toast-notification": "^3"
}
}

6
postcss.config.cjs Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

5
public/.htaccess Normal file
View file

@ -0,0 +1,5 @@
RewriteEngine on
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-l
RewriteRule ^(.*)$ index.php/$1

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

11
public/index.php Normal file
View file

@ -0,0 +1,11 @@
<?php
use Core\Router\Router;
require '../vendor/autoload.php';
// Load routes
require '../config/routes.php';
// Dispatch router
Router::dispatch();

100
readme.md Normal file
View file

@ -0,0 +1,100 @@
# IPcalc-u-later
## Overview
**IPcalc-u-later** is a web application that calculates information about an IP subnet. Given an IPv4 or IPv6 address and subnet mask in CIDR notation (e.g., `213.136.12.128/27`), the application will return the following information:
- **Network Address**
- **First IP Address**
- **Last IP Address**
- **Number of Usable Hosts**
This application is built using the **MVC (Model-View-Controller)** design pattern, without relying on any PHP frameworks, to meet the challenge requirements. The back-end is developed in PHP, and the user interface is created using HTML, TailwindCSS, TypeScript and Vue.
### Example Inputs and Outputs:
#### IPv4 Example:
- **Input**: `213.136.12.128/27`
- **Output**:
- Network: `213.136.12.128`
- First: `213.136.12.129`
- Last: `213.136.12.158`
- Hosts: `30`
#### IPv6 Example:
- **Input**: `2001:db8:85a3:8a2e::/64`
- **Output**:
- Network: `2001:0db8:85a3:8a2e::`
- First: `2001:0db8:85a3:8a2e:0000:0000:0000:0000`
- Last: `2001:0db8:85a3:8a2e:ffff:ffff:ffff:ffff`
- Hosts: `18446744073709551616`
## Features
- **Back-end**:
- MVC architecture (without using a framework)
- IP address information and calculation logic implemented in PHP
- Supports both IPv4 and IPv6
- Validation for invalid subnet input
- Self-written PHP classes for IP address calculations
- **Front-end**:
- Simple, responsive design using HTML, TailwindCSS, TypeScript and Vue
- Input field to enter the subnet
- Displays the result in a user-friendly format
- **Extras** (Optional Features):
- Optionally, uses a template engine for rendering views (Vue)
- Optionally, third-party PHP libraries via Composer
- Version control using Git
## Requirements
### Server-side
- PHP 8.3+ (or higher) for processing the back-end logic
- A web server like Apache or Nginx to host the PHP application
- Composer (for managing third-party PHP libraries, if used)
### Client-side
- **Node.js**: Version **20** or higher for front-end development
- **Yarn**: Version **1.22** or higher for managing JavaScript dependencies.
- **Any modern web browser**: Chrome, Firefox, Safari, etc.
## Installation and Setup
1. **Clone the repository**:
```bash
git clone <your-repo-url>
cd ipcalc-u-later
2. **Install PHP dependencies**:
```bash
composer install
3. **Set up Node.js and Yarn (for front-end development)**:
- Install Node.js (ensure you're using version 20 or higher)
- You can download and install the latest version from Node.js [official site](https://nodejs.org/en).
- Install Yarn globally via npm (if not already installed):
```bash
npm install -g yarn
4. **Install frontend dependencies**
```bash
yarn install
5. **Setup the server**:
- If you're using Apache, ensure that mod_rewrite is enabled and the .htaccess file is properly configured.
- If you're using Nginx, configure the server block to point to the public directory.
6. **Run the server**:
```bash
php -S localhost:8000 -t public
7. **Acces the application**:
- Open a web browser and navigate to http://localhost:8000 (or the appropriate URL based on your server configuration).
## Licence
This project is licensed under the MIT License
## Acknowledgements
- The MVC design pattern was used to structure the application.
- This project was developed as part of a technical challenge.

View file

@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
import { useToast } from 'vue-toast-notification';
import Button from '@app/components/Button.vue';
import Input from '@app/components/Input.vue';
const $toast = useToast();
const subnet = ref( '' );
const isLoading = ref( false );
const hasResults = ref( false );
const results = ref( {} );
/**
* Load subnet data based on input
*/
const getSubnetData = () => {
// Enable loading icon
isLoading.value = true;
// Create post data
const postData = new FormData();
postData.append( 'subnet', subnet.value );
// Send request
axios.post( '/api/subnet', postData ).then( (response) => {
const { data } = response;
// Load new block
isLoading.value = false;
hasResults.value = true;
results.value = data.result;
} ).catch( (error) => {
isLoading.value = false;
// Show error message
const errorMessage = error.response.data.message || 'Something went wrong.';
$toast.error(errorMessage, {
position: 'top',
duration: 1500,
});
} );
}
/**
* Reset form by resetting al vars
*/
const resetForm = () => {
subnet.value = '';
isLoading.value = false;
hasResults.value = false;
results.value = {};
}
</script>
<template>
<div class="flex justify-center items-center min-h-screen">
<div class="sm:w-3/4 md:w-2/3 lg:w-2/4 xl:w-2/5 2xl:w-1/5 -translate-y-24">
<img src="https://www.bit.nl/assets/images/bit_logo_white.png" alt="BIT Logo" class="mx-auto w-52 mb-6">
<div class="bg-white p-10 rounded-xl shadow-2xl">
<h1 class="text-2xl font-semibold text-center mb-6">IPcalc-u-later</h1>
<div v-if="hasResults">
<table class="min-w-full table-auto text-left">
<tbody>
<tr class="">
<td class="py-2 font-medium">Network Address</td>
<td class="py-2 text-right">{{ results.network }}</td>
</tr>
<tr class="">
<td class="py-2 font-medium">First Usable IP</td>
<td class="py-2 text-right">{{ results.first }}</td>
</tr>
<tr class="">
<td class="py-2 font-medium">Last Usable IP</td>
<td class="py-2 text-right">{{ results.last }}</td>
</tr>
<tr class="">
<td class="py-2 font-medium">Number of Usable Hosts</td>
<td class="py-2 text-right">{{ results.hosts }}</td>
</tr>
</tbody>
</table>
<Button @click="resetForm">Reset</Button>
</div>
<form v-else method="post" class="text-left" @submit.prevent="getSubnetData">
<div class="mb-4">
<label for="subnet" class="block text-sm font-medium">Enter Subnet</label>
<Input name="subnet" v-model="subnet" placeholder="(e.g. 192.168.1.0/24 or 2001:db8::/64)" />
</div>
<Button type="submit">
<i class="fa-solid fa-circle-notch fa-pulse" v-if="isLoading"></i>
<span v-else>Submit</span>
</Button>
</form>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps( {
type: {
type: String,
default: "button"
}
} )
</script>
<template>
<button :type class="w-full py-2 mt-4 bg-primary text-white rounded-lg hover:bg-primary-light focus:outline-none focus:ring-2 focus:ring-primary-light">
<slot />
</button>
</template>

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { defineProps, defineEmits, ref, watch } from 'vue';
const props = defineProps( {
modelValue: {
type: String,
default: '',
},
name: {
type: String,
},
required: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: "",
}
} );
const emit = defineEmits(['update:modelValue']);
const inputValue = ref(props.modelValue);
const updateValue = () => {
emit('update:modelValue', inputValue.value);
};
// If the modelValue prop changes externally, update the local ref
watch(() => props.modelValue, (newVal) => {
inputValue.value = newVal;
});
</script>
<template>
<input
type="text"
:id="name"
:name="name"
class="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
:placeholder
:required
v-model="inputValue"
@input="updateValue"
>
</template>

21
resources/scripts/main.ts Normal file
View file

@ -0,0 +1,21 @@
import '@styles/main.scss';
import { createApp } from 'vue';
import ToastPlugin from 'vue-toast-notification';
import 'vue-toast-notification/dist/theme-default.css';
// Import components
import Subnet from '@app/Subnet.vue';
// Initialize vue app
function initializeApp(element: string, component: any): void {
const app = createApp(component);
// Use plugins and global settings
app.use(ToastPlugin);
// Mount the app to a specific DOM element
app.mount(element);
}
// Load components
initializeApp('#subnet-app', Subnet);

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IPcalc-u-later</title>
<link rel="stylesheet" href="/dist/css/app.css">
<script type="module" src="/dist/scripts/app.js"></script>
</head>
<body class="bg-gradient-to-r from-primary to-primary-light">
<div id="subnet-app"></div>
<script src="https://kit.fontawesome.com/02e67c0aed.js" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,32 @@
<?php
namespace Core\Controllers;
use Core\Http\Request;
use Core\Http\Response;
class Controller
{
/**
* Request handler
*
* @var \Core\Http\Request
*/
protected Request $request;
/**
* Response handler
*
* @var \Core\Http\Response
*/
protected Response $response;
/**
* Load controller helpers
*/
public function __construct(Request $request)
{
$this->request = $request;
$this->response = new Response();
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace Core\Exceptions;
use Throwable;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run as Whoops;
class Exceptions
{
/**
* Exceptions handler instance
*
* @var Whoops
*/
private static Whoops $instance;
/**
* Get exceptions handler instance
*
* @return \Whoops\Run
*/
public static function handler(): Whoops
{
if (!isset(self::$instance)) {
$instance = new Whoops();
$instance->pushHandler(new PrettyPageHandler());
self::$instance = $instance;
}
return self::$instance;
}
/**
* Catch all exceptions
*
* @return void
*/
public static function catch(): void
{
self::handler()->register();
}
/**
* Catch single exception
*
* @param \Throwable $exception
* @return void
*/
public static function catchOne(Throwable $exception): void
{
self::handler()->handleException($exception);
}
}

69
src/Http/Request.php Normal file
View file

@ -0,0 +1,69 @@
<?php
namespace Core\Http;
class Request
{
/**
* Get request method
*
* @return mixed
*/
public function method(): mixed
{
return $_SERVER['REQUEST_METHOD'];
}
/**
* Get request url
*
* @return string
*/
public function url(): string
{
return strtok($_SERVER['REQUEST_URI'], '?');
}
/**
* Check if request is specific method
*
* @param string $method
* @return bool
*/
public function is(string $method): bool
{
return ($this->method() === strtoupper($method));
}
/**
* Check if request has POST data
*
* @param string $param
* @return bool
*/
public final function has(string $param): bool
{
return isset($_POST[$param]);
}
/**
* Get POST data
*
* @param string|null $param
* @param mixed|null $default
* @return mixed
*/
public final function get(string|null $param = null, mixed $default = null): mixed
{
if($param == null) {
return $_POST;
}
if($this->has($param)) {
return $_POST[$param];
}
return $default;
}
}

44
src/Http/Response.php Normal file
View file

@ -0,0 +1,44 @@
<?php
namespace Core\Http;
use Core\View\JsonRender;
use Core\View\Render;
use Core\View\Render\HtmlRender;
class Response
{
/**
* Set statuscode for response
*
* @param int $status
* @return $this
*/
public function status(int $status): static
{
http_response_code($status);
return $this;
}
/**
* Render HTML
*
* @param string $view
* @return \Core\View\Render
*/
public function view(string $view): Render
{
return (new HtmlRender())->view($view);
}
/**
* Render JSON
*
* @return \Core\View\Render
*/
public function json(): Render
{
return new JsonRender();
}
}

92
src/Router/Router.php Normal file
View file

@ -0,0 +1,92 @@
<?php
namespace Core\Router;
use Core\Exceptions\Exceptions;
use Core\Http\Request;
use Core\View\Render;
use Exception;
class Router
{
/**
* List of routes
*
* @var array
*/
protected static array $routes = [];
/**
* Add GET route
*
* @param string $route
* @param string $controller
* @param string $action
* @return void
*/
public static function get(string $route, string $controller, string $action): void
{
self::register($route, $controller, $action, "GET");
}
/**
* Add POST route
*
* @param string $route
* @param string $controller
* @param string $action
* @return void
*/
public static function post(string $route, string $controller, string $action): void
{
self::register($route, $controller, $action, "POST");
}
/**
* Register route
*
* @param string $route
* @param string $controller
* @param string $action
* @param string $method
* @return void
*/
public static function register(string $route, string $controller, string $action, string $method): void
{
self::$routes[$method][$route] = [
'controller' => $controller,
'action' => $action,
];
}
/**
* Dispatch router and run application
*
* @return void
*/
public static function dispatch(): void
{
// Capture all exceptions
Exceptions::catch();
// Init request
$request = new Request();
$url = $request->url();
$method = $request->method();
if (array_key_exists($url, self::$routes[$method])) {
$controller = self::$routes[$method][$url]['controller'];
$action = self::$routes[$method][$url]['action'];
$controller = new $controller($request);
$response = $controller->$action();
if ($response instanceof Render) {
$response->render();
}
} else {
Exceptions::catchOne(new Exception("No route found for: $url"));
}
}
}

16
src/View/JsonRender.php Normal file
View file

@ -0,0 +1,16 @@
<?php
namespace Core\View;
class JsonRender extends Render
{
/**
* @inheritDoc
* @throws \Exception
*/
public function render(): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->data);
}
}

58
src/View/Render.php Normal file
View file

@ -0,0 +1,58 @@
<?php
namespace Core\View;
abstract class Render
{
/**
* View data
*
* @var array
*/
protected array $data = [];
/**
* View template
*
* @var string
*/
protected readonly string $view;
/**
* Set view template
*
* @param string $view
* @return $this
*/
public function view(string $view): static
{
$this->view = $view;
return $this;
}
/**
* Add data to the view
*
* @param array|string $key
* @param mixed|null $value
* @return $this
*/
public function with(array|string $key, mixed $value = null): static
{
if (!is_array($key)) {
$key = [$key => $value];
}
$this->data = array_merge($this->data, $key);
return $this;
}
/**
* Render HTML view
*
* @return void
*/
public abstract function render(): void;
}

View file

@ -0,0 +1,28 @@
<?php
namespace Core\View\Render;
use Core\View\Render;
class HtmlRender extends Render
{
/**
* @inheritDoc
* @throws \Exception
*/
public function render(): void
{
$basePath = $_SERVER['DOCUMENT_ROOT'];
$viewsPath = $basePath . '/../resources/views/' . str_replace('.', '/', $this->view) . '.php';
if (file_exists($viewsPath)) {
extract($this->data);
include $viewsPath;
return;
}
throw new \Exception('View not found');
}
}

19
tailwind.config.js Normal file
View file

@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./resources/views/**/*.php',
'./resources/scripts/**/*.{js,ts,vue}',
'./resources/styles/**/*.{scss,css}',
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#002649',
light: '#76afe3',
},
},
},
},
plugins: [],
};

42
vite.config.ts Normal file
View file

@ -0,0 +1,42 @@
import { defineConfig } from 'vite';
import * as path from 'path';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
root: '.',
publicDir: 'public/dist',
build: {
outDir: 'public/dist',
emptyOutDir: true,
rollupOptions: {
input: {
app: path.resolve('resources/scripts/main.ts'),
},
output: {
entryFileNames: 'scripts/[name].js',
chunkFileNames: 'scripts/[name].js',
assetFileNames: '[ext]/[name].[ext]',
},
},
},
resolve: {
alias: {
'@styles': path.resolve('resources/styles'),
'@scripts': path.resolve('resources/scripts'),
'@app': path.resolve('resources/scripts/app')
},
},
css: {
postcss: './postcss.config.cjs'
},
server: {
proxy: {
'/': {
target: 'http://bit.test',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/$/, ''),
},
},
},
});

1429
yarn.lock Normal file

File diff suppressed because it is too large Load diff