@vue-lynx-example/hackernews-css 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.vue ADDED
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue-lynx';
3
+ import { RouterView, useRoute, useRouter } from 'vue-router';
4
+ import { validFeeds } from './api';
5
+ import NavLink from './components/NavLink.vue';
6
+ import { openExternalUrl } from './utils';
7
+ import logoUrl from './assets/lynx-logo.png';
8
+
9
+ const router = useRouter();
10
+ const route = useRoute();
11
+
12
+ const feedKeys = Object.keys(validFeeds);
13
+ const activeFeed = computed(() =>
14
+ route.name === 'feed-page' && typeof route.params.feed === 'string'
15
+ ? route.params.feed
16
+ : '',
17
+ );
18
+ const transitionKey = computed(
19
+ () => activeFeed.value || String(route.name ?? 'route'),
20
+ );
21
+
22
+ function goHome() {
23
+ router.push('/');
24
+ }
25
+
26
+ function openVueReference() {
27
+ openExternalUrl('https://github.com/Huxpro/vue-lynx');
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <page class="page">
33
+ <view class="header">
34
+ <view class="inner">
35
+ <view class="logo" @tap="goHome">
36
+ <image class="logo-image" :src="logoUrl" resize="cover" />
37
+ </view>
38
+
39
+ <NavLink
40
+ v-for="key in feedKeys"
41
+ :key="key"
42
+ :to="`/${key}`"
43
+ :label="validFeeds[key].title"
44
+ :active="activeFeed === key"
45
+ />
46
+
47
+ <text class="github" @tap="openVueReference">Built with VueLynx</text>
48
+ </view>
49
+ </view>
50
+
51
+ <view class="route-shell">
52
+ <RouterView v-slot="{ Component }">
53
+ <Transition name="fade" mode="out-in" :duration="200">
54
+ <component :is="Component" :key="transitionKey" />
55
+ </Transition>
56
+ </RouterView>
57
+ </view>
58
+ </page>
59
+ </template>
60
+
61
+ <style lang="scss">
62
+ .page {
63
+ height: 100%;
64
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
65
+ Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
66
+ font-size: 15px;
67
+ background-color: #f2f3f5;
68
+ color: #2e495e;
69
+ }
70
+
71
+ .header {
72
+ background-color: #3eaf7c;
73
+ position: fixed;
74
+ top: 0;
75
+ left: 0;
76
+ right: 0;
77
+ z-index: 999;
78
+ height: 55px;
79
+
80
+ .inner {
81
+ max-width: 800px;
82
+ box-sizing: border-box;
83
+ margin: 0 auto;
84
+ padding: 15px 5px;
85
+ display: flex;
86
+ flex-direction: row;
87
+ align-items: center;
88
+ }
89
+
90
+ .nav-link,
91
+ .github {
92
+ color: #fff;
93
+ line-height: 24px;
94
+ transition: color 0.15s ease;
95
+ font-weight: 300;
96
+ letter-spacing: 0.075em;
97
+ margin-right: 1.8em;
98
+ white-space: nowrap;
99
+ }
100
+
101
+ .nav-link.router-link-active {
102
+ font-weight: 600;
103
+ }
104
+
105
+ .github {
106
+ font-size: 0.9em;
107
+ margin-left: auto;
108
+ margin-right: 0;
109
+ }
110
+ }
111
+
112
+ .route-shell {
113
+ padding-top: 55px;
114
+ }
115
+
116
+ .logo {
117
+ width: 26px;
118
+ height: 26px;
119
+ margin-right: 12px;
120
+ border-width: 1px;
121
+ border-color: #fff;
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ flex-shrink: 0;
126
+ }
127
+
128
+ .logo-image {
129
+ width: 24px;
130
+ height: 24px;
131
+ }
132
+
133
+ .fade-enter-active,
134
+ .fade-leave-active {
135
+ transition: all 0.2s ease;
136
+ }
137
+
138
+ .fade-enter-from,
139
+ .fade-leave-to {
140
+ opacity: 0;
141
+ }
142
+
143
+ @media (max-width: 860px) {
144
+ .header .inner {
145
+ padding: 15px 30px;
146
+ }
147
+ }
148
+
149
+ @media (max-width: 600px) {
150
+ .header {
151
+ .inner {
152
+ padding: 15px;
153
+ }
154
+
155
+ .nav-link {
156
+ margin-right: 1em;
157
+ }
158
+
159
+ .github {
160
+ display: none;
161
+ }
162
+ }
163
+ }
164
+ </style>
package/src/api.ts ADDED
@@ -0,0 +1,88 @@
1
+ const _fetch: typeof fetch = globalThis.fetch ?? fetch;
2
+
3
+ const BASE_URL = 'https://api.hackerwebapp.com';
4
+
5
+ export interface FeedItem {
6
+ id: number;
7
+ title: string;
8
+ url: string;
9
+ user: string;
10
+ points: number;
11
+ time_ago: string;
12
+ comments_count: number;
13
+ type: string;
14
+ }
15
+
16
+ export interface ItemDetail {
17
+ id: number;
18
+ title: string;
19
+ url: string;
20
+ user: string;
21
+ points: number;
22
+ time_ago: string;
23
+ comments_count: number;
24
+ comments: CommentData[];
25
+ content: string;
26
+ type: string;
27
+ }
28
+
29
+ export interface CommentData {
30
+ id: number;
31
+ user: string;
32
+ time_ago: string;
33
+ content: string;
34
+ comments: CommentData[];
35
+ }
36
+
37
+ export interface UserData {
38
+ id: string;
39
+ created: string;
40
+ karma: number;
41
+ about: string;
42
+ }
43
+
44
+ export const validFeeds: Record<string, { title: string; pages: number }> = {
45
+ news: { title: 'News', pages: 10 },
46
+ newest: { title: 'Newest', pages: 12 },
47
+ ask: { title: 'Ask', pages: 2 },
48
+ show: { title: 'Show', pages: 2 },
49
+ jobs: { title: 'Jobs', pages: 1 },
50
+ };
51
+
52
+ export async function fetchFeed(
53
+ feed: string,
54
+ page: number,
55
+ ): Promise<FeedItem[]> {
56
+ const res = await _fetch(`${BASE_URL}/${feed}?page=${page}`);
57
+ if (!res.ok) {
58
+ throw new Error(`Failed to fetch ${feed} page ${page}`);
59
+ }
60
+ return res.json();
61
+ }
62
+
63
+ export async function fetchItem(id: number): Promise<ItemDetail> {
64
+ const res = await _fetch(`${BASE_URL}/item/${id}`);
65
+ if (!res.ok) {
66
+ throw new Error(`Failed to fetch item ${id}`);
67
+ }
68
+ return res.json();
69
+ }
70
+
71
+ export async function fetchUser(id: string): Promise<UserData> {
72
+ const res = await _fetch(
73
+ `https://hacker-news.firebaseio.com/v0/user/${id}.json`,
74
+ );
75
+ if (!res.ok) {
76
+ throw new Error(`Failed to fetch user ${id}`);
77
+ }
78
+ const data = await res.json();
79
+ if (!data) {
80
+ throw new Error(`User ${id} not found`);
81
+ }
82
+ return {
83
+ id: data.id,
84
+ created: new Date(data.created * 1000).toLocaleDateString(),
85
+ karma: data.karma,
86
+ about: data.about || '',
87
+ };
88
+ }
Binary file
Binary file
@@ -0,0 +1,107 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue-lynx';
3
+ import { useRouter } from 'vue-router';
4
+ import type { CommentData } from '../api';
5
+ import { pluralize, stripHtml } from '../utils';
6
+
7
+ defineProps<{
8
+ comment: CommentData;
9
+ }>();
10
+
11
+ const router = useRouter();
12
+ const open = ref(true);
13
+
14
+ function goToUser(userId: string) {
15
+ router.push(`/user/${userId}`);
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <view v-if="comment && comment.user" class="comment">
21
+ <view class="by">
22
+ <text class="by-link" @tap="goToUser(comment.user)">{{ comment.user }}</text>
23
+ <text>{{ comment.time_ago }}</text>
24
+ </view>
25
+
26
+ <text class="text">{{ stripHtml(comment.content) }}</text>
27
+
28
+ <view
29
+ v-if="comment.comments && comment.comments.length"
30
+ class="toggle"
31
+ :class="{ open }"
32
+ @tap="open = !open"
33
+ >
34
+ <text class="toggle-label">
35
+ {{
36
+ open
37
+ ? '[-]'
38
+ : '[+] ' + pluralize(comment.comments.length, 'reply', 'replies') + ' collapsed'
39
+ }}
40
+ </text>
41
+ </view>
42
+
43
+ <view v-if="open && comment.comments && comment.comments.length" class="comment-children">
44
+ <Comment
45
+ v-for="childComment in comment.comments"
46
+ :key="childComment.id"
47
+ :comment="childComment"
48
+ />
49
+ </view>
50
+ </view>
51
+ </template>
52
+
53
+ <style lang="scss">
54
+ .comment-children {
55
+ .comment-children {
56
+ margin-left: 1.5em;
57
+ }
58
+ }
59
+
60
+ .comment {
61
+ border-top: 1px solid #eee;
62
+ position: relative;
63
+
64
+ .by,
65
+ .text,
66
+ .toggle {
67
+ font-size: 0.9em;
68
+ margin: 1em 0;
69
+ }
70
+
71
+ .by {
72
+ display: flex;
73
+ flex-direction: row;
74
+ flex-wrap: wrap;
75
+ color: #222;
76
+ gap: 0.4em;
77
+ }
78
+
79
+ .by-link {
80
+ color: #222;
81
+ text-decoration: underline;
82
+ }
83
+
84
+ .text {
85
+ overflow-wrap: break-word;
86
+ white-space: pre-wrap;
87
+ line-height: 1.4em;
88
+ }
89
+
90
+ .toggle {
91
+ background-color: #fffbf2;
92
+ padding: 0.3em 0.5em;
93
+ border-radius: 4px;
94
+ align-self: flex-start;
95
+
96
+ &.open {
97
+ padding: 0;
98
+ background-color: transparent;
99
+ margin-bottom: -0.5em;
100
+ }
101
+ }
102
+
103
+ .toggle-label {
104
+ color: #222;
105
+ }
106
+ }
107
+ </style>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import { RouterLink } from 'vue-router';
3
+
4
+ defineProps<{
5
+ to: string;
6
+ label: string;
7
+ active?: boolean;
8
+ }>();
9
+ </script>
10
+
11
+ <template>
12
+ <RouterLink :to="to" custom v-slot="{ navigate }">
13
+ <text
14
+ class="nav-link"
15
+ :class="{ 'router-link-active': active }"
16
+ @tap="navigate"
17
+ >
18
+ {{ label }}
19
+ </text>
20
+ </RouterLink>
21
+ </template>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ import { useRouter } from 'vue-router';
3
+ import type { FeedItem } from '../api';
4
+ import { isAbsoluteUrl, openExternalUrl, toHost } from '../utils';
5
+
6
+ const props = defineProps<{
7
+ item: FeedItem;
8
+ }>();
9
+
10
+ const router = useRouter();
11
+ const itemUrl = toHost(props.item.url);
12
+
13
+ function goToItem() {
14
+ router.push(`/item/${props.item.id}`);
15
+ }
16
+
17
+ function goToUser() {
18
+ router.push(`/user/${props.item.user}`);
19
+ }
20
+
21
+ function handleTitleTap() {
22
+ if (isAbsoluteUrl(props.item.url)) {
23
+ openExternalUrl(props.item.url);
24
+ return;
25
+ }
26
+
27
+ goToItem();
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <view class="news-item">
33
+ <text class="score">{{ item.points }}</text>
34
+
35
+ <view class="title">
36
+ <text class="news-link" @tap="handleTitleTap">{{ item.title }}</text>
37
+ <text v-if="isAbsoluteUrl(item.url)" class="host">({{ itemUrl }})</text>
38
+ </view>
39
+
40
+ <view class="meta">
41
+ <text v-if="item.type !== 'job'">by </text>
42
+ <text v-if="item.type !== 'job'" class="meta-link" @tap="goToUser">
43
+ {{ item.user }}
44
+ </text>
45
+ <text class="time">{{ item.time_ago }}</text>
46
+ <text v-if="item.type !== 'job'">|</text>
47
+ <text v-if="item.type !== 'job'" class="meta-link" @tap="goToItem">
48
+ {{ item.comments_count }} comments
49
+ </text>
50
+ </view>
51
+ </view>
52
+ </template>
53
+
54
+ <style lang="scss">
55
+ .news-item {
56
+ background-color: #fff;
57
+ padding: 20px 30px 20px 80px;
58
+ border-bottom: 1px solid #eee;
59
+ position: relative;
60
+ line-height: 20px;
61
+
62
+ .title {
63
+ display: flex;
64
+ flex-direction: row;
65
+ flex-wrap: wrap;
66
+ gap: 0.25em;
67
+ }
68
+
69
+ .news-link {
70
+ color: #2e495e;
71
+ }
72
+
73
+ .score {
74
+ color: #3eaf7c;
75
+ font-size: 1.1em;
76
+ font-weight: 700;
77
+ position: absolute;
78
+ top: 50%;
79
+ left: 0;
80
+ width: 80px;
81
+ text-align: center;
82
+ margin-top: -10px;
83
+ }
84
+
85
+ .meta,
86
+ .host,
87
+ .meta-link {
88
+ font-size: 0.85em;
89
+ color: #595959;
90
+ }
91
+
92
+ .meta {
93
+ display: flex;
94
+ flex-direction: row;
95
+ flex-wrap: wrap;
96
+ gap: 0.35em;
97
+ margin-top: 4px;
98
+ }
99
+
100
+ .meta-link {
101
+ text-decoration: underline;
102
+ }
103
+ }
104
+ </style>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ show: boolean;
4
+ }>();
5
+ </script>
6
+
7
+ <template>
8
+ <view v-if="show" class="spinner" />
9
+ </template>
10
+
11
+ <style lang="scss">
12
+ .spinner {
13
+ width: 28px;
14
+ height: 28px;
15
+ border-radius: 14px;
16
+ border-width: 3px;
17
+ border-style: solid;
18
+ border-color: #eee;
19
+ border-top-color: #3eaf7c;
20
+ animation: spin 0.8s linear infinite;
21
+ }
22
+
23
+ @keyframes spin {
24
+ from {
25
+ transform: rotate(0deg);
26
+ }
27
+
28
+ to {
29
+ transform: rotate(360deg);
30
+ }
31
+ }
32
+ </style>
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { createApp } from 'vue-lynx';
2
+ import { createPinia } from 'pinia';
3
+ import { VueQueryPlugin } from '@tanstack/vue-query';
4
+
5
+ import router from './router';
6
+ import App from './App.vue';
7
+
8
+ const app = createApp(App);
9
+ app.use(createPinia());
10
+ app.use(VueQueryPlugin);
11
+ app.use(router);
12
+
13
+ router.push('/');
14
+
15
+ app.mount();