Commit 49efb238b8

49efb238b879bce764d04dad99c7a169e80f93dd

parent: 53488151f2

Unsigned

cmc <hello@cleberg.net> · 2023-06-15 03:08 UTC

massive overhaul to implement proper MVC

Layout: unified · split

CODE_OF_CONDUCT.md +2 −2
@@ -2,7 +2,7 @@
2 2
3## Summary 3## Summary
4 4
5Be logical and act in a way that represents your values. The following values 5Be logical and act in a way that represents your values. The following values
6are inherent for this project: 6are inherent for this project:
7 7
8- Respect 8- Respect
@@ -11,5 +11,5 @@ are inherent for this project:
11 11
12## Reporting 12## Reporting
13 13
14If you find anything that goes against this code of conduct or is offensive, 14If you find anything that goes against this code of conduct or is offensive,
15please report it to the maintainers. 15please report it to the maintainers.
CONTRIBUTING.md +2 −2
@@ -10,6 +10,6 @@ This project really only has a few guidelines to follow:
10 10
11## Reporting Bugs 11## Reporting Bugs
12 12
13I don't use a report template, so just remember to give as much detail as 13I don't use a report template, so just remember to give as much detail as
14possible about the bug you're experiencing. If there's not enough detail 14possible about the bug you're experiencing. If there's not enough detail
15reported, emails will need to be exchanged before anyone can look into the bug. 15reported, emails will need to be exchanged before anyone can look into the bug.
README.md +18 −16
@@ -1,18 +1,18 @@
1# hn 1# hn
2 2
3[hn](https://hn.cleberg.net) is a simple front-end alternative for Hacker 3[hn](https://hn.cleberg.net) is a simple front-end alternative for Hacker
4News, focusing on privacy and simplicity. 4News, focusing on privacy and simplicity.
5 5
6## Getting Started 6## Getting Started
7 7
8These instructions will get you a copy of the project up and running on your 8These instructions will get you a copy of the project up and running on your
9local machine for development and testing purposes. See deployment for notes on 9local machine for development and testing purposes. See deployment for notes on
10how to deploy the project on a live system. 10how to deploy the project on a live system.
11 11
12### Prerequisites 12### Prerequisites
13 13
14- A web server (e.g., Nginx or Apache) 14- A web server (e.g., Nginx or Apache)
15- PHP 15- PHP (>= v8.0)
16- Optional: minify 16- Optional: minify
17 17
18### Installing 18### Installing
@@ -20,7 +20,7 @@ how to deploy the project on a live system.
20Install the dependencies, using the web server of your choice: 20Install the dependencies, using the web server of your choice:
21 21
22``` 22```
23sudo apt install nginx-full php minify 23sudo apt install nginx-full php php-cgi php-fpm minify
24``` 24```
25 25
26Clone the repo: 26Clone the repo:
@@ -38,11 +38,11 @@ minify -o static/styles.min.css static/styles.css
38 38
39## Deployment 39## Deployment
40 40
41Deployment is as easy as copying the code to your webroot. No special packages 41Deployment is as easy as copying the code to your webroot. No special packages
42or tools required. 42or tools required.
43 43
44To deploy, ensure you have a publicly-available web server and configure it to 44To deploy, ensure you have a publicly-available web server and configure it to
45fallback with all errors to the `index.php` file rather than returning a `404` 45fallback with all errors to the `index.php` file rather than returning a `404`
46error. 46error.
47 47
48For nginx, include the following snippet in your website's conf file: 48For nginx, include the following snippet in your website's conf file:
@@ -55,7 +55,7 @@ location / {
55} 55}
56``` 56```
57 57
58For Apache, you can include the following snippet in a `.htaccess` file within 58For Apache, you can include the following snippet in a `.htaccess` file within
59the directory you're serving the PHP file from: 59the directory you're serving the PHP file from:
60 60
61```conf 61```conf
@@ -66,12 +66,13 @@ FallbackResource /index.php
66 66
67* [PHP](https://www.php.net/) - The scripting language 67* [PHP](https://www.php.net/) - The scripting language
68* [HTML](https://html.spec.whatwg.org/multipage/) - The markup language 68* [HTML](https://html.spec.whatwg.org/multipage/) - The markup language
69* [minify](https://github.com/tdewolff/minify/tree/master/cmd/minify) - Used to 69* [minify](https://github.com/tdewolff/minify/tree/master/cmd/minify) - Used to
70minify CSS 70 minify CSS
71 71
72## Contributing 72## Contributing
73 73
74Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our code of 74Please read [CONTRIBUTING.md](./CONTRIBUTING.md) and
75[CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for details on our code of
75conduct, and the process for submitting pull requests to us. 76conduct, and the process for submitting pull requests to us.
76 77
77### TODO 78### TODO
@@ -79,9 +80,10 @@ conduct, and the process for submitting pull requests to us.
79A scratch pad of ideas that may be useful to implement: 80A scratch pad of ideas that may be useful to implement:
80 81
81- [x] Add minimal CSS. 82- [x] Add minimal CSS.
82- [~] Add functionality to view a user's profile. 83- [x] Add functionality to view a user's profile.
83- [ ] Add functionality to view item-specific page with comments. 84- [ ] Add functionality to view item-specific page with comments (`ConstructStoryDiscussion`).
84- [ ] Add functionality to load more items or paginate? 85- [ ] Add functionality to handle polls (`ConstructPoll` & `ConstructPollOpt`).
86- [ ] Add functionality to load more items or paginate.
85 87
86## Versioning 88## Versioning
87 89
@@ -93,5 +95,5 @@ This project currently doesn't use versioning. See the git log instead.
93 95
94## License 96## License
95 97
96This project is licensed under the Unlicense - see the 98This project is licensed under the Unlicense - see the
97[LICENSE.md](./LICENSE.md) file for details. 99[LICENSE.md](./LICENSE.md) file for details.
index.php +6 −232
@@ -1,236 +1,10 @@
1<?php 1<?php
2 2
3$full_domain = 'https://hn.cleberg.net'; 3require_once 'src/Controller/RouteController.php';
4$path = ltrim($_SERVER['REQUEST_URI'], '/');
5$elements = explode('/', $path);
6 4
7if (empty($elements[0])) { 5$GLOBALS['full_domain'] = 'https://hn.cleberg.net';
8 $html_output = get_stories( 6$GLOBALS['author_name'] = 'cmc';
9 'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"', 7$GLOBALS['site_title'] = 'hn';
10 'Top'
11 );
12 echo_html(
13 $GLOBALS['full_domain'],
14 'The top stories from Hacker News, proxied by hn.',
15 'hn',
16 $html_output
17 );
18} else {
19 switch (array_shift($elements)) {
20 case 'top':
21 $html_output = get_stories(
22 'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"',
23 'Top'
24 );
25 echo_html(
26 $GLOBALS['full_domain'],
27 'The top stories from Hacker News, proxied by hn.',
28 'hn',
29 $html_output
30 );
31 break;
32 8
33 case 'best': 9$route = new HN\Controllers\RouteController($_SERVER['REQUEST_URI']);
34 $html_output = get_stories( 10$route->routeUser();
35 'https://hacker-news.firebaseio.com/v0/beststories.json?limitToFirst=10&orderBy="$key"',
36 'Best'
37 );
38 echo_html(
39 $GLOBALS['full_domain'] . '/best/',
40 'The best 30 stories from Hacker News, proxied by hn.',
41 'hn ~ best',
42 $html_output
43 );
44 break;
45
46 case 'new':
47 $html_output = get_stories(
48 'https://hacker-news.firebaseio.com/v0/newstories.json?limitToFirst=10&orderBy="$key"',
49 'New'
50 );
51 echo_html(
52 $GLOBALS['full_domain'] . '/new/',
53 'The newest 30 stories from Hacker News, proxied by hn.',
54 'hn ~ new',
55 $html_output
56 );
57 break;
58
59 case 'ask':
60 $html_output = get_stories(
61 'https://hacker-news.firebaseio.com/v0/askstories.json?limitToFirst=10&orderBy="$key"',
62 'Ask'
63 );
64 echo_html(
65 $GLOBALS['full_domain'] . '/ask/',
66 'The latest 30 asks from Hacker News, proxied by hn.',
67 'hn ~ ask',
68 $html_output
69 );
70 break;
71
72 case 'show':
73 $html_output = get_stories(
74 'https://hacker-news.firebaseio.com/v0/showstories.json?limitToFirst=10&orderBy="$key"',
75 'Show'
76 );
77 echo_html(
78 $GLOBALS['full_domain'] . '/show/',
79 'The latest 30 show stories from Hacker News, proxied by hn.',
80 'hn ~ show',
81 $html_output
82 );
83 break;
84
85 case 'job':
86 $html_output = get_stories(
87 'https://hacker-news.firebaseio.com/v0/jobstories.json?limitToFirst=10&orderBy="$key"',
88 'Job'
89 );
90 echo_html(
91 $GLOBALS['full_domain'] . '/job/',
92 'The latest 30 job posts from Hacker News, proxied by hn.',
93 'hn ~ job',
94 $html_output
95 );
96 break;
97
98 case 'user':
99 $html_output = get_user(
100 'https://hacker-news.firebaseio.com/v0/user/' . $elements[0] . '.json',
101 'User Profile: ' . $elements[0]
102 );
103 echo_html(
104 $GLOBALS['full_domain'] . '/user/' . $elements[0],
105 'The Hacker News profile for ' . $elements[0] . ', proxied by hn.',
106 'hn',
107 $html_output
108 );
109 break;
110
111 default:
112 header('HTTP/1.1 404 Not Found');
113 }
114}
115
116/**
117 * Extract a set of stories from Hacker News API and format in HTML
118 *
119 * @access public
120 * @param string $api_url The API endpoint to use for extraction
121 * @param string $inline_title The <h1> title to use in the HTML
122 * @return string $html_output The formatted HTML result of stories from the API
123 * @author cmc <hello@cleberg.net>
124 */
125function get_stories(string $api_url, string $inline_title): string
126{
127 $response_raw = file_get_contents($api_url);
128 if ($response_raw == "null") {
129 return '<p>ERROR: Stories not found. API returned `null`.</p>';
130 } else {
131 $response = json_decode($response_raw, true);
132 }
133
134 $html_output = '<h1>' . $inline_title . '</h1>';
135
136 for ($i = 0; $i < count($response); $i++) {
137 $sub_url = 'https://hacker-news.firebaseio.com/v0/item/' . $response[$i] . '.json';
138 $sub_response_raw = file_get_contents($sub_url);
139 $sub_response = json_decode($sub_response_raw, true);
140
141 // TODO: Can this be converted to a heredoc string with variables?
142 $html = '<div><a href="' . $sub_response['url'] . '">' . $sub_response['title'] . '</a>';
143 $html .= '<p><time datetime="' . date('Y-m-d h:m:s', $sub_response['time']) . '">';
144 $html .= date('Y-m-d h:m:s', $sub_response['time']) . '</time> by <a';
145 $html .= ' href="/user/' . $sub_response['by'] . '">';
146 $html .= $sub_response['by'] . '</a> | ' . $sub_response['score'];
147 $html .= ' points</p></div>';
148 $html_output .= $html;
149 }
150
151 return $html_output;
152}
153
154/**
155 *Extract a user's profile from Hacker News API and format in HTML
156 *
157 * @access public
158 * @param string $api_url The API endpoint to use for extraction
159 * @param string $inline_title The <h1> title to use in the HTML
160 * @return string $html_output The formatted HTML result of stories from the API
161 * @author cmc <hello@cleberg.net>
162 */
163function get_user(string $api_url, string $inline_title): string
164{
165 $response_raw = file_get_contents($api_url);
166 if ($response_raw == "null") {
167 return '<p>ERROR: User not found.</p>';
168 } else {
169 $response = json_decode($response_raw, true);
170 }
171
172 // TODO: Can this be converted to a heredoc string with variables?
173 $html_output = '<h1>' . $inline_title . '</h1>';
174 $html_output .= '<p>About: ' . $response['about'] . '</p>';
175 $html_output .= '<p>Karma: ' . $response['karma'] . '</p>';
176 $html_output .= '<p>Created: <time datetime="' . date('Y-m-d h:m:s', $response['created']) . '>' . date('Y-m-d h:m:s', $response['created']) . '</time></p>';
177 $html_output .= '<p>Recently Submitted Posts:</p>';
178
179 $limit = count($response['submitted']) > 10 ? 10 : count($response['submitted']);
180 if (count($response['submitted']) > 0) {
181 for ($i = 0; $i < $limit; $i++) {
182 $sub_url = 'https://hacker-news.firebaseio.com/v0/item/' . $response['submitted'][$i] . '.json';
183 $sub_response_raw = file_get_contents($sub_url);
184 $sub_response = json_decode($sub_response_raw, true);
185
186 if ($sub_response['type'] == 'story' || $sub_response['type'] == 'job') {
187 $html = '<div><a href="' . $sub_response['url'] . '">' . $sub_response['title'] . '</a>';
188 $html .= '<p><time datetime="' . date('Y-m-d h:m:s', $sub_response['time']) . '">';
189 $html .= date('Y-m-d h:m:s', $sub_response['time']) . '</time> by <a';
190 $html .= ' href="/user/' . $sub_response['by'] . '">';
191 $html .= $sub_response['by'] . '</a> | ' . $sub_response['score'];
192 $html .= ' points</p></div>';
193 } elseif ($sub_response['type'] == 'poll') {
194 // TODO: Handle polls
195 $html = 'TODO: Add logic to handle polls here.';
196 } else {
197 // TODO: Add link to parent with $sub_response['parent']
198 $html = '<div><time datetime="' . date('Y-m-d h:m:s', $sub_response['time']);
199 $html .= '">' . date('Y-m-d h:m:s', $sub_response['time']);
200 $html .= '</time><br><p>' . $sub_response['text'] . '</p></div>';
201 }
202 $html_output .= $html;
203 }
204 } else {
205 $html_output .= '<p>User has no submissions.</p>';
206 }
207
208 return $html_output;
209}
210
211
212/**
213 * Send formatted HTML results to the user via a template
214 *
215 * @access public
216 * @param string $page_url Canonical URL for HTML header
217 * @param string $page_description Page description for HTML header
218 * @param string $page_title Page title for HTML header
219 * @param string $page_content Page content to display in <main>
220 * @author cmc <hello@cleberg.net>
221 */
222function echo_html(string $page_url, string $page_description, string $page_title, string $page_content)
223{
224 include_once 'src/View/class-template.php';
225
226 $template = new HN\View\Template(
227 $page_url,
228 $page_description,
229 $page_title,
230 $page_content
231 );
232
233 $template->echo_template('templates/template.html');
234}
235
236// EOF
src/Controller/FeedController.php added +47
@@ -0,0 +1,47 @@
1<?php
2
3namespace HN\Controllers;
4
5class FeedController
6{
7 /**
8 * @var string
9 */
10 private string $canonical_url;
11 /**
12 * @var string
13 */
14 private string $description;
15 /**
16 * @var string
17 */
18 private string $title;
19 /**
20 * @var string
21 */
22 private string $content;
23 /**
24 * @var false|string
25 */
26 private mixed $current_year;
27
28 public function __construct(string $canonical_url, string $description, string $title, string $content)
29 {
30 $this->canonical_url = $canonical_url;
31 $this->description = $description;
32 $this->title = $title;
33 $this->content = $content;
34 $this->current_year = date("Y");
35 }
36
37 /**
38 * Request template to be presented to the user
39 *
40 * @access public
41 * @author cmc <hello@cleberg.net>
42 */
43 public function render(): void
44 {
45 include_once 'src/View/BaseTemplate.php';
46 }
47}
src/Controller/RouteController.php added +171
@@ -0,0 +1,171 @@
1<?php
2
3namespace HN\Controllers;
4
5require_once 'src/Controller/FeedController.php';
6
7use function HN\Models\GetApiResults;
8use function HN\Models\ParseItem;
9use function HN\Models\ParseStories;
10use function HN\Models\ParseUser;
11
12class RouteController
13{
14 /**
15 * @var string
16 */
17 private string $request;
18
19 public function __construct(string $request)
20 {
21 $this->request = $request;
22 }
23
24 /**
25 * Route the user to the appropriate function, based on the URL
26 *
27 * @access public
28 * @return void No return type; send user to FeedController->render() or a 404 error
29 * @author cmc <hello@cleberg.net>
30 */
31 public function routeUser(): void
32 {
33 include_once 'src/Model/ApiService.php';
34 $path = ltrim($this->request, '/');
35 $elements = explode('/', $path);
36
37 switch (array_shift($elements)) {
38 case '':
39 case 'top':
40 $feed = new FeedController(
41 $GLOBALS['full_domain'],
42 'The top stories from Hacker News, proxied by hn.',
43 'hn',
44 ParseStories(
45 GetApiResults(
46 'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"'
47 ),
48 'Top'
49 )
50 );
51
52 $feed->render();
53 break;
54
55 case 'best':
56 $feed = new FeedController(
57 $GLOBALS['full_domain'] . '/Best/',
58 'The best stories from Hacker News, proxied by hn.',
59 'hn ~ best',
60 ParseStories(
61 GetApiResults(
62 'https://hacker-news.firebaseio.com/v0/beststories.json?limitToFirst=10&orderBy="$key"'
63 ),
64 'Best'
65 )
66 );
67
68 $feed->render();
69 break;
70
71 case 'new':
72 $feed = new FeedController(
73 $GLOBALS['full_domain'] . '/new/',
74 'The newest stories from Hacker News, proxied by hn.',
75 'hn ~ new',
76 ParseStories(
77 GetApiResults(
78 'https://hacker-news.firebaseio.com/v0/newstories.json?limitToFirst=10&orderBy="$key"'
79 ),
80 'New'
81 )
82 );
83
84 $feed->render();
85 break;
86
87 case 'ask':
88 $feed = new FeedController(
89 $GLOBALS['full_domain'] . '/ask/',
90 'The top asks from Hacker News, proxied by hn.',
91 'hn ~ ask',
92 ParseStories(
93 GetApiResults(
94 'https://hacker-news.firebaseio.com/v0/askstories.json?limitToFirst=10&orderBy="$key"'
95 ),
96 'Ask'
97 )
98 );
99
100 $feed->render();
101 break;
102
103 case 'show':
104 $feed = new FeedController(
105 $GLOBALS['full_domain'] . '/show/',
106 'The latest showcases from Hacker News, proxied by hn.',
107 'hn ~ show',
108 ParseStories(
109 GetApiResults(
110 'https://hacker-news.firebaseio.com/v0/showstories.json?limitToFirst=10&orderBy="$key"'
111 ),
112 'Show'
113 )
114 );
115
116 $feed->render();
117 break;
118
119 case 'job':
120 $feed = new FeedController(
121 $GLOBALS['full_domain'] . '/job/',
122 'The latest jobs from Hacker News, proxied by hn.',
123 'hn ~ jobs',
124 ParseStories(
125 GetApiResults(
126 'https://hacker-news.firebaseio.com/v0/jobstories.json?limitToFirst=10&orderBy="$key"'
127 ),
128 'Job'
129 )
130 );
131
132 $feed->render();
133 break;
134
135 case 'user':
136 $feed = new FeedController(
137 $GLOBALS['full_domain'] . '/user/' . $elements[0],
138 'The Hacker News profile for ' . $elements[0] . ', proxied by hn.',
139 'hn ~ ' . $elements[0],
140 ParseUser(
141 GetApiResults(
142 'https://hacker-news.firebaseio.com/v0/user/' . $elements[0] . '.json'
143 ),
144 'User: ' . $elements[0]
145 )
146 );
147
148 $feed->render();
149 break;
150
151 case 'item':
152 $feed = new FeedController(
153 $GLOBALS['full_domain'] . '/item/' . $elements[0],
154 'Hacker News story ' . $elements[0] . ', proxied by hn.',
155 'hn ~ ' . $elements[0],
156 ParseItem(
157 GetApiResults(
158 'https://hacker-news.firebaseio.com/v0/item/' . $elements[0] . '.json'
159 ),
160 'Item: ' . $elements[0]
161 )
162 );
163
164 $feed->render();
165 break;
166
167 default:
168 header('HTTP/1.1 404 Not Found');
169 }
170 }
171}\ No newline at end of file
src/Model/ApiService.php added +218
@@ -0,0 +1,218 @@
1<?php
2
3namespace HN\Models;
4
5/**
6 * Extract a set of stories from the Hacker News API
7 *
8 * @access public
9 * @param string $api_url The API endpoint to use for extraction
10 * @return mixed The API results formatted into an HTML section
11 * @author cmc <hello@cleberg.net>
12 */
13function GetApiResults(string $api_url): mixed
14{
15 $response = file_get_contents($api_url);
16 return json_decode($response, true);
17}
18
19/**
20 * Formats a given set of API results into an HTML section
21 *
22 * @access public
23 * @param mixed $api_results The decoded API results
24 * @param string $inline_title The <h1> title to use in the HTML
25 * @return string $html_output The formatted HTML result of stories from the API or the error message
26 * @author cmc <hello@cleberg.net>
27 */
28function ParseStories(mixed $api_results, string $inline_title): string
29{
30 if ($api_results == "null") {
31 return '<p>ERROR: Stories not found. API returned `null`.</p>';
32 } else {
33 $html_output = '<h1>' . $inline_title . '</h1>';
34 for ($i = 0; $i < count($api_results); $i++) {
35 $story_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results[$i] . '.json');
36 $html_output .= ConstructStory($story_api_results);
37 }
38
39 return $html_output;
40 }
41}
42
43/**
44 *Extract a user's profile from Hacker News API and format in HTML
45 *
46 * @access public
47 * @param mixed $api_results The decoded API results
48 * @param string $inline_title The <h1> title to use in the HTML
49 * @return string $html_output The formatted HTML result of stories from the API
50 * @author cmc <hello@cleberg.net>
51 */
52function ParseUser(mixed $api_results, string $inline_title): string
53{
54 if ($api_results == "null") {
55 return '<p>ERROR: User not found.</p>';
56 } else {
57 $about = $api_results['about'];
58 $karma = $api_results['karma'];
59 $created = date('Y-m-d h:m:s', $api_results['created']);
60
61 $html_output = <<<EOT
62 <div class="user-details">
63 <h1>$inline_title</h1>
64 <p>About: $about</p>
65 <p>Karma: $karma</p>
66 <p>Created: <time datetime="$created">$created</time></p>
67 <br>
68 <h2>Recently Submitted</h2>
69 </div>
70 EOT;
71
72 $limit = (count($api_results['submitted']) > 10) ? 10 : count($api_results['submitted']);
73 if (count($api_results['submitted']) > 0) {
74 for ($i = 0; $i < $limit; $i++) {
75 $user_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['submitted'][$i] . '.json');
76 $html_output .= GetItem($user_api_results);
77 }
78 } else {
79 $html_output .= '<p>User has no submissions.</p>';
80 }
81
82 return $html_output;
83 }
84}
85
86
87/**
88 * Formats one specific item requested by the user
89 *
90 * @access public
91 * @param mixed $api_results The decoded API results
92 * @param string $inline_title The <h1> title to use in the HTML
93 * @return string $html_output The formatted HTML result of stories from the API or the error message
94 * @author cmc <hello@cleberg.net>
95 */
96function ParseItem(mixed $api_results, string $inline_title): string
97{
98 // TODO: Need to create a page specifially for /item/ requests
99 // TODO: Use the GetItem() and Construct*() functions below, then output in it's own page - possibly with a single parent and descendat, if exist
100 return 'TODO';
101}
102
103
104/**
105 * Formats one item from the API to HTML
106 *
107 * @access public
108 * @param mixed $api_results The decoded API results
109 * @return string The formatted HTML result of stories from the API or the error message
110 * @author cmc <hello@cleberg.net>
111 */
112function GetItem(mixed $api_results): string
113{
114 $type = $api_results['type'];
115
116 return match ($type) {
117 'story', 'job' => ConstructStory($api_results),
118 'comment' => ConstructComment($api_results),
119 'poll' => ConstructPoll($api_results),
120 'pollopt' => ConstructPollOpt($api_results),
121 default => 'ERROR: Item type not found.',
122 };
123}
124
125
126/**
127 * Creates a story HTML element
128 *
129 * @access public
130 * @param mixed $api_results The decoded API results
131 * @return string The formatted HTML result of stories from the API or the error message
132 * @author cmc <hello@cleberg.net>
133 */
134function ConstructStory(mixed $api_results): string
135{
136 $id = $api_results['id'];
137 $url = $api_results['url'];
138 $title = $api_results['title'];
139 $time = date('Y-m-d h:m:s', $api_results['time']);
140 $by = $api_results['by'];
141 $score = $api_results['score'];
142 $descendants = $api_results['descendants'];
143
144 return <<<EOT
145 <div class="story">
146 <a href="$url">$title</a>
147 <p>
148 <time datetime="$time">$time</time>
149 by <a href="/user/$by/">$by</a>
150 | $score points
151 | <a href="/item/$id">$descendants comments</a>
152 </p>
153 </div>
154 EOT;
155}
156
157/**
158 * Creates a story discussion page with comments
159 *
160 * @access public
161 * @return string The formatted HTML result of stories from the API or the error message
162 * @author cmc <hello@cleberg.net>
163 */
164function ConstructStoryDiscussion(): string
165{
166 // TODO: Create discussion page, using mostly the same details as ConstructStory - except you need to check for $api_results['text'] to see if the poster left text in addition to the link.
167 // : Also need to show comments (at least a list of top level ones to start)
168 return 'TODO';
169}
170
171/**
172 * Creates a comment HTML element
173 *
174 * @access public
175 * @param mixed $api_results The decoded API results
176 * @return string The formatted HTML result of stories from the API or the error message
177 * @author cmc <hello@cleberg.net>
178 */
179function ConstructComment($api_results): string
180{
181 $time = date('Y-m-d h:m:s', $api_results['time']);
182 $text = $api_results['text'];
183 $parent = $api_results['parent'];
184
185 return <<<EOT
186 <div class="comment">
187 <p>$text</p>
188 <p><i>Submitted in response to: <a href="/item/$parent/">$parent</a></i></p>
189 <time datetime="$time">$time</time>
190 </div>
191 EOT;
192}
193
194/**
195 * Creates a poll HTML element
196 *
197 * @access public
198 * @param mixed $api_results The decoded API results
199 * @return string The formatted HTML result of stories from the API or the error message
200 * @author cmc <hello@cleberg.net>
201 */
202function ConstructPoll($api_results): string
203{
204 return 'TODO';
205}
206
207/**
208 * Creates a poll-option HTML element
209 *
210 * @access public
211 * @param mixed $api_results The decoded API results
212 * @return string The formatted HTML result of stories from the API or the error message
213 * @author cmc <hello@cleberg.net>
214 */
215function ConstructPollOpt($api_results): string
216{
217 return 'TODO';
218}\ No newline at end of file
src/Model/CacheService.php added +10
@@ -0,0 +1,10 @@
1<?php
2
3/**
4 * TODO: Implement a way to cache certain content in SQLite or something similarly performative
5 *
6 * @access public
7 * @return void
8 * @author cmc <hello@cleberg.net>
9 */
10function CacheService() {}\ No newline at end of file
src/View/BaseTemplate.php added +35
@@ -0,0 +1,35 @@
1<!doctype html>
2<html lang="en">
3
4<head>
5 <title><?php echo $this->title; ?></title>
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
8 <meta http-equiv="x-ua-compatible" content="ie=edge">
9 <meta name="author" content="<?php echo $GLOBALS['author_name']; ?>">
10 <meta name="description" content="<?php echo $this->description; ?>">
11 <link rel="canonical" href="<?php echo $this->canonical_url; ?>">
12 <link rel="stylesheet" href="/static/styles.min.css">
13</head>
14
15<body>
16<main id="main">
17 <nav class="links">
18 <span><a href="/">Top</a> &middot; </span>
19 <span><a href="/best/">Best</a> &middot;</span>
20 <span><a href="/new/">New</a> &middot;</span>
21 <span><a href="/ask/">Ask</a> &middot;</span>
22 <span><a href="/show/">Show</a> &middot;</span>
23 <span><a href="/job/">Job</a></span>
24 </nav>
25 <?php echo $this->content; ?>
26</main>
27
28<footer>
29 <p><a href="https://sr.ht/~cmc/hn/">Source Code</a></p>
30 <p>Copyright &copy; 2023 - <?php echo $this->current_year; ?></p>
31</footer>
32
33</body>
34
35</html>
src/View/class-template.php deleted −58
@@ -1,58 +0,0 @@
1<?php
2
3namespace HN\View;
4
5/**
6* Template View
7*
8* @author cmc <hello@cleberg.net>
9*/
10class Template
11{
12 /**
13 * @var string
14 */
15 private $canonical_url;
16 /**
17 * @var string
18 */
19 private $description;
20 /**
21 * @var string
22 */
23 private $title;
24 /**
25 * @var string
26 */
27 private $content;
28 /**
29 * @var false|string
30 */
31 private $current_year;
32
33 public function __construct(string $canonical_url, string $page_description, string $page_title, string $content_col) {
34 $this->canonical_url = $canonical_url;
35 $this->description = $page_description;
36 $this->title = $page_title;
37 $this->content = $content_col;
38 $this->current_year = date("Y");
39 }
40
41 public function echo_template(string $template_file) {
42 // Get the template file
43 $page = file_get_contents($template_file);
44
45 // Replace the template variables
46 $page = str_replace('{page_title}', $this->title, $page);
47 $page = str_replace('{page_description}', $this->description, $page);
48 $page = str_replace('{canonical_url}', $this->canonical_url, $page);
49 $page = str_replace('{content}', $this->content, $page);
50 $page = str_replace('{current_year}', $this->current_year, $page);
51
52 // Echo the filled-out template
53 echo $page;
54 }
55}
56
57// EOF
58
static/styles.css +164 −23
@@ -1,43 +1,184 @@
1/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 1/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
2button,hr,input{overflow:visible}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}details,main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 2button, hr, input {
3 overflow: visible
4}
5
6progress, sub, sup {
7 vertical-align: baseline
8}
9
10[type=checkbox], [type=radio], legend {
11 box-sizing: border-box;
12 padding: 0
13}
14
15html {
16 line-height: 1.15;
17 -webkit-text-size-adjust: 100%
18}
19
20body {
21 margin: 0
22}
23
24details, main {
25 display: block
26}
27
28h1 {
29 font-size: 2em;
30 margin: .67em 0
31}
32
33hr {
34 box-sizing: content-box;
35 height: 0
36}
37
38code, kbd, pre, samp {
39 font-family: monospace, monospace;
40 font-size: 1em
41}
42
43a {
44 background-color: transparent
45}
46
47abbr[title] {
48 border-bottom: none;
49 text-decoration: underline;
50 text-decoration: underline dotted
51}
52
53b, strong {
54 font-weight: bolder
55}
56
57small {
58 font-size: 80%
59}
60
61sub, sup {
62 font-size: 75%;
63 line-height: 0;
64 position: relative
65}
66
67sub {
68 bottom: -.25em
69}
70
71sup {
72 top: -.5em
73}
74
75img {
76 border-style: none
77}
78
79button, input, optgroup, select, textarea {
80 font-family: inherit;
81 font-size: 100%;
82 line-height: 1.15;
83 margin: 0
84}
85
86button, select {
87 text-transform: none
88}
89
90[type=button], [type=reset], [type=submit], button {
91 -webkit-appearance: button
92}
93
94[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {
95 border-style: none;
96 padding: 0
97}
98
99[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {
100 outline: ButtonText dotted 1px
101}
102
103fieldset {
104 padding: .35em .75em .625em
105}
106
107legend {
108 color: inherit;
109 display: table;
110 max-width: 100%;
111 white-space: normal
112}
113
114textarea {
115 overflow: auto
116}
117
118[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {
119 height: auto
120}
121
122[type=search] {
123 -webkit-appearance: textfield;
124 outline-offset: -2px
125}
126
127[type=search]::-webkit-search-decoration {
128 -webkit-appearance: none
129}
130
131::-webkit-file-upload-button {
132 -webkit-appearance: button;
133 font: inherit
134}
135
136summary {
137 display: list-item
138}
139
140[hidden], template {
141 display: none
142}
3 143
4/* custom css */ 144/* custom css */
5body { 145body {
6 padding: 1rem; 146 padding: 1rem;
7 font-family: system-ui, sans-serif; 147 font-family: system-ui, sans-serif;
148 max-width: 40em;
8} 149}
9 150
10body > main > div { 151body > main > div {
11 margin-bottom: 1rem; 152 margin-bottom: 1rem;
12} 153}
13 154
14body > main > div > p { 155body > main > div > p {
15 margin-top: 0.5rem; 156 margin-top: 0.5rem;
16} 157}
17 158
18a { 159a {
19 text-decoration: none; 160 text-decoration: none;
20} 161}
21 162
22footer { 163footer {
23 border-top: 1px solid black; 164 border-top: 1px solid black;
24} 165}
25 166
26@media (prefers-color-scheme: dark) { 167@media (prefers-color-scheme: dark) {
27 body { 168 body {
28 background-color: #000; 169 background-color: #000;
29 color: #ccc; 170 color: #ccc;
30 } 171 }
31 172
32 h1,h2,h3,h4,h5,h6 { 173 h1, h2, h3, h4, h5, h6 {
33 color: #fff; 174 color: #fff;
34 } 175 }
35 176
36 a,a:hover,a:visited { 177 a, a:hover, a:visited {
37 color: #0f0; 178 color: #0f0;
38 } 179 }
39 180
40 footer { 181 footer {
41 border-color: #ccc; 182 border-color: #ccc;
42 } 183 }
43} 184}
static/styles.min.css +1 −1
@@ -1 +1 @@
1/*!normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css*/button,hr,input{overflow:visible}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}details,main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:initial}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none}body{padding:1rem;font-family:system-ui,sans-serif}body>main>div{margin-bottom:1rem}body>main>div>p{margin-top:.5rem}a{text-decoration:none}footer{border-top:1px solid #000}@media(prefers-color-scheme:dark){body{background-color:#000;color:#ccc}h1,h2,h3,h4,h5,h6{color:#fff}a,a:hover,a:visited{color:#0f0}footer{border-color:#ccc}}\ No newline at end of file 1button, hr, input {overflow: visible }progress, sub, sup {vertical-align: baseline }[type=checkbox], [type=radio], legend {box-sizing: border-box;padding: 0 }html {line-height: 1.15;-webkit-text-size-adjust: 100% }body {margin: 0 }details, main {display: block }h1 {font-size: 2em;margin: .67em 0 }hr {box-sizing: content-box;height: 0 }code, kbd, pre, samp {font-family: monospace, monospace;font-size: 1em }a {background-color: transparent }abbr[title] {border-bottom: none;text-decoration: underline;text-decoration: underline dotted }b, strong {font-weight: bolder }small {font-size: 80% }sub, sup {font-size: 75%;line-height: 0;position: relative }sub {bottom: -.25em }sup {top: -.5em }img {border-style: none }button, input, optgroup, select, textarea {font-family: inherit;font-size: 100%;line-height: 1.15;margin: 0 }button, select {text-transform: none }[type=button], [type=reset], [type=submit], button {-webkit-appearance: button }[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {border-style: none;padding: 0 }[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {outline: ButtonText dotted 1px }fieldset {padding: .35em .75em .625em }legend {color: inherit;display: table;max-width: 100%;white-space: normal }textarea {overflow: auto }[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {height: auto }[type=search] {-webkit-appearance: textfield;outline-offset: -2px }[type=search]::-webkit-search-decoration {-webkit-appearance: none }::-webkit-file-upload-button {-webkit-appearance: button;font: inherit }summary {display: list-item }[hidden], template {display: none }body {padding: 1rem;font-family: system-ui, sans-serif;max-width: 40em;}body > main > div {margin-bottom: 1rem;}body > main > div > p {margin-top: 0.5rem;}a {text-decoration: none;}footer {border-top: 1px solid black;}.user-submission {border-bottom: 1px solid black;}@media (prefers-color-scheme: dark) {body {background-color: #000;color: #ccc;}h1, h2, h3, h4, h5, h6 {color: #fff;}a, a:hover, a:visited {color: #0f0;}footer {border-color: #ccc;}.user-submission {border-color: white;}}\ No newline at end of file
templates/template.html deleted −36
@@ -1,36 +0,0 @@
1<!doctype html>
2<html lang="en">
3
4<head>
5 <title>{page_title}</title>
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
8 <meta http-equiv="x-ua-compatible" content="ie=edge">
9 <meta name="author" content="My Name">
10 <meta name="description" content="{page_description}">
11 <link rel="canonical" href="{canonical_url}">
12 <!--<link rel="icon" href="/favicon.ico">-->
13 <link rel="stylesheet" href="/static/styles.min.css">
14</head>
15
16<body>
17 <main id="main">
18 <nav class="links">
19 <span><a href="/">Top</a> &middot; </span>
20 <span><a href="/best/">Best</a> &middot;</span>
21 <span><a href="/new/">New</a> &middot;</span>
22 <span><a href="/ask/">Ask</a> &middot;</span>
23 <span><a href="/show/">Show</a> &middot;</span>
24 <span><a href="/job/">Job</a></span>
25 </nav>
26 {content}
27 </main>
28
29 <footer>
30 <p><a href="https://sr.ht/~cmc/hn/">Source Code</a></p>
31 <p>Copyright &copy; 2023 - {current_year}</p>
32 </footer>
33
34</body>
35
36</html>