ตัวอย่างการสร้าง Webboard ด้วย PHP + MySQLi และ Bootstrap
ตัวอย่างนี้เหมาะสำหรับผู้ที่อยากจะเริ่มต้นเขียน PHP ติดต่อกับ MySQL แต่อยากได้ตัวอย่างโค้ดที่มี comment อธิบายโดยละเอียด แต่ไม่อยากให้เอาไปต่อยอดทันทีนะครับ อยากให้พยายามอ่าน comment จนเข้าใจมากกว่า
ซึ่งตัวอย่างนี้ก็ใช้วิธีการเขียนแบบที่คนส่วนใหญ่คุ้นเคย คือเป็นแบบ procedural ไม่ได้ใช้ class เพราะหลายคนบอกว่า OOP มันยากเกินไปสำหรับผู้เริ่มต้น หรือยังไม่พร้อมที่จะศึกษา
ซึ่งจริงๆ จุดประสงค์ของตัวอย่างนี้คือต้องการให้แนวคิดเริ่มต้นเกี่ยวการเขียน PHP web application แบบแยกส่วนการคำนวณออกจากส่วนแสดงผล และเน้นพื้นฐานการเขียน PHP ที่ถูกต้องและเน้นเรื่องความปลอดภัย หรือเทคนิคอื่นๆ เช่น
- การกำหนดค่าของตัวแปรก่อนใช้งาน
- การตรวจสอบการมีอยู่ของตัวแปรก่อนเข้าถึงด้วย isset() และ empty()
- การตรวจสอบความถูกต้องของข้อมูลก่อน INSERT Data validation
- การใช้ mysqli::escape_string() เพื่อป้องกัน SQL Injection
- การใช้ตัวแปรที่อยู่ใน double quote string " เพื่อแทนที่ค่าใน string (String interpolation) แทนการใช้ concat operator .
- การกำหนด id ให้ element เพื่อใช้ร่วมกับ hash tag ใน URL มีผลให้ browser scroll มายังตำแหน่งที่ต้องการเมื่อ page load
และตั้งใจให้เป็นตัวอย่างพื้นฐานเพื่อเปรียบเทียบกับตัวอย่างต่อไปที่ตั้งใจจะทำโดยใช้โค้ดเดิมนี่แหละ แต่จะทำให้อยู่ในรูปแบบ OOP จะได้สามารถพิจารณากันได้ว่าถ้าใช้ OOP แล้วมันจะดีกว่าอย่างไร
แต่ก็ใช่ว่าตัวอย่างนี้จะไม่ใช้ object เลยเสียทีเดียว เพราะใช้ฟังก์ชั่น mysqli ในแบบ OOP เหตุผลมีอธิบายอยู่ใน Source Code ครับ
โครงสร้างฐานข้อมูลอาจจะเห็นมีบางอย่างที่ยังไม่ได้ใช้งานในตัวอย่างนี้ เช่น user_id ซึ่งตั้งใจดีไซน์เผื่อไว้สำหรับระบบสมาชิกครับ
Download ไฟล์ทั้งหมดได้ ที่นี่
Github Respository
http://github.com/phpinfo-in-th/workshop-webboard
มีข้อสงสัยสอบถามได้ที่
phpinfo.in.th Facebook Page
โครงสร้างฐานข้อมูล
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
CREATE TABLE IF NOT EXISTS `comment` (
`id` int(10) unsigned NOT NULL,
`topic_id` int(10) unsigned NOT NULL,
`user_id` int(10) unsigned NOT NULL DEFAULT '0',
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` timestamp NULL DEFAULT NULL,
`description` text NOT NULL,
`name` varchar(64) NOT NULL,
`ip` varchar(45) CHARACTER SET ascii COLLATE ascii_bin NOT NULL
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `topic` (
`id` int(10) unsigned NOT NULL,
`user_id` int(10) unsigned NOT NULL DEFAULT '0',
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` timestamp NULL DEFAULT NULL,
`last_commented` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
`tags` varchar(255) NOT NULL DEFAULT '',
`title` varchar(255) NOT NULL,
`description` text NOT NULL,
`name` varchar(64) NOT NULL,
`ip` varchar(45) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`num_comments` int(10) unsigned NOT NULL DEFAULT '0',
`last_commented_user_id` int(10) unsigned NOT NULL DEFAULT '0',
`last_commented_name` varchar(64) NOT NULL DEFAULT '',
`num_views` int(10) unsigned NOT NULL DEFAULT '0'
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
ALTER TABLE `comment`
ADD PRIMARY KEY (`id`), ADD KEY `topic_id` (`topic_id`), ADD KEY `user_id` (`user_id`), ADD KEY `created` (`created`), ADD KEY `name` (`name`);
ALTER TABLE `topic`
ADD PRIMARY KEY (`id`), ADD KEY `user_id` (`user_id`), ADD KEY `created` (`created`), ADD KEY `last_replied` (`last_commented`), ADD KEY `name` (`name`), ADD KEY `num_comments` (`num_comments`), ADD KEY `num_views` (`num_views`);
ALTER TABLE `comment`
MODIFY `id` int(10) unsigned NOT NULL AUTO_INCREMENT;
ALTER TABLE `topic`
MODIFY `id` int(10) unsigned NOT NULL AUTO_INCREMENT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
inc/mysqli.inc.php (ไฟล์สำหรับ config ฐานข้อมูล แก้ไขข้อมูลให้ตรงตามเครื่องคุณที่ไฟล์นี้)
<?php
if (get_magic_quotes_gpc() || ini_get('register_globals')) {
/*
ตรวจสอบการตั้งค่า magic_quotes_gpc และ register_globals ใน php.ini ว่าเปิดใช้งานอยู่หรือไม่
ถ้ามีการเปิดใช้งาน เราจะไม่ให้โปรแกรมติดต่อกับฐานข้อมูลโดยเด็ดขาด
ดู
http://php.net/manual/en/info.configuration.php#ini.magic-quotes-gpc
http://php.net/manual/en/ini.core.php#ini.register-globals
*/
$FATAL_ERROR = 'มีการตั้งค่าที่ไม่ปลอดภัยใน php.ini';
require 'inc/main.inc.php';
}
/*
สร้าง object ของ class mysqli
*/
$mysqli = new mysqli();
/*
ทำการเชื่อมต่อกับฐานข้อมูล
โดยเราจำเป็นต้องใช้ @ operator เพื่อกัน PHP แสดง warning
เพราะ mysqli::connect() จะทำให้เกิด PHP warning หากมี error
*/
@$mysqli->connect(
'localhost', // host
'root', // username
'', // password
'phpinfo_in_th_workshop' // default database
);
/*
แต่เราจะมาตรวจสอบ error ตรงนี้แทน
หากมี error ที่เกิดขึ้นระหว่างการเชื่อมต่อ เช่น username หรือ password ผิด
จะมี error message ส่งมาจาก MySQL Server โดยจะไปอยู่ใน mysqli::$connect_error
ก็ให้แสดง error message ที่ได้จาก MySQL Server และจบการทำงาน (ดู inc/main.inc.php)
*/
if ($mysqli->connect_error) {
$TITLE = $mysqli->connect_error;
/*
ให้ error message อยู่ในรูปแบบ #<error code> - <error message>
*/
$FATAL_ERROR = "#{$mysqli->connect_errno} - {$mysqli->connect_error}";
require 'inc/main.inc.php';
}
/*
กำหนด charset สำหรับการเชื่อมต่อครั้งนี้ให้เป็น utf8
*/
$mysqli->set_charset('utf8');
ไฟล์ PHP หลัก
index.php
<?php
/*
ตัวอย่างนี้จะเน้นการแยกส่วนการทำงาน
คือส่วนที่ติดต่อกับฐานข้อมูลก็จะเป็นตัวสร้างข้อมูลเพื่อส่งไปให้ template แสดงผล
โดยตัวแปรที่จะถูกใช้ใน template (inc/index.inc.php, inc/post.inc.php, inc/view.inc.php)
จะเป็นตัวแปรที่เป็นตัวพิมพ์ใหญ่ทั้งหมด เช่น $PAGE, $ITEMS
ซึ่งเราจะใช้แนวทางนี้ทั้งหมดสำหรับตัวอย่างนี้
*/
/*
ทำการเชื่อมต่อกับฐานข้อมูล ดู (inc/mysqli.inc.php)
*/
require 'inc/mysqli.inc.php';
/*
กำหนดค่า default ให้กับตัวแปร $PAGE
*/
$PAGE = empty($_GET['page'])
? 1
: (int)$_GET['page'];
/*
จำนวนกระทู้ที่จะแสดงใน 1 หน้า
*/
$ITEMS_PER_PAGE = 100;
/*
คำนวณ offset เริ่มต้นที่จะกำหนดใน LIMIT ซึ่ง offset ของแถวแรกเริ่มที่ 0 ไม่ใช่ 1
ถ้าอยู่ที่หน้า 1 ก็จะได้ LIMIT 0, 100
ถ้าอยู่ที่หน้า 3 ก็จะได้ LIMIT 200, 100
*/
$START_OFFSET = ($PAGE - 1) * $ITEMS_PER_PAGE;
/*
ส่ง SQL query ไปยัง MySQL Server ด้วย mysqli::query()
หากไม่มี error และชนิดของ query ที่ส่งไปเป็น SELECT หรืออื่นๆ
ที่คืนแถวกลับมาเช่น SHOW DATABASES, SHOW VARIABLES
mysqli::query() จะ return instance ของ class mysqli_result กลับมา
นอกนั้นจะ return true หรือ false
โดยเราจะเลือก SELECT เฉพาะฟิลด์ที่ต้องใช้มาเท่านั้น
จะไม่ใช้ SELECT * เพื่อลดการใช้หน่อยความจำและเพื่อเพิ่มประสิทธิภาพ
และเราจะเรียงลำดับตามวันที่มีผู้แสดงความเห็นล่าสุด (last_commented)
*/
$result = $mysqli->query(
"
SELECT
`id`,
`created`,
`last_commented`,
`title`,
`name`,
`num_comments`,
`last_commented_name`
FROM `topic`
ORDER BY `last_commented` DESC
LIMIT {$START_OFFSET}, {$ITEMS_PER_PAGE}
"
);
/*
เมื่อได้ $result ซึ่งจะเป็น instance ของ class mysqli_result
เราก็จะอ่านข้อมูลที่ได้ลง array ด้วย mysqli_result::fetch_assoc()
คืออ่านมาเป็น associative array และใส่เข้าไปใน array $ITEMS เพื่อนำไปใช้ใน template ต่อไป
โดยเราจะอ่านเข้ามาพักในตัวแปร $item ก่อน ซึ่งหากยังมีข้อมูลอยู่ $item จะไม่ใช่ null
จะทำให้เงื่อนไขใน while เป็นจริง $item ก็จะถูกเพิ่มเข้าไปใน $ITEMS
แต่หากอ่านข้อมูลจนหมดแล้ว (หรือไม่มีข้อมูลตั้งแต่ต้น) mysqli_result::fetch_assoc() จะ return null
และทำให้ $item เป็น null เงื่อนไขก็จะเป็นเท็จ และออกจาก while
*/
$ITEMS = array();
while ($item = $result->fetch_assoc()) {
$ITEMS[] = $item;
}
/*
ปล่อยหน่วยความจำที่ $result ใช้
*/
$result->free();
/*
หาจำนวนกระทู้ทั้งหมด ซึ่งในที่นี้ไม่ใช้ SELECT SQL_CALC_FOUND_ROWS ร่วมกับ FOUND_ROWS()
เพราะพิสูจน์จากหลายแหล่งข้อมูลแล้วว่าทำงานช้ากว่า COUNT(*)
*/
$result = $mysqli->query('SELECT COUNT(*) FROM `topic`');
/*
mysqli_result::fetch_row() หรือ mysqli_result::fetch_assoc()
จะคืนค่ากลับมาเป็น array เสมอแม้จะมีแค่ฟิลด์เดียวที่ SELECT มาก็ตาม
แต่เนื่องจากเราอยากได้ค่าของฟิลด์แรกใน array ไม่ใช่ตัว array เอง
เราจึงใช้ current() เพื่ออ่านค่าของสมาชิกตัวแรกใน array
*/
$FOUND_ROWS = current($result->fetch_row());
/*
ปล่อยหน่วยความจำที่ $result ใช้
*/
$result->free();
/*
หาจำนวนหน้าทั้งหมด โดยหารจำนวนแถวทั้งหมดด้วยจำนวนที่แสดงผลต่อหน้า และปัดเศษขึ้นด้วย ceil()
*/
$NUM_PAGES = ceil($FOUND_ROWS / $ITEMS_PER_PAGE);
/*
บอก inc/main.inc.php ให้ require ไฟล์ inc/index.inc.php เป็น template
*/
$PAGE_TEMPLATE = 'inc/index.inc.php';
require 'inc/main.inc.php';
post.php
<?php
/*
เราใช้ post.php เป็นทั้งไฟล์ที่ทำการแสดงผลและบันทึกข้อมูลกระทู้ใหม่
ดังนั้นเราจะตรวจสอบว่าการเรียกไฟล์นี้นั้นเป็นการบันทึกหรือไม่ด้วยค่าของตัวแปร $_SERVER['REQUEST_METHOD']
ซึ่งมันจะมีค่าเป็น 'POST' หากมีการ submit มาจาก <form> ที่มี method="post"
*/
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
/*
ตรวจสอบให้แน่ชัดว่ามีข้อมูลที่จำเป็นส่งมาครบหรือไม่ด้วย isset()
ซึ่งจะเป็นจริงหากใน $_POST มี key ที่ต้องการครบ
*/
if (!isset($_POST['title'], $_POST['description'], $_POST['name'])) {
/*
หากไม่ครบก็ให้ redirect ไปที่ index.php
*/
header('Location: index.php');
exit;
}
/*
เราจะ copy $_POST มาไว้ในตัวแปร $DATA
ด้วยเหตุผลที่ว่าเราจะไม่เปลี่ยนแปลงค่าของ $_POST โดยตรง
และเพื่อให้เป็นแนวทางเดียวกันกับทุกตัวแปรที่จะส่งไปยัง template
*/
$DATA = $_POST;
/*
ทำการ trim() (ตัดช่องว่างหน้าและหลัง) ของข้อมูลใน $DATA ทุกตัว
*/
foreach ($DATA as $key => $value) {
$DATA[$key] = trim($value);
}
/*
ตรวจสอบว่า $DATA['title'] เป็นค่าว่างหรือไม่
จะเห็นว่าเราใช้ === เปรียบเทียบกับ empty string โดยไม่ใช้ empty() หรือ !$DATA['title']
เพราะการเปรียบเทียบด้วยวิธีหลังเป็นการเปรียบเทียบแบบ loose คือมันจะเป็นจริงได้ในหลายกรณีเกินไป
เช่น เมื่อ $DATA['title'] มีค่าเท่ากับ string '0' ซึ่งไม่ตรงความต้องการของเราแน่ๆ
*/
if ($DATA['title'] === '') {
/*
กำหนดค่าให้กับตัวแปร $FORM_ERRORS เพื่อนำไปใช้ใน inc/form_errors.inc.php ต่อไป
*/
$FORM_ERRORS['title'] = "กรุณาระบุ 'หัวข้อ'";
}
/*
และตรวจสอบความยาวของ $DATA['title'] ว่ามีความยาวมากกว่าที่กำหนดหรือไม่
ด้วย mb_strlen() ซึ่งเราไม่ใช้ strlen()
เพราะว่า strlen() จะตรวจจำนวน byte ไม่ใช่จำนวนตัวอักษร
ซึ่งปัจจุบันเราใช้ encoding ชนิด UTF-8 เป็นหลัก ตัวอักษร 1 ตัวอาจจะมีความยาวมากกว่า 1 byte
อย่างภาษาไทย ทุกตัวอักษรจะมีขนาด 3 bytes
*/
elseif (mb_strlen($DATA['title'], 'UTF-8') > 255) {
$FORM_ERRORS['title'] = "'หัวข้อ' ต้องมีความยาวไม่เกิน 255 ตัวอักษร";
}
/*
ทำการตรวจสอบกับข้อมูลอื่นๆ เช่นเดียวกัน
*/
if ($DATA['description'] === '') {
$FORM_ERRORS['description'] = "กรุณาระบุ 'รายละเอียด'";
} elseif (mb_strlen($DATA['description'], 'UTF-8') > 65535) {
$FORM_ERRORS['description'] = "'รายละเอียด' ต้องมีความยาวไม่เกิน 65535 ตัวอักษร";
}
if ($DATA['name'] === '') {
$FORM_ERRORS['name'] = "กรุณาระบุ 'ชื่อ'";
} elseif (mb_strlen($DATA['name'], 'UTF-8') > 64) {
$FORM_ERRORS['name'] = "'ชื่อ' ต้องมีความยาวไม่เกิน 64 ตัวอักษร";
}
/*
ถ้าไม่มีตัวแปร $FORM_ERRORS ถูกสร้างขึ้นมาจากการตรวจสอบข้างต้น แสดงว่าไม่มี error
ข้อมูลทั้งหมดสามารถ INSERT เข้าฐานข้อมูลได้อย่างปลอดภัย
*/
if (!isset($FORM_ERRORS)) {
/*
ทำการเชื่อมต่อกับฐานข้อมูล ดู (inc/mysqli.inc.php)
ซึ่งเราไม่จำเป็นต้องเชื่อมต่อตั้งแต่แรกเพราะยังไม่จำเป็นต้องใช้จนกว่าจะแน่ใจว่าข้อมูลนั้นถูกต้องทั้งหมด
*/
require 'inc/mysqli.inc.php';
/*
ส่ง SQL query ไปยัง MySQL Server ด้วย mysqli::query()
โดยเราจะ escape ข้อมูลที่มาจากภายนอกทั้งหมดด้วย mysqli::escape_string()
โดยใช้ฟังก์ชั่น sprintf() ช่วย ดู (inc/main.inc.php สำหรับ sprintf())
*/
$mysqli->query(
/*
mysqli::escape_string() จะแปลงตัวอักษรพิเศษ เช่น ' ให้เป็น \' หรือ ''
ซึ่งทำให้ MySQL Server รู้ว่ามันเป็นข้อมูล ไม่ใช่ delimeter
หากเราไม่ใช้ mysqli::escape_string() และผ่านข้อมูลไปเป็น SQL query โดยตรง
อาจจะทำให้เกิด error หรือ SQL Injection ขึ้นได้
และนี่คือข้อดีของการใช้ mysqli ในแบบ OOP คือจะเห็นว่าเราสามารถแทนที่
$mysqli->escape_string() ลงไปใน double quote string ได้เลย
แต่ถ้าเราใช้ mysqli_escape_string() ที่เป็น procedural style
จะไม่สามารถทำแบบนี้ได้
*/
"
INSERT INTO `topic`
(
`last_commented`,
`title`,
`description`,
`name`,
`ip`
)
VALUES
(
NOW(),
'{$mysqli->escape_string($DATA['title'])}',
'{$mysqli->escape_string($DATA['description'])}',
'{$mysqli->escape_string($DATA['name'])}',
'{$_SERVER['REMOTE_ADDR']}'
)
"
);
/*
redirect ไปยัง index.php โดยส่ง query string ชื่อ highlight_id
ที่มีค่าเป็น id ของแถวที่เพิ่ง INSERT เข้าไปในตาราง topic ไปด้วย
เพื่อใช้เน้นสีพื้นหลังของกระทู้ใหม่ (ดู inc/index.inc.php)
*/
header('Location: index.php?highlight_id=' . $mysqli->insert_id);
/*
จบการทำงาน
*/
exit;
}
/*
หากมี error ก็จะแสดงผลให้ผู้ใช้แก้ไขข้อมูลให้ถูกต้อง
*/
} else {
/*
หากไม่ใช่การ POST ก็ให้กำหนดค่า default สำหรับ $DATA ซึ่งจะถูกใช้งานใน inc/post.inc.php
โดยให้เป็นค่าว่างทั้งหมด
*/
$DATA = array(
'title' => '',
'description' => '',
'name' => '',
);
}
$TAGS = array('PHP', 'JavaScript', 'SQL', 'HTML', 'CSS');
/*
บอก inc/main.inc.php ให้ require ไฟล์ inc/post.inc.php เป็น template
*/
$TITLE = 'ตั้งกระทู้ใหม่';
$PAGE_TEMPLATE = 'inc/post.inc.php';
require 'inc/main.inc.php';
view.php
<?php
require 'inc/mysqli.inc.php';
/*
เราใช้ view.php เป็นทั้งไฟล์ที่ทำการแสดงผลและบันทึกข้อมูลความเห็นใหม่
ดังนั้นเราจะตรวจสอบว่าการเรียกไฟล์นี้นั้นเป็นการบันทึกหรือไม่ด้วยค่าของตัวแปร $_SERVER['REQUEST_METHOD']
ซึ่งมันจะมีค่าเป็น 'POST' หากมีการ submit มาจาก <form> ที่มี method="post"
*/
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
/*
ตรวจสอบให้แน่ชัดว่ามีข้อมูลที่จำเป็นส่งมาครบหรือไม่ด้วย isset()
ซึ่งจะเป็นจริงหากใน $_POST มี key ที่ต้องการครบ
*/
if (!isset($_POST['topic_id'], $_POST['description'], $_POST['name'])) {
/*
หากไม่ครบก็ให้ redirect ไปที่ index.php
*/
header('Location: index.php');
exit;
}
/*
เราจะ copy $_POST มาไว้ในตัวแปร $DATA
ด้วยเหตุผลที่ว่าเราจะไม่เปลี่ยนแปลงค่าของ $_POST โดยตรง
และเพื่อให้เป็นแนวทางเดียวกันกับทุกตัวแปรที่จะส่งไปยัง template
*/
$DATA = $_POST;
/*
$TOPIC_ID จะถูกใช้ใน template
*/
$TOPIC_ID = (int)$DATA['topic_id'];
/*
ทำการ trim() (ตัดช่องว่างหน้าและหลัง) ของข้อมูลใน $DATA ทุกตัว
*/
foreach ($DATA as $key => $value) {
$DATA[$key] = trim($value);
}
/*
ตรวจสอบว่ามีกระทู้ที่มี id ตาม $TOPIC_ID อยู่จริงหรือไม่
จะเห็นว่าเราสามารถนำ $TOPIC_ID ไปแทนที่ตรงๆ ได้เลย ไม่ใช้ mysqli::escape_string()
เพราะก่อนหน้านี้เรากำหนด $TOPIC_ID ด้วย (int)$DATA['topic_id']
ซึ่ง (int) เป็น cast operator จะทำการแปลงค่าใน $DATA['topic_id'] ให้เป็นตัวเลขจำนวนเต็ม
หากไม่สามารถแปลงได้ มันจะให้ค่า 0 เสมอ
*/
$result = $mysqli->query(
"
SELECT `id`
FROM `topic`
WHERE `id` = {$TOPIC_ID}
LIMIT 1
"
);
/*
หาก SELECT ไม่เจอ mysqli_result::fetch_row() จะ return null
*/
if (!$result->fetch_row()) {
header('Location: index.php');
exit;
}
/*
ทำการตรวจสอบความถูกต้องของข้อมูลแบบเดียวกันกับ post.php
*/
if ($DATA['description'] === '') {
$FORM_ERRORS['description'] = "กรุณาระบุ 'ข้อความ'";
} elseif (mb_strlen($DATA['description'], 'UTF-8') > 65535) {
$FORM_ERRORS['description'] = "'ข้อความ' ต้องมีความยาวไม่เกิน 65535 ตัวอักษร";
}
if ($DATA['name'] === '') {
$FORM_ERRORS['name'] = "กรุณาระบุ 'ชื่อ'";
} elseif (mb_strlen($DATA['name'], 'UTF-8') > 64) {
$FORM_ERRORS['name'] = "'ชื่อ' ต้องมีความยาวไม่เกิน 64 ตัวอักษร";
}
/*
ถ้าไม่มีตัวแปร $FORM_ERRORS ถูกสร้างขึ้นมาจากการตรวจสอบข้างต้น แสดงว่าไม่มี error
ข้อมูลทั้งหมดสามารถ INSERT เข้าฐานข้อมูลได้อย่างปลอดภัย
*/
if (!isset($FORM_ERRORS)) {
$mysqli->query(
/*
mysqli::escape_string() จะแปลงตัวอักษรพิเศษ เช่น ' ให้เป็น \' หรือ ''
ซึ่งทำให้ MySQL Server รู้ว่ามันเป็นข้อมูล ไม่ใช่ delimeter
หากเราไม่ใช้ mysqli::escape_string() และผ่านข้อมูลไปเป็น SQL query โดยตรง
อาจจะทำให้เกิด error หรือ SQL Injection ขึ้นได้
ยกเว้น $TOPIC_ID ตามที่กล่าวไว้ข้างต้น
และ $_SERVER['REMOTE_ADDR'] ที่เชื่อถือได้ว่าไม่มีตัวอักษรพิเศษแน่นอน
และนี่คือข้อดีของการใช้ mysqli ในแบบ OOP คือจะเห็นว่าเราสามารถแทนที่
$mysqli->escape_string() ลงไปใน double quote string ได้เลย
แต่ถ้าเราใช้ mysqli_escape_string() ที่เป็น procedural style
จะไม่สามารถทำแบบนี้ได้
*/
"
INSERT INTO `comment`
(
`topic_id`,
`description`,
`name`,
`ip`
)
VALUES
(
{$TOPIC_ID},
'{$mysqli->escape_string($DATA['description'])}',
'{$mysqli->escape_string($DATA['name'])}',
'{$_SERVER['REMOTE_ADDR']}'
)
"
);
/*
อ่าน id ของความเห็นที่เพิ่ง INSERT เข้าไปด้วย mysqli::$insert_id
*/
$comment_id = $mysqli->insert_id;
/*
ทำการ UPDATE กระทู้
โดยให้เวลาตอบกระทู้ล่าสุด (last_commented) เป็นเวลาปัจจุบัน เพื่อให้กระทู้ย้ายขึ้นมาบนสุด
และเพิ่มจำนวนความเห็น (num_comments)
และกำหนดชื่อผู้แสดงความเห็นล่าสุด (last_commented_name) เป็น $DATA['name']
*/
$mysqli->query(
"
UPDATE `topic`
SET
`last_commented` = NOW(),
`num_comments` = `num_comments` + 1,
`last_commented_name` = '{$mysqli->escape_string($DATA['name'])}'
WHERE `id` = {$TOPIC_ID}
"
);
/*
refresh view.php ด้วยการ redirect
โดยกำหนด hash tag #comment-<id ของความเห็นล่าสุด> เข้าไปด้วย
เพื่อให้ browser scroll มาที่ความเห็นที่ผู้ใช้เพิ่งบันทึกไป
*/
header("Location: view.php?topic_id={$TOPIC_ID}#comment-{$comment_id}");
exit;
}
/*
หากมี error ก็จะแสดงผลให้ผู้ใช้แก้ไขข้อมูลให้ถูกต้อง
*/
} else {
/*
หากไม่ใช่การ POST ก็ให้กำหนดค่า default สำหรับ $TOPIC_ID จาก $_GET['topic_id']
และกำหนดค่า default สำหรับ $DATA ซึ่งจะถูกใช้งานใน inc/view.inc.php
โดยให้เป็นค่าว่างทั้งหมด
*/
$TOPIC_ID = empty($_GET['topic_id'])
? 0
: (int)$_GET['topic_id'];
$DATA = array(
'description' => '',
'name' => '',
);
}
/*
SELECT กระทู้ที่มี id ตาม $TOPIC_ID
*/
$result = $mysqli->query(
"
SELECT
`id`,
`created`,
`title`,
`description`,
`name`,
`ip`,
`num_comments`,
`num_views`
FROM `topic`
WHERE `id` = {$TOPIC_ID}
LIMIT 1
"
);
$topic = $result->fetch_assoc();
/*
หากไม่เจอกระทู้ที่มี id ตาม $TOPIC_ID ตัวแปร $topic ก็จะเป็น null
*/
if (!isset($topic)) {
/*
กำหนดให้ inc/main.inc.php แสดง error
*/
$FATAL_ERROR = $TITLE = "ไม่มีกระทู้หมายเลข {$TOPIC_ID} อยู่ในฐานข้อมูล";
require 'inc/main.inc.php';
}
/*
กำหนดตัวแปร $ITEMS ให้เป็น array ของทั้งกระทู้และความเห็น
โดยเราจะไม่แยกมันออกจากกัน เพื่อลดความซ้ำซ้อนของโค้ดแสดงผล (ดู inc/view.inc.php)
ให้ $topic เป็นสมาชิกตัวแรกของมัน
*/
$ITEMS = array($topic);
/*
ปล่อยหน่วยความจำที่ $result ใช้
*/
$result->free();
/*
UPDATE จำนวนผู้เข้าชมของกระทู้
*/
$mysqli->query(
"
UPDATE `topic`
SET `num_views` = `num_views` + 1
WHERE `id` = {$TOPIC_ID}
LIMIT 1
"
);
/*
หากกระทู้มีความเห็น
*/
if ($topic['num_comments']) {
/*
กำหนดค่า default ให้กับตัวแปร $PAGE
*/
$PAGE = empty($_GET['page'])
? 1
: (int)$_GET['page'];
/*
จำนวนความเห็นที่จะแสดงใน 1 หน้า
*/
$ITEMS_PER_PAGE = 100;
/*
คำนวณ offset เริ่มต้นที่จะกำหนดใน LIMIT ซึ่ง offset ของแถวแรกเริ่มที่ 0 ไม่ใช่ 1
ถ้าอยู่ที่หน้า 1 ก็จะได้ LIMIT 0, 100
ถ้าอยู่ที่หน้า 3 ก็จะได้ LIMIT 200, 100
*/
$START_OFFSET = ($PAGE - 1) * $ITEMS_PER_PAGE;
/*
SELECT และอ่านข้อมูลความเห็นเข้ามาไว้ใน $ITEMS (ดู index.php)
*/
$result = $mysqli->query(
"
SELECT
`id`,
`created`,
`description`,
`name`,
`ip`
FROM `comment`
WHERE `topic_id` = {$TOPIC_ID}
ORDER BY `created`
LIMIT {$START_OFFSET}, {$ITEMS_PER_PAGE}
"
);
while ($comment = $result->fetch_assoc()) {
$ITEMS[] = $comment;
}
$result->free();
$result = $mysqli->query(
"
SELECT COUNT(*)
FROM `comment`
WHERE `topic_id` = {$TOPIC_ID}
"
);
$FOUND_ROWS = current($result->fetch_row());
$result->free();
$NUM_PAGES = ceil($FOUND_ROWS / $ITEMS_PER_PAGE);
} else {
/*
ถ้าไม่มีความเห็น ก็กำหนดค่า default ให้กับ $NUM_PAGES เพื่อใช้ใน template ต่อไป
*/
$NUM_PAGES = 0;
}
/*
บอก inc/main.inc.php ให้ใช้หัวข้อกระทู้เป็น <title>
*/
$TITLE = $topic['title'];
/*
บอก inc/main.inc.php ให้ require ไฟล์ inc/view.inc.php เป็น template
*/
$PAGE_TEMPLATE = 'inc/view.inc.php';
require 'inc/main.inc.php';
ไฟล์ template
inc/main.inc.php
<?php
/*
ไฟล์ template หลักของทุกหน้า ซึ่งอาจจะ require template รองอีกที
จากตัวแปร $PAGE_TEMPLATE ที่ต้องกำหนดไว้ก่อนเรียกใช้ไฟล์นี้
และจะใช้ Alternative syntax for control structures ใน template
(ดู http://php.net/manual/en/control-structures.alternative-syntax.php)
เช่น
if (1 > 2) {
...
}
จะเป็น
if (1 > 2):
...
endif;
และในตัวอย่างนี้ จะยังไม่ใช้ JavaScript ใดใดทั้งสิ้น
*/
/*
ฟังก์ชั่นตัวช่วยแปลง unix timestamp หรือ SQL DATETIME ให้เป็นวันที่ภาษาไทยแบบย่อ
เช่น '2014-12-01 04:14:00' จะกลายเป็น '1 ธ.ค. 57 04:14'
*/
function thai_datetime($timestamp)
{
static $thaiMonthAbbrs = array(
'ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.',
'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.',
);
/*
หาก $timestamp ไม่ใช่ตัวเลข
*/
if (!is_numeric($timestamp)) {
/*
ให้ใช้ฟังก์ชั่น strtotime() แปลง $timestamp ให้เป็น unix timestamp
*/
$timestamp = strtotime($timestamp);
}
/*
และใช้ฟังก์ชั่น getdate() ดึงข้อมูล array เกี่ยวกับ timestamp นั้นๆ
เช่น วันที่, เดือน, ปี, ชั่วโมง, นาที, วินาที ฯลฯ
*/
$info = getdate($timestamp);
/*
เราใช้ฟังก์ชั่น sprintf() เพื่อทำการจัดรูปแบบข้อความ
โดย argument แรกจะเป็น 'รูปแบบ' และ argument ที่เหลือจะเป็นค่าที่จะส่งไปแทนที่ใน 'รูปแบบ'
ซึ่งรูปแบบที่จะเป็น ตัวแทนที่ นั้น จะเริ่มต้นด้วย % ตามด้วยตัวอักษร s, d หรืออื่นๆ (ดู PHP Manual)
*/
return sprintf(
/*
%d คือให้แทนที่ค่าที่ส่งมา ในรูปแบบตัวเลขจำนวนเต็ม
%s คือให้แทนที่ค่าที่ส่งมา ในรูปแบบ string หากค่านั้นไม่ใช่ string ก็จะทำการแปลงให้เป็น string
%02d คือให้แทนที่ค่าที่ส่งมา ในรูปแบบตัวเลขจำนวนเต็ม และเติม 0 เข้าไปข้างหน้าสูงสุด 2 ตัว
เช่น ค่าที่ส่งมาคือ 1 ก็จะกลายเป็น 01
*/
'%d %s %d %02d:%02d',
/*
$info['mday'] จะเป็นตัวเลขวันที่ 1 - 31
ค่าจะไปแทนที่ %d ตัวแรก
*/
$info['mday'],
/*
$info['mon'] จะเป็นตัวเลขเดือน 1 - 12
ซึ่งเราจะเอาไปใช้เป็น key ในการเลือกค่าจากตัวแปร $thaiMonthAbbrs
ซึ่ง $thaiMonthAbbrs นั้นมี key เริ่มที่ 0 เราจึงต้องลบ $info['mon'] ด้วย 1 เสียก่อน
ค่านี้จะไปแทนที่ %s
*/
$thaiMonthAbbrs[$info['mon'] - 1],
/*
แปลง $info['year'] ให้เป็น พ.ศ. โดย + ด้วย 543
และใช้ substr() ตัดเฉพาะตัวเลข 2 หลักสุดท้ายของ พ.ศ. ออกมา
ค่าจะไปแทนที่ %d ตัวที่สอง
*/
substr($info['year'] + 543, -2),
/*
เลขชั่วโมง
ค่านี้จะไปแทนที่ %02d ตัวแรก
*/
$info['hours'],
/*
เลขนาที
ค่านี้จะไปแทนที่ %02d ตัวที่สอง
*/
$info['minutes']
);
}
/*
ฟังก์ชั่นตัวช่วยแปลง unix timestamp หรือ SQL DATETIME ให้เป็นช่วงห่างของเวลาภาษาไทย
เช่น 15 นาทีที่แล้ว
และหากช่วงห่างเกินจำนวนวันที่กำหนดไว้ใน $daysBeforeFullDate ก็จะแสดงวันที่เต็ม
โดยเรียกใช้ thai_datetime() อีกทอดหนึ่ง
*/
function thai_time($timestamp, $daysBeforeFullDate = 3)
{
/*
หาก $timestamp ไม่ใช่ตัวเลข
*/
if (!is_numeric($timestamp)) {
/*
ให้ใช้ฟังก์ชั่น strtotime() แปลง $timestamp ให้เป็น unix timestamp
*/
$timestamp = strtotime($timestamp);
}
/*
เราจะหาค่าช่วงห่างของเวลาปัจจุบันกับ $timestamp
โดยเวลาปัจจุบันนั้นหาได้จากฟังก์ชั่น time()
*/
$diff = time() - $timestamp;
/*
หากความต่างของเวลาปัจจุบันกับ $timestamp น้อยกว่า 10 วินาที
*/
if (!$diff) {
return 'ไม่กี่วินาทีที่แล้ว';
}
/*
หากความต่างของเวลาปัจจุบันกับ $timestamp น้อยกว่า 1 นาที
*/
if ($diff < 60) {
return $diff . ' วินาทีที่แล้ว';
}
/*
หากความต่างของเวลาปัจจุบันกับ $timestamp น้อยกว่า 1 ชั่วโมง
*/
if ($diff < 3600) {
return (int)($diff / 60) . ' นาทีที่แล้ว';
}
/*
หากความต่างของเวลาปัจจุบันกับ $timestamp น้อยกว่า 1 วัน
*/
if ($diff < 86400) {
return (int)($diff / 3600) . ' ชั่วโมงที่แล้ว';
}
/*
หากความต่างของเวลาปัจจุบันกับ $timestamp น้อยกว่าจำนวนวันที่กำหนดไว้
ในตัวแปร $daysBeforeFullDate ที่เราจะใช้เป็นตัวบอกว่า
ควรจะแสดงวันที่เต็มเมื่อช่วงห่างเกินกี่วัน
*/
if ($diff < 86400 * $daysBeforeFullDate) {
return (int)($diff / 86400) . ' วันที่แล้ว';
}
/*
หากช่วงห่างไม่อยู่ในเงื่อนไขข้างต้นเลย ให้แสดงวันที่เต็ม
*/
return thai_datetime($timestamp);
}
/*
หากมีการเชื่อมต่อฐานข้อมูล ให้ทำการตัดการเชื่อมต่อเสีย
*/
if (isset($mysqli)) {
/*
ใช้ @ operator ด้วย เพื่อป้องกัน PHP Warning หากก่อนหน้านี้ทำการเชื่อมต่อฐานข้อมูลไม่สำเร็จ
*/
@$mysqli->close();
}
/*
กำหนดค่า default ให้กับตัวแปร $HIGHLIGHT_ID
ซึ่งจะถูกใช้ในไฟล์ inc/index.inc.php (และอื่นๆ ในอนาคต)
*/
$HIGHLIGHT_ID = isset($_GET['highlight_id'])
? $_GET['highlight_id']
: null;
/*
กำหนดค่า default ให้กับตัวแปร $TITLE หากไม่ได้กำหนดไว้ก่อนหน้านี้
*/
if (!isset($TITLE)) {
$TITLE = 'Webboard Workshop';
}
/*
กำหนดตัวแปร $PARENT_FILENAME ให้เป็นชื่อไฟล์ที่ผู้ใช้เรียก
โดยตรวจจาก $_SERVER['SCRIPT_FILENAME'] ซึ่งจะมีค่าเป็นชื่อไฟล์ PHP ที่ผู้ใช้เรียก
เช่น C:\xampp\htdocs\workshop-webboard\index.php
แต่เนื่องจากเราต้องการเพียงส่วนท้ายสุด คือ index.php เราจึงใช้ฟังก์ชั่น pathinfo()
ดึงข้อมูลส่วนนี้ออกมา ซึ่งปกติ pathinfo() จะคืนค่าออกมาเป็น array รายละเอียดของชื่อไฟล์
แต่ถ้าเรากำหนด argument ตัวที่สอง ก็จะดึงเฉพาะส่วนออกมาได้
ซึ่ง PATHINFO_BASENAME หมายถึง ให้เอาเฉพาะชื่อไฟล์และนามสกุลมา
*/
$PARENT_FILENAME = pathinfo($_SERVER['SCRIPT_FILENAME'], PATHINFO_BASENAME);
?>
<!DOCTYPE html>
<html>
<head>
<title><?php
/*
ก่อน echo ค่าของตัวแปรใดใดก็ตามที่ไม่แน่ใจว่าค่าของมันจะเป็นอะไรกันแน่ ออกมาเป็นส่วนหนึ่งของ HTML
และไม่ต้องการให้ค่าเหล่านั้นมีความหมายพิเศษ เช่น เราอยากแสดงผลคำว่า '<div>'
แต่หากเรา echo มันออกมาตรงๆ browser ก็จะมองว่ามันเป็น tag <div> ไม่ใช่ข้อความ '<div>'
ดังนั้นเราจึงจำเป็นต้อง escape ตัวอักษรพิเศษ < > & " '
ที่อาจจะมีอยู่ในค่าของตัวแปรให้เป็น html entity เสียก่อน ด้วยฟังก์ชั่น htmlspecialchars()
เช่น <div> ก็จะกลายเป็น <div>
*/
echo htmlspecialchars($TITLE, ENT_QUOTES, 'UTF-8');
?></title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap-theme.min.css">
<link rel="stylesheet" href="css/workshop-webboard.css">
</head>
<body>
<div class="container">
<div class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="index.php">
Webboard Workshop
</a>
</div>
<ul class="nav navbar-nav">
<li class="<?php
/*
ตรวจว่าขณะนี้ผู้ใช้อยู่ที่หน้าแรกหรือไม่
*/
if ($PARENT_FILENAME === 'index.php') {
/*
ถ้าใช่ ก็ให้เพิ่ม class 'active' เข้าไปใน <li> นี้
เพื่อเน้นว่าในขณะนี้ ผู้ใช้อยู่ที่หน้านี้
*/
echo 'active';
}
?>">
<a href="index.php">
<span class="glyphicon glyphicon-home"></span>
หน้าแรก
</a>
</li>
<li class="<?php
/*
ทำการตรวจสอบเมนูอื่นเช่นเดียวกัน
*/
if ($PARENT_FILENAME === 'post.php') {
echo 'active';
}
?>">
<a href="post.php">
<span class="glyphicon glyphicon-pencil"></span>
ตั้งกระทู้ใหม่
</a>
</li>
<?php
/*
สำหรับเมนูนี้ ถ้าไม่ได้อยู่ที่ view.php ก็จะไม่แสดงผลเลย
*/
if ($PARENT_FILENAME === 'view.php'):
?>
<li class="active">
<a href="#">
<span class="glyphicon glyphicon-eye-open"></span>
<?php
echo htmlspecialchars($TITLE, ENT_QUOTES, 'UTF-8');
?>
</a>
</li>
<?php
endif;
?>
</ul>
</div>
</div>
<?php
/*
หากมีตัวแปร $FATAL_ERROR ถูกกำหนดไว้ก่อนหน้า
แสดงว่ามี error ที่ทำให้ไม่สามารถแสดงผลข้อมูลหน้านั้นได้อย่างถูกต้อง
เช่น ไม่สามารถเชื่อมต่อฐานข้อมูลได้ หรือผู้ใช้ส่ง id ของกระทู้ที่ไม่มีอยู่จริงมาให้
เราก็จะแสดงผลข้อความที่กำหนดไว้ใน $FATAL_ERROR
*/
if (isset($FATAL_ERROR)):
?>
<div class="alert alert-danger">
<?php
echo htmlspecialchars($FATAL_ERROR, ENT_QUOTES, 'UTF-8');
?>
</div>
<?php
/*
นอกนั้นจะ require ไฟล์ที่กำหนดไว้ในตัวแปร $PAGE_TEMPLATE
ซึ่งจะตรวจสอบด้วย isset() ก่อนว่ามีตัวแปรนี้กำหนดไว้หรือไม่
ถ้าไม่ได้กำหนด ก็จะไม่ require ไฟล์ใดใด และแสดงหน้าเปล่าๆ ที่มีแค่ส่วน navigation
*/
elseif (isset($PAGE_TEMPLATE)):
/*
และไม่ได้ตรวจสอบว่าไฟล์ตามค่าของ $PAGE_TEMPLATE นั้นมีอยู่จริงหรือไม่
หากไม่มีอยู่จริงก็จะเกิด PHP fatal error และจบการทำงาน
เพราะ require เป็นคำสั่งที่ต่างจาก include (ซึ่งไม่ได้ใช้ในตัวอย่างนี้)
จะจบการทำงานทันทีหากหาไฟล์ที่กำหนดไม่เจอ
*/
require $PAGE_TEMPLATE;
endif;
?>
</div>
<?php
/*
ตรวจสอบว่ามี key 'REQUEST_TIME_FLOAT' อยู่ใน array $_SERVER หรือไม่
ซึ่ง $_SERVER['REQUEST_TIME_FLOAT'] จะเป็นเวลาที่ PHP เริ่มต้นทำงาน
เป็นหน่วย microsecond (1/1000000 วินาที)
และมีให้ใช้ตั้งแต่ PHP 5.4 เราจึงต้องตรวจสอบการมีอยู่ของมันก่อนใช้งาน
หากมีตัวแปรนี้อยู่ เราจะแสดงผลเวลาการทำงานของ request นี้ ว่าใช้เวลาไปเท่าไหร่
โดยเอาค่าจาก microtime(true) ที่จะ return microsecond ปัจจุบันกลับมา
ไปลบกับ $_SERVER['REQUEST_TIME_FLOAT'] ก็จะได้เวลาที่ request นี้ใช้
และจัดรูปแบบให้เป็นทศนิยม 4 ตำแหน่งด้วย number_format()
การจับเวลาเป็นวิธีหนึ่งที่จะบอกเราได้ว่า เราเขียนโปรแกรมได้อย่างมีประสิทธิภาพหรือไม่ในด้านความเร็ว
*/
if (isset($_SERVER['REQUEST_TIME_FLOAT'])):
?>
<div class="text-center">
<span class="label label-info">Time: <?php
echo number_format(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], 4);
?>s</span>
</div>
<?php
endif;
?>
</body>
</html>
<?php
/*
จบการทำงานเสมอ
ดังนั้น ณ จุดใดก็ตามที่มีการ require หรือ include ไฟล์นี้ ก็มั่นใจได้ว่าจะจบการทำงานแน่นอน
*/
exit;
inc/index.inc.php
<div class="panel panel-default">
<div class="panel-heading">
<h4>Title</h4>
</div>
<div class="panel-body">
Annoucement
</div>
<table class="table table-condensed table-bordered table-striped table-hover">
<thead>
<tr>
<th class="text-center width-15">กระทู้โดย</th>
<th class="text-center width-60">หัวข้อ</th>
<th class="text-center text-info width-10">
<span class="glyphicon glyphicon-comment" title="จำนวนความเห็น"></span>
</th>
<th class="text-center text-info width-15">ความเห็นล่าสุดโดย</th>
</tr>
</head>
<tbody>
<?php
/********** เริ่ม LOOP แสดงกระทู้ **********/
foreach ($ITEMS as $item):
?>
<tr class="<?php
/*
เพิ่ม class 'success' เข้าไปใน <tr> นี้ หาก id ของกระทู้ ตรงกับ $_GET['highlight_id']
ซึ่งจะถูกส่งมาจาก post.php (ดู post.php)
*/
if ($item['id'] === $HIGHLIGHT_ID) {
echo 'success';
}
?>">
<td>
<strong>
<?php
echo htmlspecialchars($item['name'], ENT_QUOTES, 'UTF-8');
?>
</strong>
<br>
<small class="text-muted" title="<?php
/*
แปลงวันที่ให้เป็นภาษาไทยด้วยฟังก์ชั่น thai_datetime() ที่กำหนดไว้ใน inc/main.inc.php
โดยใส่ไว้ใน attribute title เพื่อให้แสดงขึ้นมาเมื่อผู้ใช้เอาเมาส์ไปชี้
*/
echo thai_datetime($item['created']);
?>">
<?php
/*
แปลงวันที่ให้เป็นช่วงห่างของเวลาภาษาไทยด้วยฟังก์ชั่น thai_time()
ที่กำหนดไว้ใน inc/main.inc.php
*/
echo thai_time($item['created']);
?>
</small>
</td>
<td>
<a href="view.php?topic_id=<?php echo $item['id']; ?>">
<?php
echo htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
?>
</a>
</td>
<td class="text-center text-info">
<?php
echo $item['num_comments'];
?>
</td>
<td>
<?php
/*
หาก $item['last_commented_name'] ไม่ใช่ค่าว่าง นั่นหมายถึงกระทู้นี้มีผู้แสดงความเห็น
ก็ให้แสดงชื่อผู้แสดงความเห็น
*/
if ($item['last_commented_name'] !== ''):
?>
<strong class="text-info">
<?php
echo htmlspecialchars($item['last_commented_name'], ENT_QUOTES, 'UTF-8');
?>
</strong>
<br>
<small class="text-muted" title="<?php
echo thai_datetime($item['last_commented']);
?>">
<?php
echo thai_time($item['last_commented']);
?>
</small>
<?php
endif;
?>
</td>
</tr>
<?php
endforeach;
/********** จบ LOOP แสดงกระทู้ **********/
?>
</tbody>
</table>
<?php
/*
หากจำนวนหน้ามากกว่า 1 เราจะสร้าง pagination
*/
if ($NUM_PAGES > 1):
?>
<div class="panel-footer text-center">
<ul class="pagination">
<?php
/********** เริ่ม LOOP แสดงหน้าของ pagination **********/
/*
โดยให้ $i เริ่มจาก 1 ไปถึงจำนวนหน้าซึ่งคือ $NUM_PAGES
และหาก $i เท่ากับ $PAGE ที่เป็นหมายเลขหน้าปัจจุบัน
เราก็จะเพิ่ม class 'active' เข้าไปใน <li> เพื่อให้เน้นว่าเป็นหน้าปัจจุบัน
ใน href ของ <a> จะกำหนด query string ได้แก่
page เพื่อส่งต่อไปเป็นค่าใน array $_GET ใน index.php
ซึ่งค่าเหล่านี้จะทำให้ index.php รู้ว่าจะต้อง SELECT ข้อมูลจากตาราง topic
โดยเริ่มจาก offset ใด
*/
for ($page = 1; $page <= $NUM_PAGES; ++$page):
?>
<li class="<?php
if ($page === $PAGE) {
echo 'active';
}
?>">
<a href="index.php?page=<?php echo $page; ?>">
<?php echo $page; ?>
</a>
</li>
<?php
endfor;
/********** จบ LOOP แสดงหน้าของ pagination **********/
?>
</ul>
</div>
<?php
endif;
?>
</div>
inc/post.inc.php
<?php
/********** เริ่ม FORM ตั้งกระทู้ใหม่ **********/
/*
โดย form นี้จะใช้ method POST ในการส่งข้อมูลไปยัง post.php
ข้อมูลที่จะส่งให้กับ post.php ก็ได้แก่
title เป็น input type=text
description เป็น textarea
และ name เป็น input type=text
*/
?>
<form action="post.php" method="post" class="form-horizontal panel panel-default">
<div class="panel-heading">
<h4>
<span class="glyphicon glyphicon-pencil"></span>
ตั้งกระทู้ใหม่
</h4>
</div>
<div class="panel-body">
<?php
/*
แสดง errors (ถ้ามี)
ดูคำอธิบายใน inc/form_errors.inc.php
*/
require 'inc/form_errors.inc.php';
?>
<div class="form-group <?php
/*
ถ้ามี key ชื่อ 'title' อยู่ใน array $FORM_ERRORS
ให้เพิ่ม class 'has-error' เข้าไปใน <div> นี้
*/
if (isset($FORM_ERRORS['title'])) {
echo 'has-error';
}
?>">
<label for="titleInput" class="col-sm-2 control-label">*หัวข้อ</label>
<div class="col-sm-10">
<input
type="text"
id="titleInput"
name="title"
value="<?php
echo htmlspecialchars($DATA['title'], ENT_QUOTES, 'UTF-8');
?>"
placeholder="หัวข้อ"
spellcheck="false"
class="form-control"
>
</div>
</div>
<div class="form-group <?php
/*
ถ้ามี key ชื่อ 'description' อยู่ใน array $FORM_ERRORS
ให้เพิ่ม class 'has-error' เข้าไปใน <div> นี้
*/
if (isset($FORM_ERRORS['description'])) {
echo 'has-error';
}
?>">
<label for="descriptionTextarea" class="col-sm-2 control-label">*รายละเอียด</label>
<div class="col-sm-10">
<textarea
id="descriptionTextarea"
name="description"
rows="10"
placeholder="รายละเอียด"
spellcheck="false"
class="form-control"
><?php
echo htmlspecialchars($DATA['description'], ENT_QUOTES, 'UTF-8');
?></textarea>
</div>
</div>
<div class="form-group <?php
/*
ถ้ามี key ชื่อ 'name' อยู่ใน array $FORM_ERRORS
ให้เพิ่ม class 'has-error' เข้าไปใน <div> นี้
*/
if (isset($FORM_ERRORS['name'])) {
echo 'has-error';
}
?>">
<label for="nameInput" class="col-sm-2 control-label">*ชื่อ</label>
<div class="col-sm-4">
<input
type="text"
id="nameInput"
name="name"
value="<?php
echo htmlspecialchars($DATA['name'], ENT_QUOTES, 'UTF-8');
?>"
placeholder="ชื่อ"
spellcheck="false"
class="form-control"
>
</div>
</div>
<hr>
<div class="form-group">
<div class="col-sm-4 col-sm-offset-4">
<button type="submit" class="btn btn-primary btn-block">
ตั้งกระทู้
</button>
</div>
</div>
</div>
</form>
<?php
/********** จบ FORM แสดงความเห็น **********/
inc/view.inc.php
<?php
/********** เริ่ม LOOP แสดงกระทู้และความเห็น **********/
/*
เนื่องจากเราได้รวมกระทู้และความเห็นเข้าเป็น array เดียวกัน (ดู view.php)
จึงทำให้ไม่ต้องเขียนโค้ด HTML ซ้ำซ้อน
เพียงแค่ตรวจว่า $no เป็น 0 หรือไม่ ก็จะทราบว่า $item นั้นเป็นกระทู้
*/
foreach ($ITEMS as $no => $item):
/*
กำหนดตัวแปรเพื่อบอกว่า $item ปัจจุบันเป็นกระทู้หรือความเห็น
*/
$isTopic = $no === 0;
/*
ตรวจเงื่อนไขว่า $no เป็น 1 หรือไม่ หากมันเป็น 1 แสดงว่าขณะนี้ $item คือความเห็นแรก
ดังนั้นเราจะสร้าง <div id="comments"></div> เพื่อให้ browser scroll มายังจุดนี้
เมื่อมี hash tag #comments อยู่ใน URL
*/
if ($no === 1):
?>
<div id="comments"></div>
<?php
/*
หากจำนวนหน้ามากกว่า 1 เราจะสร้าง pagination
*/
if ($NUM_PAGES > 1):
/*
เนื่องจากเราจะแสดง pagination ทั้งก่อนแสดงความเห็น และหลังแสดงความเห็น
จึงใช้ ob_start() เพื่อเก็บ output ที่ได้ไปใช้ในภายหลัง โดยไม่ต้องเขียนโค้ดซ้ำซ้อน
*/
ob_start();
?>
<div class="text-center">
<ul class="pagination">
<?php
/********** เริ่ม LOOP แสดงหน้าของ pagination **********/
/*
โดยให้ $i เริ่มจาก 1 ไปถึงจำนวนหน้าซึ่งคือ $NUM_PAGES
และหาก $i เท่ากับ $PAGE ที่เป็นหมายเลขหน้าปัจจุบัน
เราก็จะเพิ่ม class 'active' เข้าไปใน <li> เพื่อให้เน้นว่าเป็นหน้าปัจจุบัน
ใน href ของ <a> จะกำหนด query string ได้แก่
topic_id และ page เพื่อส่งต่อไปเป็นค่าใน array $_GET ใน view.php
ซึ่งค่าเหล่านี้จะทำให้ view.php รู้ว่าจะแสดงกระทู้ id อะไร
และรู้ว่าจะต้อง SELECT ข้อมูลจากตาราง comment โดยเริ่มจาก offset ใด
และกำหนด hash tag #comments เพื่อให้ browser scroll ไปที่ความเห็นแรก
*/
for ($i = 1; $i <= $NUM_PAGES; ++$i):
?>
<li class="<?php if ($i === $PAGE) { echo 'active'; } ?>">
<a href="view.php?topic_id=<?php
echo $TOPIC_ID;
?>&page=<?php
echo $i;
?>#comments">
<?php echo $i; ?>
</a>
</li>
<?php
endfor;
/********** จบ LOOP แสดงหน้าของ pagination **********/
?>
</ul>
</div>
<?php
/*
เก็บ output ที่อยู่ใน output buffer หมดหลังเรียกใช้ ob_start()
เข้ามาไว้ในตัวแปร $PAGINATION พร้อมกับแสดงผล และปิด output buffering
*/
$PAGINATION = ob_get_flush();
endif;
endif;
?>
<div id="<?php
/*
หาก $item ไม่ใช่กระทู้
กำหนด id ให้กับ <div> เพื่อให้ browser scroll มายังจุดนี้
เมื่อมี hash tag #comment-<id ของความเห็น> อยู่ใน URL
*/
if (!$isTopic) {
echo "comment-{$item['id']}";
}
?>" class="panel <?php
/*
หาก $item เป็นกระทู้ ให้ใช้ class 'panel-info' เพื่อเน้นสีว่าเป็นกระทู้
นอกนั้นใช้ 'panel-default'
*/
echo $isTopic
? 'panel-info'
: 'panel-default';
?>">
<?php
/*
หาก $item เป็นกระทู้ เราจะแสดงหัวข้อกระทู้ด้วย
*/
if ($isTopic):
?>
<div class="panel-heading">
<h4>
<span class="badge pull-right">
<span class="glyphicon glyphicon-eye-open"></span> <?php
/*
แสดงจำนวนการเข้าชมและจำนวนความเห็น
โดยใช้ number_format() เพื่อใส่ , เข้าไปในตัวเลขให้ดูสวยงาม
เช่น 1234 จะเป็น 1,234
*/
echo number_format($item['num_views']);
?> <span class="glyphicon glyphicon-comment"></span> <?php
echo number_format($item['num_comments']);
?>
</span>
<?php
echo htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
?>
</h4>
</div>
<?php
/*
หาก $item เป็นความเห็น
*/
else:
?>
<span class="badge">
<?php
/*
แสดงเลขลำดับความเห็น
*/
echo $START_OFFSET + $no;
?>
</span>
<?php
endif;
?>
<div class="panel-body">
<?php
/*
แสดงรายละเอียดของกระทู้หรือข้อความของความเห็น
โดยใช้ nl2br() เพื่อเปลี่ยน newline (\n) ให้เป็น tag <br>
*/
echo nl2br(htmlspecialchars($item['description'], ENT_QUOTES, 'UTF-8'));
?>
</div>
<div class="panel-footer">
<small class="text-muted">โดย:</small>
<strong class="text-info"><?php
/*
แสดงชื่อ
*/
echo htmlspecialchars($item['name'], ENT_QUOTES, 'UTF-8');
?></strong>
<small class="text-muted">เมื่อ:</small>
<span class="text-info" title="<?php
/*
แปลงวันที่ให้เป็นภาษาไทยด้วยฟังก์ชั่น thai_datetime() ที่กำหนดไว้ใน inc/main.inc.php
โดยใส่ไว้ใน attribute title เพื่อให้แสดงขึ้นมาเมื่อผู้ใช้เอาเมาส์ไปชี้
*/
echo thai_datetime($item['created']);
?>"><?php
/*
แปลงวันที่ให้เป็นช่วงห่างของเวลาภาษาไทยด้วยฟังก์ชั่น thai_time()
ที่กำหนดไว้ใน inc/main.inc.php
*/
echo thai_time($item['created']);
?></span>
<small class="text-muted">จาก:</small>
<span class="text-warning"><?php
/*
แสดง IP
*/
echo $item['ip'];
?></span>
</div>
</div>
<?php
endforeach;
/********** จบ LOOP แสดงกระทู้และความเห็น **********/
/*
ตรวจสอบว่ามีตัวแปร $PAGINATION ถูกกำหนดค่าไว้หรือไม่
ถ้ามี ก็ให้แสดงผล pagination อีกครั้ง
*/
if (isset($PAGINATION)) {
echo $PAGINATION;
}
/********** เริ่ม FORM แสดงความเห็น **********/
/*
โดย form นี้จะใช้ method POST ในการส่งข้อมูลไปยัง view.php
จะเห็นว่าใน action ของ form มี hash tag #comment-form อยู่ด้วย
ใช้เพื่อให้ browser scroll มาจุดนี้เมื่อมี error เกิดขึ้น เช่น เมื่อไม่ได้ระบุ 'ชื่อ'
ข้อมูลที่จะส่งให้กับ view.php ก็ได้แก่
topic_id เป็น hidden input ซึ่งจะไม่แสดงผลให้ผู้ใช้เห็น
description เป็น textarea
และ name เป็น input type=text
*/
?>
<form action="view.php#comment-form" method="post" id="comment-form" class="form-horizontal panel panel-default">
<input type="hidden" name="topic_id" value="<?php echo $TOPIC_ID; ?>">
<div class="panel-heading">
<h4>
<span class="glyphicon glyphicon-comment"></span>
แสดงความเห็น
</h4>
</div>
<div class="panel-body">
<?php
/*
แสดง errors (ถ้ามี)
ดูคำอธิบายใน inc/form_errors.inc.php
*/
require 'inc/form_errors.inc.php';
?>
<div class="form-group <?php
/*
ถ้ามี key ชื่อ 'description' อยู่ใน array $FORM_ERRORS
ให้เพิ่ม class 'has-error' เข้าไปใน <div> นี้
*/
if (isset($FORM_ERRORS['description'])) {
echo 'has-error';
}
?>">
<label for="descriptionTextarea" class="col-sm-2 control-label">*ข้อความ</label>
<div class="col-sm-10">
<textarea
id="descriptionTextarea"
name="description"
rows="10"
placeholder="ข้อความ"
spellcheck="false"
class="form-control"
><?php
echo htmlspecialchars($DATA['description'], ENT_QUOTES, 'UTF-8');
?></textarea>
</div>
</div>
<div class="form-group <?php
/*
ถ้ามี key ชื่อ 'name' อยู่ใน array $FORM_ERRORS
ให้เพิ่ม class 'has-error' เข้าไปใน <div> นี้
*/
if (isset($FORM_ERRORS['name'])) {
echo 'has-error';
}
?>">
<label for="nameInput" class="col-sm-2 control-label">*ชื่อ</label>
<div class="col-sm-4">
<input
type="text"
id="nameInput"
name="name"
value="<?php
echo htmlspecialchars($DATA['name'], ENT_QUOTES, 'UTF-8');
?>"
placeholder="ชื่อ"
spellcheck="false"
class="form-control"
>
</div>
</div>
<hr>
<div class="form-group">
<div class="col-sm-4 col-sm-offset-4">
<button type="submit" class="btn btn-primary btn-block">
แสดงความเห็น
</button>
</div>
</div>
</div>
</form>
<?php
/********** จบ FORM แสดงความเห็น **********/
inc/form_errors.inc.php
<?php
/********** เริ่มการแสดงผลข้อผิดพลาดที่เกิดจากความไม่ถูกต้องของข้อมูลจาก FORM **********/
/*
โดยตรวจสอบจากตัวแปร $FORM_ERRORS ว่าได้ถูกกำหนดค่าไว้แล้วหรือยัง
ซึ่งตัวแปร $FORM_ERRORS นี้จะมีชนิดเป็น array
ประกอบไปด้วย key ของฟิลด์ที่เกิดความผิดพลาด
และมี value เป็น error message ที่เราได้กำหนดไว้ก่อนหน้านี้ (ใน post.php และ view.php)
*/
if (isset($FORM_ERRORS)):
?>
<div class="alert alert-danger">
<ul class="list-unstyled">
<?php
/********** เริ่ม LOOP แสดงผล errors **********/
/*
วนลูปทุก value ใน array $FORM_ERRORS
โดยกำหนดแต่ละ value ให้อยู่ในตัวแปร $message
*/
foreach ($FORM_ERRORS as $message):
?>
<li>
<span class="glyphicon glyphicon-exclamation-sign"></span>
<?php
echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
?>
</li>
<?php
endforeach;
/********** จบ LOOP แสดงผล errors **********/
?>
</ul>
</div>
<?php
endif;
/********** จบการแสดงผลข้อผิดพลาดที่เกิดจากความไม่ถูกต้องของข้อมูลจาก FORM **********/