瀏覽代碼

- Moving all human-facing strings to L10n class

- Better time and date formatting
- Cancel link on edit
- Form styling
master
Rocketsoup 3 年之前
父節點
當前提交
5fc17fdd07
共有 2 個文件被更改,包括 352 次插入102 次删除
  1. 308
    98
      htdocs/index.php
  2. 44
    4
      htdocs/journal.css

+ 308
- 98
htdocs/index.php 查看文件

58
 /// For detecting database migrations.
58
 /// For detecting database migrations.
59
 define('SCHEMA_VERSION', '20230107');
59
 define('SCHEMA_VERSION', '20230107');
60
 define('RECENT_POSTS_PER_PAGE', 50);
60
 define('RECENT_POSTS_PER_PAGE', 50);
61
-define('SESSION_COOKIE_TTL_SECONDS', 14 * 24 * 60 * 60); // 14 days
61
+define('SECOND', 1);
62
+define('MINUTE', 60 * SECOND);
63
+define('HOUR', 60 * MINUTE);
64
+define('DAY', 24 * HOUR);
65
+define('SESSION_COOKIE_TTL_SECONDS', 14 * DAY);
62
 define('SESSION_COOKIE_NAME', 'journal_token');
66
 define('SESSION_COOKIE_NAME', 'journal_token');
63
 /// If a new post matches an existing post created less than this many seconds
67
 /// If a new post matches an existing post created less than this many seconds
64
 /// ago, the new duplicate post will be ignored.
68
 /// ago, the new duplicate post will be ignored.
94
 	// error_log($message, 0);
98
 	// error_log($message, 0);
95
 }
99
 }
96
 
100
 
101
+// -- Localization ----------------------------------------
102
+
103
+class L10n {
104
+	// General
105
+	public const page_title = "Microjournal";
106
+	public const mobile_app_icon_title = "μJournal";
107
+
108
+	// Nav menu
109
+	public const hamburger_menu_symbol = "☰";
110
+	public const menu_search = "Search";
111
+	public const menu_log_out = "Log out";
112
+
113
+	// Create post form
114
+	public const post_form_placeholder = "Your journal post here…";
115
+	public const post_form_submit_post = "Post";
116
+	public const post_form_submit_update = "Update";
117
+	public const post_form_cancel = "Cancel";
118
+
119
+	// Previous posts
120
+	public const posts_previous_page = "Previous";
121
+	public const posts_next_page = "Next";
122
+	public static function post_created(int $timestamp): string {
123
+		$date = self::date_string($timestamp);
124
+		$date_full = self::date_string($timestamp, full: true);
125
+		$date_iso = self::date_string($timestamp, iso: true);
126
+		return "Posted <time datetime=\"{$date_iso}\" title=\"{$date_full}\">{$date}</time>";
127
+	}
128
+	public static function post_updated(int $timestamp): string {
129
+		$date = self::date_string($timestamp);
130
+		$date_full = self::date_string($timestamp, full: true);
131
+		$date_iso = self::date_string($timestamp, iso: true);
132
+		return "(updated <time datetime=\"{$date_iso}\" title=\"{$date_full}\">{$date}</time>)";
133
+	}
134
+	public const post_menu = "actions";
135
+	public const post_menu_edit = "Edit";
136
+	public const post_menu_delete = "Delete";
137
+
138
+	// Search form
139
+	public const search_submit = "Search";
140
+	public const search_cancel = "Cancel";
141
+
142
+	// Login form
143
+	public const login_username_label = "Username:";
144
+	public const login_password_label = "Password:";
145
+	public const login_submit = "Log in";
146
+
147
+	// Create account form
148
+	public const create_prompt = "Journal is not setup. Please create a " .
149
+		"username and password to secure your data.";
150
+	public const create_username_label = "Username:";
151
+	public const create_password_label = "Password:";
152
+	public const create_time_zone_label = "Time zone:";
153
+	public const create_submit = "Create account";
154
+
155
+	// Error messages
156
+	public const error_login_incorrect = "Login incorrect.";
157
+	public const error_bad_sql_datatype = "Bad datatype in sqlite statement.";
158
+	public const error_not_configured = "Journal not configured. Open " .
159
+		"index.php in an editor and configure the variables at the top of " .
160
+		"the file.";
161
+	public const error_database_not_found = "Database file cannot be found. " .
162
+		"Check configuration at the top of index.php.";
163
+	public static function error_database_not_writable(
164
+		string $user, string $group): string {
165
+		return "Database file exists but is not writable by web server " .
166
+			"user '{$user}' (user group '{$group}'). Check file permissions.";
167
+	}
168
+	public const error_no_schema_version = "No schema version in database. " .
169
+		"Corrupted?";
170
+	public static function error_unexpected_schema_version(
171
+		string $schema_version): string {
172
+		return "Unexpected schema version {$schema_version}.";
173
+	}
174
+	public const error_database_web_accessible = "Journal database is " .
175
+		"accessible from the web! Either alter your .htaccess permissions " .
176
+		"or rename the database with a UUID filename.";
177
+	public static function error_parameter_required(string $name): string {
178
+		return "Parameter {$name} is required.";
179
+	}
180
+	public static function error_parameter_empty(string $name): string {
181
+		return "Parameter {$name} cannot be empty.";
182
+	}
183
+	public static function error_parameter_integer(string $name): string {
184
+		return "Parameter {$name} must be integer.";
185
+	}
186
+	public const error_invalid_username = "Invalid username. Can be letters, " .
187
+		"numbers, underscores, and dashes, or an email address.";
188
+	public const error_sign_in_to_post = "Please sign in to post.";
189
+	public const error_sign_in_to_edit = "Please sign in to edit.";
190
+
191
+	public static function date_string(int $timestamp, bool $full=false, bool $iso=false): string {
192
+		static $fmtTime = null;
193
+		static $fmtDayOfWeek = null;
194
+		static $fmtMonthDay = null;
195
+		static $fmtLong = null;
196
+		static $fmtFull = null;
197
+		static $fmtIso = null;
198
+		$locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
199
+		$timezone = User::$current->timezone;
200
+		if (!$fmtTime) {
201
+			$fmtTime = IntlDateFormatter::create(
202
+				locale: $locale_code,
203
+				dateType: IntlDateFormatter::NONE,
204
+				timeType: IntlDateFormatter::MEDIUM,
205
+				timezone: $timezone);
206
+			$fmtDayOfWeek = IntlDateFormatter::create(
207
+				locale: $locale_code,
208
+				dateType: IntlDateFormatter::FULL,
209
+				timeType: IntlDateFormatter::NONE,
210
+				timezone: $timezone,
211
+				pattern: "cccc");
212
+			$fmtMonthDay = IntlDateFormatter::create(
213
+				locale: $locale_code,
214
+				dateType: IntlDateFormatter::MEDIUM,
215
+				timeType: IntlDateFormatter::NONE,
216
+				timezone: $timezone,
217
+				pattern: "MMMM d");
218
+			$fmtLong = IntlDateFormatter::create(
219
+				locale: $locale_code,
220
+				dateType: IntlDateFormatter::MEDIUM,
221
+				timeType: IntlDateFormatter::MEDIUM,
222
+				timezone: $timezone);
223
+			$fmtFull = IntlDateFormatter::create(
224
+				locale: $locale_code,
225
+				dateType: IntlDateFormatter::FULL,
226
+				timeType: IntlDateFormatter::FULL,
227
+				timezone: $timezone);
228
+			$fmtIso = IntlDateFormatter::create(
229
+				locale: $locale_code,
230
+				dateType: IntlDateFormatter::FULL,
231
+				timeType: IntlDateFormatter::FULL,
232
+				timezone: $timezone,
233
+				pattern: "yyyy-MM-dd'T'HH:mm:ssxxx");
234
+		}
235
+		$now = time();
236
+		if ($full) {
237
+			return $fmtFull->format($timestamp);
238
+		}
239
+		if ($iso) {
240
+			return $fmtIso->format($timestamp);
241
+		}
242
+		if ($timestamp > $now) { // future
243
+			return $fmtLong->format($timestamp);
244
+		}
245
+		if ($now - $timestamp < 12 * HOUR) {
246
+			return $fmtTime->format($timestamp);
247
+		}
248
+		if ($now - $timestamp < 4 * DAY) {
249
+			return $fmtDayOfWeek->format($timestamp) . ', ' . $time->format($timestamp);
250
+		}
251
+		if ($now - $timestamp < 180 * DAY) {
252
+			return $fmtMonthDay->format($timestamp) . ', ' . $time->format($timestamp);
253
+		}
254
+		return $fmtLong->format($timestamp);
255
+	}
256
+}
257
+
97
 // -- Data classes ----------------------------------------
258
 // -- Data classes ----------------------------------------
98
 
259
 
99
 /// Represents a journal post.
260
 /// Represents a journal post.
134
 			trace("Same post already created recently. Skipping.");
295
 			trace("Same post already created recently. Skipping.");
135
 			return;
296
 			return;
136
 		}
297
 		}
137
-		$sql = 'INSERT INTO posts (body, author_id, created) VALUES (:body, :author_id, :created);';
138
-		$args = array(':body' => $body, ':author_id' => $author_id, ':created' => $created);
298
+		$sql = 'INSERT INTO posts (body, author_id, created) VALUES ' .
299
+			'(:body, :author_id, :created);';
300
+		$args = array(
301
+			':body' => $body,
302
+			':author_id' => $author_id,
303
+			':created' => $created,
304
+		);
139
 		Database::query($sql, $args);
305
 		Database::query($sql, $args);
140
 	}
306
 	}
141
 
307
 
149
 			string $body,
315
 			string $body,
150
 			int $author_id,
316
 			int $author_id,
151
 			int $created): bool {
317
 			int $created): bool {
152
-		$sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id AND ABS(created - :created) < :seconds LIMIT 1;';
318
+		$sql = 'SELECT * FROM posts WHERE body=:body AND author_id=:author_id ' .
319
+			'AND ABS(created - :created) < :seconds LIMIT 1;';
153
 		$args = array(
320
 		$args = array(
154
 			':body' => $body,
321
 			':body' => $body,
155
 			':author_id' => $author_id,
322
 			':author_id' => $author_id,
165
 	/// @param ?int $before_time  If provided, only posts older than this Unix
332
 	/// @param ?int $before_time  If provided, only posts older than this Unix
166
 	///                           timestamp are returned.
333
 	///                           timestamp are returned.
167
 	/// @return array             Three-element tuple - 0: array of Posts,
334
 	/// @return array             Three-element tuple - 0: array of Posts,
168
-	///                           1: relative URL to show previous page, or null if none
169
-	///                           2: relative URL to show next page, or null if none
335
+	///                           1: relative URL to show previous page, or null
336
+	///                           2: relative URL to show next page, or null
170
 	public static function get_posts(
337
 	public static function get_posts(
171
 			int $user_id,
338
 			int $user_id,
172
 			int $count = RECENT_POSTS_PER_PAGE,
339
 			int $count = RECENT_POSTS_PER_PAGE,
203
 		if ($before_time) {
370
 		if ($before_time) {
204
 			// We're paged forward. Check if there are newer posts.
371
 			// We're paged forward. Check if there are newer posts.
205
 			$sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id AND ' .
372
 			$sql = 'SELECT rowid, * FROM posts WHERE author_id=:author_id AND ' .
206
-				'created >= :before_time ' . $search_where . ' ORDER BY created ASC LIMIT :count;';
373
+				'created >= :before_time ' . $search_where .
374
+				' ORDER BY created ASC LIMIT :count;';
207
 			// Reusing same $args
375
 			// Reusing same $args
208
 			$newer_posts = Database::query_objects('Post', $sql, $args);
376
 			$newer_posts = Database::query_objects('Post', $sql, $args);
209
 			if (sizeof($newer_posts) > $count) {
377
 			if (sizeof($newer_posts) > $count) {
325
 		$user = self::get_by_username($username);
493
 		$user = self::get_by_username($username);
326
 		if ($user === null) {
494
 		if ($user === null) {
327
 			trace("Login failed. No such user {$username}.");
495
 			trace("Login failed. No such user {$username}.");
328
-			fatal_error("Login incorrect.");
496
+			fatal_error(L10n::error_login_incorrect);
329
 			return null;
497
 			return null;
330
 		}
498
 		}
331
 		if (!password_verify($password, $user->password_hash)) {
499
 		if (!password_verify($password, $user->password_hash)) {
332
 			trace("Login failed. Bad password for {$username}.");
500
 			trace("Login failed. Bad password for {$username}.");
333
-			fatal_error("Login incorrect.");
501
+			fatal_error(L10n::error_login_incorrect);
334
 			return null;
502
 			return null;
335
 		}
503
 		}
336
 		trace("Login succeeded for {$username}.");
504
 		trace("Login succeeded for {$username}.");
342
 
510
 
343
 	/// Updates self::$current from $_SESSION.
511
 	/// Updates self::$current from $_SESSION.
344
 	public static function update_current(): void {
512
 	public static function update_current(): void {
345
-		if (self::any_exist() && array_key_exists(SESSION_KEY_USER_ID, $_SESSION) && is_int($_SESSION[SESSION_KEY_USER_ID])) {
513
+		if (self::any_exist() &&
514
+			array_key_exists(SESSION_KEY_USER_ID, $_SESSION) &&
515
+			is_int($_SESSION[SESSION_KEY_USER_ID])) {
346
 			$id = $_SESSION[SESSION_KEY_USER_ID];
516
 			$id = $_SESSION[SESSION_KEY_USER_ID];
347
 			self::$current = User::get_by_id($id);
517
 			self::$current = User::get_by_id($id);
348
 		} else {
518
 		} else {
414
 		$this->updated = time();
584
 		$this->updated = time();
415
 		$secure = str_starts_with(BASE_URL, 'https:');
585
 		$secure = str_starts_with(BASE_URL, 'https:');
416
 		$expires = intval(time()) + SESSION_COOKIE_TTL_SECONDS;
586
 		$expires = intval(time()) + SESSION_COOKIE_TTL_SECONDS;
417
-		trace('Updating cookie to ' . localized_date_string($expires));
587
+		trace('Updating cookie to ' . L10n::date_string($expires));
418
 		setcookie(SESSION_COOKIE_NAME, $this->token, $expires, path: '/',
588
 		setcookie(SESSION_COOKIE_NAME, $this->token, $expires, path: '/',
419
 			secure: $secure, httponly: true);
589
 			secure: $secure, httponly: true);
420
 		if ($update_table) {
590
 		if ($update_table) {
421
 			$sql = 'UPDATE sessions SET updated=:updated WHERE rowid=:rowid;';
591
 			$sql = 'UPDATE sessions SET updated=:updated WHERE rowid=:rowid;';
422
-			$args = array(':updated' => $this->updated, ':rowid' => $this->rowid);
592
+			$args = array(
593
+				':updated' => $this->updated,
594
+				':rowid' => $this->rowid,
595
+			);
423
 			Database::query($sql, $args);
596
 			Database::query($sql, $args);
424
 		}
597
 		}
425
 	}
598
 	}
447
 		$args = array(':rowid' => $this->rowid);
620
 		$args = array(':rowid' => $this->rowid);
448
 		Database::query($sql, $args);
621
 		Database::query($sql, $args);
449
 		$secure = str_starts_with(BASE_URL, 'https:');
622
 		$secure = str_starts_with(BASE_URL, 'https:');
450
-		setcookie(SESSION_COOKIE_NAME, '', 1, path: '/', secure: $secure, httponly: true);
623
+		setcookie(SESSION_COOKIE_NAME, '', 1, path: '/', secure: $secure,
624
+			httponly: true);
451
 		unset($_COOKIE[SESSION_COOKIE_NAME]);
625
 		unset($_COOKIE[SESSION_COOKIE_NAME]);
452
 	}
626
 	}
453
 }
627
 }
492
 	/// @param $params     Map of SQL placeholders to values.
666
 	/// @param $params     Map of SQL placeholders to values.
493
 	/// @return ?object    Object of given class from first result row, or null
667
 	/// @return ?object    Object of given class from first result row, or null
494
 	///                    if no rows matched.
668
 	///                    if no rows matched.
495
-	public static function query_object(string $classname, string $sql, array $params = array()): ?object {
669
+	public static function query_object(
670
+		string $classname,
671
+		string $sql,
672
+		array $params = array()): ?object {
496
 		$objs = self::query_objects($classname, $sql, $params);
673
 		$objs = self::query_objects($classname, $sql, $params);
497
 		return (sizeof($objs) > 0) ? $objs[0] : null;
674
 		return (sizeof($objs) > 0) ? $objs[0] : null;
498
 	}
675
 	}
505
 	/// @param $sql        Query to execute.
682
 	/// @param $sql        Query to execute.
506
 	/// @param $params     Map of SQL placeholders to values.
683
 	/// @param $params     Map of SQL placeholders to values.
507
 	/// @return array      Array of records of the given class.
684
 	/// @return array      Array of records of the given class.
508
-	public static function query_objects(string $classname, string $sql, array $params = array()): array {
685
+	public static function query_objects(
686
+		string $classname,
687
+		string $sql,
688
+		array $params = array()): array {
509
 		$rows = self::query($sql, $params);
689
 		$rows = self::query($sql, $params);
510
 		$objs = array();
690
 		$objs = array();
511
 		if ($rows !== false) {
691
 		if ($rows !== false) {
524
 	/// @param $params  Map of SQL placeholders to values.
704
 	/// @param $params  Map of SQL placeholders to values.
525
 	/// @return array   Array of arrays. The inner arrays contain both indexed
705
 	/// @return array   Array of arrays. The inner arrays contain both indexed
526
 	///                 and named column values.
706
 	///                 and named column values.
527
-	public static function query(string $sql, array $params = array()): bool|array {
707
+	public static function query(
708
+		string $sql,
709
+		array $params = array()): bool|array {
528
 		$db = new SQLite3(DB_PATH);
710
 		$db = new SQLite3(DB_PATH);
529
 		trace('SQL: ' . $sql);
711
 		trace('SQL: ' . $sql);
530
 		$stmt = $db->prepare($sql);
712
 		$stmt = $db->prepare($sql);
563
 			case 'NULL':
745
 			case 'NULL':
564
 				return SQLITE3_NULL;
746
 				return SQLITE3_NULL;
565
 			default:
747
 			default:
566
-				fatal_error("Bad datatype in sqlite statement");
748
+				fatal_error(L10n::error_bad_sql_datatype);
567
 		}
749
 		}
568
 	}
750
 	}
569
 
751
 
617
 	private static function set_config_value(string $name, $value): void {
799
 	private static function set_config_value(string $name, $value): void {
618
 		$args = array(':name' => $name, ':value' => $value);
800
 		$args = array(':name' => $name, ':value' => $value);
619
 		Database::query('UPDATE config SET value=:value WHERE name=:name;', $args);
801
 		Database::query('UPDATE config SET value=:value WHERE name=:name;', $args);
620
-		Database::query('INSERT OR IGNORE INTO config (name, value) VALUES (:name, :value);', $args);
802
+		Database::query('INSERT OR IGNORE INTO config (name, value) ' .
803
+			'VALUES (:name, :value);', $args);
621
 	}
804
 	}
622
 }
805
 }
623
 
806
 
627
 function check_setup(): void {
810
 function check_setup(): void {
628
 	// Check user-configurable variables to make sure they aren't default values.
811
 	// Check user-configurable variables to make sure they aren't default values.
629
 	if (str_contains(BASE_URL, 'example.com') || DB_PATH == '/path/to/journal.db') {
812
 	if (str_contains(BASE_URL, 'example.com') || DB_PATH == '/path/to/journal.db') {
630
-		fatal_error("Journal not configured. Open index.php in an editor and configure the variables at the top of the file.");
813
+		fatal_error(L10n::error_not_configured);
631
 	}
814
 	}
632
 
815
 
633
 	if (!Database::exists()) {
816
 	if (!Database::exists()) {
634
-		fatal_error("Database file cannot be found. Check configuration at the top of index.php.");
817
+		fatal_error(L10n::error_database_not_found);
635
 	}
818
 	}
636
 
819
 
637
 	if (!Database::is_writable()) {
820
 	if (!Database::is_writable()) {
638
 		$user = trim(shell_exec('whoami'));
821
 		$user = trim(shell_exec('whoami'));
639
 		$group = trim(shell_exec('id -gn'));
822
 		$group = trim(shell_exec('id -gn'));
640
-		fatal_error("Database file exists but is not writable by web server user '{$user}' (user group '{$group}'). Check file permissions.");
823
+		fatal_error(L10n::error_database_not_writable($user, $group));
641
 	}
824
 	}
642
 
825
 
643
 	$schema_version = Config::get_schema_version();
826
 	$schema_version = Config::get_schema_version();
644
 	if ($schema_version === null) {
827
 	if ($schema_version === null) {
645
-		fatal_error("No schema version in database. Corrupted?");
828
+		fatal_error(L10n::error_no_schema_version);
646
 	}
829
 	}
647
 
830
 
648
 	if ($schema_version != SCHEMA_VERSION) {
831
 	if ($schema_version != SCHEMA_VERSION) {
649
 		// TODO: If schema changes, migration paths will go here. For now just fail.
832
 		// TODO: If schema changes, migration paths will go here. For now just fail.
650
-		fatal_error("Unexpected schema version $schema_version.");
833
+		fatal_error(L10n::error_unexpected_schema_version($schema_version));
651
 	}
834
 	}
652
 
835
 
653
 	if (Config::get_is_configured()) {
836
 	if (Config::get_is_configured()) {
658
 	// If using default database name, make sure it's not accessible from the
841
 	// If using default database name, make sure it's not accessible from the
659
 	// web. It'd be far too easy to access.
842
 	// web. It'd be far too easy to access.
660
 	if (Database::is_accessible_by_web()) {
843
 	if (Database::is_accessible_by_web()) {
661
-		fatal_error("Journal database is accessible from the web! Either alter your .htaccess permissions or rename the database with a UUID filename.");
844
+		fatal_error(L10n::error_database_web_accessible);
662
 	}
845
 	}
663
 
846
 
664
 	// Everything looks good!
847
 	// Everything looks good!
705
 function validate(array $source, string $name, int $type, bool $required = true) {
888
 function validate(array $source, string $name, int $type, bool $required = true) {
706
 	if (!array_key_exists($name, $source)) {
889
 	if (!array_key_exists($name, $source)) {
707
 		if ($required) {
890
 		if ($required) {
708
-			fatal_error("Parameter {$name} is required.");
891
+			fatal_error(L10n::error_parameter_required($name));
709
 		}
892
 		}
710
 		return null;
893
 		return null;
711
 	}
894
 	}
715
 	}
898
 	}
716
 	if (strlen($val) == 0) {
899
 	if (strlen($val) == 0) {
717
 		if ($type & INPUT_TYPE_NONEMPTY) {
900
 		if ($type & INPUT_TYPE_NONEMPTY) {
718
-			fatal_error("Parameter {$name} cannot be empty.");
901
+			fatal_error(L10n::error_parameter_empty($name));
719
 		}
902
 		}
720
 	}
903
 	}
721
 	if (($type & INPUT_TYPE_INT) == INPUT_TYPE_INT) {
904
 	if (($type & INPUT_TYPE_INT) == INPUT_TYPE_INT) {
722
 		if (is_numeric($val)) return intval($val);
905
 		if (is_numeric($val)) return intval($val);
723
-		fatal_error("Parameter {$name} must be integer.");
906
+		fatal_error(L10n::error_parameter_integer($name));
724
 	}
907
 	}
725
 	if (($type & INPUT_TYPE_STRING) == INPUT_TYPE_STRING) {
908
 	if (($type & INPUT_TYPE_STRING) == INPUT_TYPE_STRING) {
726
 		return $val;
909
 		return $val;
727
 	}
910
 	}
728
 	if (($type & INPUT_TYPE_USERNAME) == INPUT_TYPE_USERNAME) {
911
 	if (($type & INPUT_TYPE_USERNAME) == INPUT_TYPE_USERNAME) {
729
 		if (preg_match('/[a-zA-Z0-9_@+-]+/', $val)) return $val;
912
 		if (preg_match('/[a-zA-Z0-9_@+-]+/', $val)) return $val;
730
-		fatal_error("Invalid username. Can be letters, numbers, underscores, and dashes, or an email address.");
913
+		fatal_error(L10n::error_invalid_username);
731
 	}
914
 	}
732
 	return null;
915
 	return null;
733
 }
916
 }
734
 
917
 
735
-function localized_date_string(int $timestamp): string {
736
-	$locale_code = Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) ?: 'en-US';
737
-	$timezone = User::$current->timezone;
738
-	$formatter = IntlDateFormatter::create(
739
-		$locale_code,
740
-		dateType: IntlDateFormatter::MEDIUM,
741
-		timeType: IntlDateFormatter::MEDIUM,
742
-		timezone: $timezone,
743
-		calendar: IntlDateFormatter::GREGORIAN);
744
-	return $formatter->format($timestamp);
745
-}
746
-
747
 // -- HTML renderers --------------------------------------
918
 // -- HTML renderers --------------------------------------
748
 
919
 
749
 class HTMLPage {
920
 class HTMLPage {
750
 	public static function render_page_start(): void {
921
 	public static function render_page_start(): void {
922
+		$str_title = L10n::page_title;
923
+		$str_app_icon_title = L10n::mobile_app_icon_title;
751
 		print(<<<HTML
924
 		print(<<<HTML
752
 			<!DOCTYPE html>
925
 			<!DOCTYPE html>
753
 			<html>
926
 			<html>
754
 				<head>
927
 				<head>
755
-					<title>Microjournal</title>
928
+					<title>{$str_title}</title>
756
 					<meta charset="UTF-8" />
929
 					<meta charset="UTF-8" />
757
 					<link rel="stylesheet" type="text/css" href="journal.css" />
930
 					<link rel="stylesheet" type="text/css" href="journal.css" />
758
 					<link rel="apple-touch-icon" sizes="58x58" href="apple-touch-icon-58.png" />
931
 					<link rel="apple-touch-icon" sizes="58x58" href="apple-touch-icon-58.png" />
764
 					<link rel="apple-touch-icon" sizes="152x152" href="apple-touch-icon-152.png" />
937
 					<link rel="apple-touch-icon" sizes="152x152" href="apple-touch-icon-152.png" />
765
 					<link rel="apple-touch-icon" sizes="167x167" href="apple-touch-icon-167.png" />
938
 					<link rel="apple-touch-icon" sizes="167x167" href="apple-touch-icon-167.png" />
766
 					<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180.png" />
939
 					<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180.png" />
767
-					<meta name="apple-mobile-web-app-title" content="μJournal" />
940
+					<meta name="apple-mobile-web-app-title" content="{$str_app_icon_title}" />
768
 					<meta name="apple-mobile-web-app-capable" content="yes" />
941
 					<meta name="apple-mobile-web-app-capable" content="yes" />
769
 					<meta name="theme-color" content="#fff" media="(prefers-color-scheme: light)" />
942
 					<meta name="theme-color" content="#fff" media="(prefers-color-scheme: light)" />
770
 					<meta name="theme-color" content="#444" media="(prefers-color-scheme: dark)" />
943
 					<meta name="theme-color" content="#444" media="(prefers-color-scheme: dark)" />
771
-					<meta name="viewport" content="user-scalable=no,width=device-width,viewport-fit=cover" />
944
+					<meta name="viewport" content="user-scalable=no, width=device-width, viewport-fit=cover, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no" />
772
 					<link rel="icon" sizes="16x16" type="image/png" href="favicon-16.png" />
945
 					<link rel="icon" sizes="16x16" type="image/png" href="favicon-16.png" />
773
 					<link rel="icon" sizes="32x32" type="image/png" href="favicon-32.png" />
946
 					<link rel="icon" sizes="32x32" type="image/png" href="favicon-32.png" />
774
 				</head>
947
 				</head>
775
 				<body>
948
 				<body>
776
 					<nav>
949
 					<nav>
777
 						<div class="top-nav">
950
 						<div class="top-nav">
778
-							<span class="title">Microjournal</span>
951
+							<span class="title">{$str_title}</span>
779
 			HTML
952
 			HTML
780
 		);
953
 		);
781
 		if (User::$current) {
954
 		if (User::$current) {
955
+			$str_hamburger = L10n::hamburger_menu_symbol;
956
+			$str_search = L10n::menu_search;
957
+			$str_log_out = L10n::menu_log_out;
782
 			print(<<<HTML
958
 			print(<<<HTML
783
 							<div class="menu-container">
959
 							<div class="menu-container">
784
 								<details class="menu">
960
 								<details class="menu">
785
-									<summary class="no-indicator menu-button"><span></span></summary>
961
+									<summary class="no-indicator menu-button"><span>{$str_hamburger}</span></summary>
786
 
962
 
787
 									<ul>
963
 									<ul>
788
-										<li><a href="?search">Search</a></li>
964
+										<li><a href="?search">{$str_search}</a></li>
789
 										<li class="menu-divider"></li>
965
 										<li class="menu-divider"></li>
790
-										<li class="logout-item destructive"><a href="?logout">Log out</a></li>
966
+										<li class="logout-item destructive"><a href="?logout">{$str_log_out}</a></li>
791
 									</ul>
967
 									</ul>
792
 								</details>
968
 								</details>
793
 							</div>
969
 							</div>
825
 
1001
 
826
 	public static function render_post_form(): void {
1002
 	public static function render_post_form(): void {
827
 		$action = 'post';
1003
 		$action = 'post';
828
-		$verb = 'Post';
1004
+		$str_submit = L10n::post_form_submit_post;
829
 		$body = '';
1005
 		$body = '';
830
 		if ($edit_id = validate($_GET, 'edit', INPUT_TYPE_INT, required: false)) {
1006
 		if ($edit_id = validate($_GET, 'edit', INPUT_TYPE_INT, required: false)) {
831
 			if ($post = Post::get_by_id($edit_id)) {
1007
 			if ($post = Post::get_by_id($edit_id)) {
832
 				$body = $post->body;
1008
 				$body = $post->body;
833
 				$action = 'edit';
1009
 				$action = 'edit';
834
-				$verb = 'Update';
1010
+				$str_submit = L10n::post_form_submit_update;
835
 			} else {
1011
 			} else {
836
 				unset($edit_id);
1012
 				unset($edit_id);
837
 			}
1013
 			}
840
 			unset($_SESSION[SESSION_KEY_POST_BODY]);
1016
 			unset($_SESSION[SESSION_KEY_POST_BODY]);
841
 		}
1017
 		}
842
 		$body_html = htmlentities($body);
1018
 		$body_html = htmlentities($body);
1019
+		$str_placeholder = L10n::post_form_placeholder;
843
 		print(<<<HTML
1020
 		print(<<<HTML
844
 			<form id="post-form" method="POST">
1021
 			<form id="post-form" method="POST">
845
-				<div class="text-container"><textarea name="body" placeholder="Your journal post here…">$body_html</textarea></div>
1022
+				<div class="text-container"><textarea name="body" placeholder="$str_placeholder">$body_html</textarea></div>
846
 				<input type="hidden" name="action" value="{$action}" />
1023
 				<input type="hidden" name="action" value="{$action}" />
847
 			HTML
1024
 			HTML
848
 		);
1025
 		);
849
 		if ($edit_id) {
1026
 		if ($edit_id) {
850
 			print("<input type=\"hidden\" name=\"edit_id\" value=\"{$edit_id}\" />");
1027
 			print("<input type=\"hidden\" name=\"edit_id\" value=\"{$edit_id}\" />");
851
 		}
1028
 		}
1029
+		print("<div class=\"submit-post form-buttons\">");
1030
+		if ($edit_id) {
1031
+			$url = BASE_URL;
1032
+			$str_cancel = L10n::post_form_cancel;
1033
+			print("<a class=\"cancel-edit\" href=\"{$url}\">{$str_cancel}</a> ");
1034
+		}
852
 		print(<<<HTML
1035
 		print(<<<HTML
853
-				<div class="submit-post"><input type="submit" value="{$verb}" /></div>
1036
+				<input type="submit" value="{$str_submit}" /></div>
854
 			</form>
1037
 			</form>
855
 			HTML
1038
 			HTML
856
 		);
1039
 		);
862
 		[ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, query: $query, before_time: $before);
1045
 		[ $posts, $prev_url, $next_url ] = Post::get_posts(User::$current->user_id, query: $query, before_time: $before);
863
 		print("<div class=\"post-container\">\n");
1046
 		print("<div class=\"post-container\">\n");
864
 		if ($prev_url !== null) {
1047
 		if ($prev_url !== null) {
865
-			print("<div class=\"previous\"><a href=\"{$prev_url}\">Previous</a></div>\n");
1048
+			$str_prev = L10n::posts_previous_page;
1049
+			print("<div class=\"previous\"><a href=\"{$prev_url}\">{$str_prev}</a></div>\n");
866
 		}
1050
 		}
867
 		foreach ($posts as $post) {
1051
 		foreach ($posts as $post) {
868
 			self::render_post($post);
1052
 			self::render_post($post);
869
 		}
1053
 		}
870
 		if ($next_url !== null) {
1054
 		if ($next_url !== null) {
871
-			print("<div class=\"next\"><a href=\"{$next_url}\">Next</a></div>\n");
1055
+			$str_next = L10n::posts_next_page;
1056
+			print("<div class=\"next\"><a href=\"{$next_url}\">{$str_next}</a></div>\n");
872
 		}
1057
 		}
873
 		print("</div>\n");
1058
 		print("</div>\n");
874
 	}
1059
 	}
893
 	public static function render_post(Post $post): void {
1078
 	public static function render_post(Post $post): void {
894
 		$body_html = MARKDOWN_ENABLED ? Markdown::markdown_to_html($post->body) :
1079
 		$body_html = MARKDOWN_ENABLED ? Markdown::markdown_to_html($post->body) :
895
 			self::post_body_html($post->body);
1080
 			self::post_body_html($post->body);
896
-		$date = localized_date_string($post->created);
1081
+		$str_posted = L10n::post_created($post->created);
897
 		print(<<<HTML
1082
 		print(<<<HTML
898
 			<div class="post">
1083
 			<div class="post">
899
 				<article>
1084
 				<article>
901
 					<div class="post-footer">
1086
 					<div class="post-footer">
902
 						<footer class="secondary-text">
1087
 						<footer class="secondary-text">
903
 							<div class="post-date">
1088
 							<div class="post-date">
904
-								Posted {$date}
1089
+								{$str_posted}
905
 			HTML
1090
 			HTML
906
 		);
1091
 		);
907
 		if ($post->updated && $post->updated != $post->created) {
1092
 		if ($post->updated && $post->updated != $post->created) {
908
-			print('<br/>(updated ' . localized_date_string($post->updated) . ')');
1093
+			$str_updated = L10n::post_updated($post->updated);
1094
+			print("<br/>\n$str_updated");
909
 		}
1095
 		}
1096
+		$str_menu = L10n::post_menu;
1097
+		$str_edit = L10n::post_menu_edit;
1098
+		$str_delete = L10n::post_menu_delete;
910
 		print(<<<HTML
1099
 		print(<<<HTML
911
 							</div>
1100
 							</div>
912
 							<details class="post-actions menu">
1101
 							<details class="post-actions menu">
913
-								<summary class="no-indicator menu-button"><span>actions</span></summary>
1102
+								<summary class="no-indicator menu-button"><span>$str_menu</span></summary>
914
 
1103
 
915
 								<ul>
1104
 								<ul>
916
-									<li><a href="?edit={$post->post_id}">Edit</a></li>
1105
+									<li><a href="?edit={$post->post_id}">{$str_edit}</a></li>
917
 									<li class="menu-divider"></li>
1106
 									<li class="menu-divider"></li>
918
-									<li class="post-action-delete destructive"><a href="?delete={$post->post_id}">Delete</a></li>
1107
+									<li class="post-action-delete destructive"><a href="?delete={$post->post_id}">{$str_delete}</a></li>
919
 								</ul>
1108
 								</ul>
920
 							</details>
1109
 							</details>
921
 						</footer>
1110
 						</footer>
930
 		$q = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
1119
 		$q = validate($_GET, 'search', INPUT_TYPE_STRING | INPUT_TYPE_TRIMMED, required: false);
931
 		$q_html = htmlentities($q);
1120
 		$q_html = htmlentities($q);
932
 		$cancel = BASE_URL;
1121
 		$cancel = BASE_URL;
1122
+		$str_submit = L10n::search_submit;
1123
+		$str_cancel = L10n::search_cancel;
933
 		print(<<<HTML
1124
 		print(<<<HTML
934
-			<form id="search-form" method="GET">
935
-				<div>
936
-					<label for="search">Search:</label>
937
-					<input type="text" name="search" id="search" value="{$q_html}" autocapitalize="off" />
938
-					<input type="submit" value="Search" />
939
-					<a href="{$cancel}">Cancel</a>
940
-				</div>
941
-			</form>
1125
+			<div class="form-container">
1126
+				<form id="search-form" method="GET">
1127
+					<div>
1128
+						<input type="search" name="search" id="search" value="{$q_html}" size="40" autocapitalize="off" />
1129
+					</div>
1130
+					<div class="form-buttons">
1131
+						<a class="cancel-search" href="{$cancel}">{$str_cancel}</a>
1132
+						<input type="submit" value="{$str_submit}" />
1133
+					</div>
1134
+				</form>
1135
+			</div>
942
 			HTML
1136
 			HTML
943
 		);
1137
 		);
944
 	}
1138
 	}
945
 
1139
 
946
 	public static function render_sign_in_form(): void {
1140
 	public static function render_sign_in_form(): void {
1141
+		$str_username = L10n::login_username_label;
1142
+		$str_password = L10n::login_password_label;
1143
+		$str_submit = L10n::login_submit;
947
 		print(<<<HTML
1144
 		print(<<<HTML
948
-			<form id="signin-form" method="POST">
949
-				<div>
950
-					<label for="username">Username:</label>
951
-					<input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" />
952
-				</div>
953
-				<div>
954
-					<label for="password">Password:</label>
955
-					<input type="password" name="password" id="password" />
956
-				</div>
957
-				<input type="hidden" name="action" value="signin" />
958
-				<input type="submit" value="Sign in" />
959
-			</form>
1145
+			<div class="form-container">
1146
+				<form id="signin-form" method="POST">
1147
+					<div style="text-align: end;">
1148
+						<label for="username">{$str_username}</label>
1149
+						<input type="text" name="username" id="username" autocapitalize="off" autocorrect="off" />
1150
+					</div>
1151
+					<div style="text-align: end;">
1152
+						<label for="password">{$str_password}</label>
1153
+						<input type="password" name="password" id="password" />
1154
+					</div>
1155
+					<input type="hidden" name="action" value="signin" />
1156
+					<div class="form-buttons">
1157
+						<input type="submit" value="{$str_submit}" />
1158
+					</div>
1159
+				</form>
1160
+			</div>
960
 			HTML
1161
 			HTML
961
 		);
1162
 		);
962
 	}
1163
 	}
963
 
1164
 
964
 	public static function render_setup_form(): void {
1165
 	public static function render_setup_form(): void {
1166
+		$str_prompt = L10n::create_prompt;
1167
+		$str_username = L10n::create_username_label;
1168
+		$str_password = L10n::create_password_label;
1169
+		$str_time_zone = L10n::create_time_zone_label;
965
 		print(<<<HTML
1170
 		print(<<<HTML
966
-			<div class="important">Journal is not setup. Please create a username and password to secure your data.</div>
967
-			<form id="setup-form" method="POST">
968
-				<div>
969
-					<label for="username">Username:</label>
970
-					<input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" />
971
-				</div>
972
-				<div>
973
-					<label for="password">Password:</label>
974
-					<input type="password" id="password" name="password" />
975
-				</div>
976
-				<div>
977
-					<label for="timezone">Time zone:</label>
978
-					<select name="timezone" id="timezone">
1171
+			<div class="important">{$str_prompt}</div>
1172
+			<div class="form-container">
1173
+				<form id="setup-form" method="POST">
1174
+					<div style="text-align: end;">
1175
+						<label for="username">{$str_username}</label>
1176
+						<input type="text" id="username" name="username" autocapitalize="off" autocorrect="off" />
1177
+					</div>
1178
+					<div style="text-align: end;">
1179
+						<label for="password">{$str_password}</label>
1180
+						<input type="password" id="password" name="password" />
1181
+					</div>
1182
+					<div>
1183
+						<label for="timezone">{$str_time_zone}</label>
1184
+						<select name="timezone" id="timezone">
979
 			HTML
1185
 			HTML
980
 		);
1186
 		);
981
 		foreach (DateTimeZone::listIdentifiers() as $timezone) {
1187
 		foreach (DateTimeZone::listIdentifiers() as $timezone) {
985
 			}
1191
 			}
986
 			print(">" . htmlentities($timezone) . "</option>\n");
1192
 			print(">" . htmlentities($timezone) . "</option>\n");
987
 		}
1193
 		}
1194
+		$str_submit = L10n::create_submit;
988
 		print(<<<HTML
1195
 		print(<<<HTML
989
-					</select></div>
990
-				<input type="hidden" name="action" value="createaccount" />
991
-				<div><input type="submit" value="Create account" /></div>
992
-			</form>
1196
+						</select></div>
1197
+					<input type="hidden" name="action" value="createaccount" />
1198
+					<div class="form-buttons">
1199
+						<input type="submit" value="{$str_submit}" />
1200
+					</div>
1201
+				</form>
1202
+			</div>
993
 			HTML
1203
 			HTML
994
 		);
1204
 		);
995
 	}
1205
 	}
1249
 				if (!User::$current) {
1459
 				if (!User::$current) {
1250
 					// Not logged in. Save body for populating once they sign in.
1460
 					// Not logged in. Save body for populating once they sign in.
1251
 					$_SESSION[SESSION_KEY_POST_BODY] = $body;
1461
 					$_SESSION[SESSION_KEY_POST_BODY] = $body;
1252
-					fatal_error('Please sign in to post.');
1462
+					fatal_error(L10n::error_sign_in_to_post);
1253
 				}
1463
 				}
1254
 				Post::create($body, $author, $created);
1464
 				Post::create($body, $author, $created);
1255
 				break;
1465
 				break;
1258
 				$edit_id = validate($_POST, 'edit_id', INPUT_TYPE_INT);
1468
 				$edit_id = validate($_POST, 'edit_id', INPUT_TYPE_INT);
1259
 				if (!User::$current) {
1469
 				if (!User::$current) {
1260
 					// Not logged in. Save body for populating once they sign in.
1470
 					// Not logged in. Save body for populating once they sign in.
1261
-					fatal_error('Please sign in to edit.');
1471
+					fatal_error(L10n::error_sign_in_to_edit);
1262
 				}
1472
 				}
1263
 				Post::get_by_id($edit_id)?->update($body);
1473
 				Post::get_by_id($edit_id)?->update($body);
1264
 				break;
1474
 				break;

+ 44
- 4
htdocs/journal.css 查看文件

9
 	--menu-border: #ccc;
9
 	--menu-border: #ccc;
10
 
10
 
11
 	--highlight: #d83;
11
 	--highlight: #d83;
12
+	--highlight-contrast: #000;
12
 	--error-text: #d00;
13
 	--error-text: #d00;
13
 	--error-background: #fcc;
14
 	--error-background: #fcc;
14
 	--error-border: #400;
15
 	--error-border: #400;
17
 	--callout-border: #886;
18
 	--callout-border: #886;
18
 	--destructive: #e00;
19
 	--destructive: #e00;
19
 	--destructive-contrast: #fff;
20
 	--destructive-contrast: #fff;
21
+
22
+	--font-size: 12pt;
20
 }
23
 }
21
 
24
 
22
 @media(prefers-color-scheme: dark) {
25
 @media(prefers-color-scheme: dark) {
35
 :root, input, textarea {
38
 :root, input, textarea {
36
 	font-family: Garamond, Times New Roman, serif;
39
 	font-family: Garamond, Times New Roman, serif;
37
 	line-height: 1.25;
40
 	line-height: 1.25;
38
-	font-size: 12pt;
41
+	font-size: var(--font-size);
39
 }
42
 }
40
 body, textarea, input, select {
43
 body, textarea, input, select {
41
 	background-color: var(--page-background);
44
 	background-color: var(--page-background);
79
 }
82
 }
80
 details.menu li a:hover {
83
 details.menu li a:hover {
81
 	background-color: var(--highlight);
84
 	background-color: var(--highlight);
82
-	color: black;
85
+	color: var(--highlight-contrast);
83
 }
86
 }
84
 details.menu li {
87
 details.menu li {
85
 }
88
 }
114
 }
117
 }
115
 details[open] summary.menu-button > span:first-child {
118
 details[open] summary.menu-button > span:first-child {
116
 	background-color: var(--highlight);
119
 	background-color: var(--highlight);
117
-	color: white;
120
+	color: var(--highlight-contrast);
121
+}
122
+.form-container {
123
+	position: relative;
124
+	text-align: center;
125
+	margin-left: auto;
126
+	margin-right: auto;
127
+}
128
+.form-container form {
129
+	display: inline-block;
130
+	text-align: start;
118
 }
131
 }
119
 
132
 
120
 
133
 
188
 .submit-post {
201
 .submit-post {
189
 	text-align: right;
202
 	text-align: right;
190
 }
203
 }
204
+.cancel-edit {
205
+	display: inline-block;
206
+	padding-inline-end: 2em;
207
+}
208
+.form-buttons {
209
+	text-align: end;
210
+}
191
 input[type="submit"] {
211
 input[type="submit"] {
192
 	margin-top: 0.5em;
212
 	margin-top: 0.5em;
193
 	min-width: 15ch;
213
 	min-width: 15ch;
214
+	padding: 0.3em 1.5em;
215
+	color: var(--highlight);
216
+	border: 1px solid var(--highlight);
217
+	border-radius: 0.2em;
218
+	font-size: 1rem;
219
+	-webkit-appearance: none;
220
+}
221
+input[type="submit"]:hover {
222
+	background-color: var(--highlight);
223
+	color: var(--highlight-contrast);
224
+}
225
+
226
+/* Search */
227
+
228
+#search-form input[type="search"] {
229
+	width: 100%;
230
+}
231
+.cancel-search {
232
+	display: inline-block;
233
+	padding-inline-end: 1em;
194
 }
234
 }
195
 
235
 
196
 /* Posts */
236
 /* Posts */
239
 
279
 
240
 @media screen and (max-width: 450px) {
280
 @media screen and (max-width: 450px) {
241
 	:root {
281
 	:root {
242
-		font-size: 16pt;
282
+		--font-size: 16px;
243
 	}
283
 	}
244
 }
284
 }

Loading…
取消
儲存