Commit d4f9448a authored by Pooya Parsa's avatar Pooya Parsa

feat: rewrite project with hnpwa api

parent 2c64c349
import { initializeApp, database } from "firebase/app"
export async function createAPI({ config, version }) {
await import(/* webpackChunkName: "firebase" */ "firebase/database")
initializeApp(config)
return database().ref(version)
}
import Firebase from "firebase"
import LRU from "lru-cache"
export async function createAPI({ config, version }) {
let api
// this piece of code may run multiple times in development mode,
// so we attach the instantiated API to `process` to avoid duplications
if (process.__API__) {
api = process.__API__
} else {
Firebase.initializeApp(config)
api = process.__API__ = Firebase.database().ref(version)
api.onServer = true
// fetched item cache
api.cachedItems = LRU({
max: 1000,
maxAge: 1000 * 60 * 15 // 15 min cache
})
// cache the latest story ids
api.cachedIds = {}
;["top", "new", "show", "ask", "job"].forEach(type => {
api.child(`${type}stories`).on("value", snapshot => {
api.cachedIds[type] = snapshot.val()
})
})
}
return api
}
// This is aliased in webpack config based on server/client build
import { createAPI } from "create-api"
const logRequests = !!process.env.DEBUG_API
let api = {}
let _api = createAPI({
version: "/v0",
config: {
databaseURL: "https://hacker-news.firebaseio.com"
}
}).then(_api => {
api = _api
// warm the front page cache every 15 min
// make sure to do this only once across all requests
if (api.onServer) {
warmCache()
}
})
function warmCache() {
if (!api.cachedIds) return
fetchItems((api.cachedIds.top || []).slice(0, 30))
setTimeout(warmCache, 1000 * 60 * 15)
}
async function fetch(child) {
logRequests && console.log(`fetching ${child}...`)
await _api
const cache = api.cachedItems
if (cache && cache.has(child)) {
logRequests && console.log(`cache hit for ${child}.`)
return cache.get(child)
} else {
return new Promise((resolve, reject) => {
api.child(child).once(
"value",
snapshot => {
const val = snapshot.val()
// mark the timestamp when this item is cached
if (val) val.__lastUpdated = Date.now()
cache && cache.set(child, val)
logRequests && console.log(`fetched ${child}.`)
resolve(val)
},
reject
)
})
}
}
export function fetchIdsByType(type) {
return api.cachedIds && api.cachedIds[type]
? Promise.resolve(api.cachedIds[type])
: fetch(`${type}stories`)
}
export function fetchItem(id) {
return fetch(`item/${id}`)
}
export function fetchItems(ids) {
return Promise.all(ids.map(id => fetchItem(id)))
}
export function fetchUser(id) {
return fetch(`user/${id}`)
}
export async function watchList(type, cb) {
let first = true
await _api
const ref = api.child(`${type}stories`)
const handler = snapshot => {
if (first) {
first = false
} else {
cb(snapshot.val())
}
}
ref.on("value", handler)
return () => {
ref.off("value", handler)
}
}
<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
<head>
{{ HEAD }}
<style>
#skip a { position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden; }
#skip a:focus { position:static; width:auto; height:auto; }
</style>
</head>
<body {{ BODY_ATTRS }}>
<div id="skip"><a href="#app">skip to content</a></div>
{{ APP }}
</body>
</html>
export const feeds = {
news: { title: "News", pages: 10 },
newest: { title: "Newest", pages: 12 },
ask: { title: "Ask", pages: 2 },
show: { title: "Show", pages: 2 },
jobs: { title: "Jobs", pages: 1 }
}
export const validFeeds = Object.keys(feeds)
export const lazy = (commit, task, optimistic, enabled) => {
// By default, do lazy operations only in client
if (enabled === undefined) {
enabled = process.client
}
// Non lazy mode
if (!enabled) {
return task().then(commit)
}
// Do real task in background
Promise.resolve(task(optimistic))
.then(commit)
.catch(console.error)
// Commit optimistic value and resolve
return Promise.resolve(commit(optimistic))
}
<template>
<li v-if="comment" class="comment">
<div class="by">
<router-link :to="'/user/' + comment.by">{{ comment.by }}</router-link>
<router-link :to="'/user/' + comment.user">{{ comment.user }}</router-link>
{{ comment.time | timeAgo }} ago
</div>
<div class="text" v-html="comment.text" />
<div v-if="comment.kids && comment.kids.length" :class="{ open }" class="toggle">
<a @click="open = !open">{{ open ? '[-]' : '[+] ' + pluralize(comment.kids.length) + ' collapsed' }}
<div class="text" v-html="comment.content" />
<div v-if="comment.comments && comment.comments.length" :class="{ open }" class="toggle">
<a @click="open = !open">{{ open ? '[-]' : '[+] ' + pluralize(comment.comments.length) + ' collapsed' }}
</a>
</div>
<ul v-show="open" class="comment-children">
<comment v-for="id in comment.kids" :key="id" :id="id" />
<comment v-for="childComment in comment.comments" :key="childComment.id" :comment="childComment" />
</ul>
</li>
</template>
......@@ -19,8 +19,8 @@
export default {
name: "Comment",
props: {
id: {
type: String,
comment: {
type: Object,
required: true
}
},
......@@ -29,11 +29,6 @@ export default {
open: true
}
},
computed: {
comment() {
return this.$store.state.items[this.id]
}
},
methods: {
pluralize: n => n + (n === 1 ? " reply" : " replies")
}
......
<template>
<li class="news-item">
<span class="score">{{ item.score }}</span>
<span class="score">{{ item.points }}</span>
<span class="title">
<template v-if="item.url">
<a :href="item.url" target="_blank" rel="noopener">{{ item.title }}</a>
......@@ -14,7 +14,7 @@
<span class="meta">
<span v-if="item.type !== 'job'" class="by">
by
<router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
<router-link :to="'/user/' + item.user">{{ item.user }}</router-link>
</span>
<span class="time">
{{ item.time | timeAgo }} ago
......
import ItemList from "./ItemList.vue"
const camelize = str => str.charAt(0).toUpperCase() + str.slice(1)
// This is a factory function for dynamically creating root-level list views,
// since they share most of the logic except for the type of items to display.
// They are essentially higher order components wrapping ItemList.vue.
export default function createListView(type) {
return {
name: `${type}-stories-view`,
fetch({ store }) {
return store.dispatch("FETCH_LIST_DATA", { type })
},
head: {
title: camelize(type)
},
render(h) {
return h(ItemList, { props: { type } })
}
}
}
<template>
<div class="news-list-nav">
<router-link v-if="page > 1" :to="`/${type}/${page - 1}`">&lt; prev</router-link>
<router-link v-if="page > 1" :to="`/${feed}/${page - 1}`">&lt; prev</router-link>
<a v-else class="disabled">&lt; prev</a>
<span>{{ page }}/{{ maxPage }}</span>
<router-link v-if="hasMore" :to="`/${type}/${page + 1}`">more &gt;</router-link>
<router-link v-if="hasMore" :to="`/${feed}/${page + 1}`">more &gt;</router-link>
<a v-else class="disabled">more &gt;</a>
</div>
</template>
......@@ -11,7 +11,7 @@
<script>
export default {
props: {
type: {
feed: {
type: String,
required: true
},
......
<script>
import Spinner from "./spinner"
export default {
functional: true,
props: {
loading: {
type: Boolean,
default: false
}
},
render(h, { props, children }) {
return props.loading
? h("div", { style: { "text-align": "center" } }, [
h(Spinner, { props: { show: true } })
])
: children
}
}
</script>
......@@ -5,11 +5,9 @@
<router-link to="/" exact>
<img class="logo" src="~assets/logo.png" alt="logo">
</router-link>
<router-link to="/top">Top</router-link>
<router-link to="/new">New</router-link>
<router-link to="/show">Show</router-link>
<router-link to="/ask">Ask</router-link>
<router-link to="/job">Jobs</router-link>
<router-link v-for="(list, key) in feeds" :key="key" :to="`/${key}`">
{{ list.title }}
</router-link>
<a class="github" href="https://github.com/nuxt/hackernews" target="_blank" rel="noopener">
Built with Nuxt.js
</a>
......@@ -20,6 +18,8 @@
</template>
<script>
import { feeds } from "~/common/api"
export default {
head() {
return {
......@@ -28,6 +28,9 @@ export default {
{ rel: "canonical", href: `https://hn.nuxtjs.org${this.$route.path}` }
]
}
},
computed: {
feeds: () => feeds
}
}
</script>
......
export default ({ isDev, req, redirect }) => {
// Redirect to https
if (!isDev && req) {
const protocol =
req.headers["x-forwarded-proto"] ||
(req.connection.encrypted ? "https" : "http")
if (protocol === "http") {
return redirect(301, `https://${req.headers.host}${req.url}`)
}
}
}
module.exports = {
mode: "universal",
build: {
extend(config, { isClient, isDev }) {
// Run ESLint on save
if (isDev && isClient) {
config.module.rules.push({
enforce: "pre",
test: /\.(js|vue)$/,
loader: "eslint-loader",
exclude: /(node_modules)/
})
}
config.resolve.alias["create-api"] = `./create-api-${
isClient ? "client" : "server"
}.js`
},
vendor: ["firebase"]
},
head: {
titleTemplate: "Nuxt HN | %s",
meta: [
......@@ -28,10 +10,7 @@ module.exports = {
{ property: "twitter:card", content: "summary_large_image" },
{ property: "twitter:site", content: "@nuxt_js" }
],
link: [
{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
{ rel: "dns-prefetch", href: "https://hacker-news.firebaseio.com" }
]
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }]
},
loading: {
color: "#59cc93"
......@@ -46,15 +25,17 @@ module.exports = {
description: "HackerNews clone built with Nuxt.js",
theme_color: "#188269"
},
modules: ["@nuxtjs/pwa", "@nuxtjs/component-cache"],
plugins: [
"~/plugins/vuex-router-sync",
"~/plugins/filters",
"~/plugins/components"
],
router: {
middleware: ["https"]
modules: ["@nuxtjs/pwa", "@nuxtjs/component-cache", "@nuxtjs/axios"],
axios: {
proxy: true
},
proxy: {
"/api": {
target: "https://api.hnpwa.com/v0/",
pathRewrite: { "^/api/": "" }
}
},
plugins: ["~/plugins/filters"],
render: {
static: {
maxAge: "1y",
......
......@@ -32,6 +32,7 @@
"node": ">=8.0"
},
"dependencies": {
"@nuxtjs/axios": "^5.1.1",
"@nuxtjs/component-cache": "^1.1.1",
"@nuxtjs/pwa": "2.0.8",
"nuxt-edge": "^2.0.0-25364965.06067bf",
......
<template>
<div class="news-view view">
<item-list-nav :type="type" :page="page" :max-page="maxPage" />
<transition :name="transition" mode="out-in">
<div v-if="displayedPage > 0" :key="displayedPage" class="news-list">
<transition-group tag="ul" name="item">
<item v-for="item in displayedItems" :key="item.id" :item="item" />
</transition-group>
</div>
</transition>
<item-list-nav :type="type" :page="page" :max-page="maxPage" />
<div class="view">
<item-list-nav :feed="feed" :page="page" :max-page="maxPage" />
<lazy-wrapper :loading="loading">
<transition :name="transition" mode="out-in">
<div :key="displayedPage" class="news-list">
<transition-group tag="ul" name="item">
<item v-for="item in displayedItems" :key="item.id" :item="item" />
</transition-group>
</div>
</transition>
<item-list-nav :feed="feed" :page="page" :max-page="maxPage" />
</lazy-wrapper>
</div>
</template>
<script>
import { watchList } from "../api"
import Item from "./Item.vue"
import ItemListNav from "./ItemListNav.vue"
import Item from "~/components/item.vue"
import ItemListNav from "~/components/item-list-nav.vue"
import LazyWrapper from "~/components/lazy-wrapper"
import { feeds, validFeeds } from "~/common/api"
export default {
name: "ItemList",
components: {
Item,
ItemListNav
ItemListNav,
LazyWrapper
},
validate({ params: { feed } }) {
return validFeeds.includes(feed)
},
fetch({ store, params: { feed, page = 1 } }) {
return store.dispatch("FETCH_FEED", { feed, page })
},
props: {
type: {
type: String,
required: true
head() {
return {
title: feeds[this.$route.params.feed].title
}
},
data() {
return {
transition: "slide-right",
displayedPage: Number(this.$route.params.page) || 1,
displayedItems: this.$store.getters.activeItems
displayedPage: Number(this.page) || 1
}
},
computed: {
feed() {
return this.$route.params.feed
},
page() {
return Number(this.$route.params.page) || 1
},
maxPage() {
const { itemsPerPage, lists } = this.$store.state
return Math.ceil(lists[this.type].length / itemsPerPage)
return feeds[this.feed].pages
},
pageData() {
return this.$store.state.feeds[this.feed][this.page]
},
displayedItems() {
return this.pageData.map(id => this.$store.state.items[id])
},
loading() {
return this.displayedItems.length === 0
}
},
watch: {
page(to, from) {
console.log("Page changed", to, from)
this.loadItems(to, from)
}
page: "pageChanged"
},
async beforeMount() {
if (this.$root._isMounted) {
this.loadItems(this.page)
}
// watch the current list for realtime updates
this.unwatchList = await watchList(this.type, ids => {
this.$store.commit("SET_LIST", { type: this.type, ids })
this.$store.dispatch("ENSURE_ACTIVE_ITEMS").then(() => {
this.displayedItems = this.$store.getters.activeItems
})
})
},
beforeDestroy() {
this.unwatchList()
mounted() {
this.pageChanged(this.page)
},
methods: {
loadItems(to = this.page, from = -1) {
this.$nuxt.$loading.start()
pageChanged(to, from = -1) {
if (to < 0 || to > this.maxPage) {
this.$router.replace(`/${this.feed}/1`)
return
}
// Prefetch next page
this.$store
.dispatch("FETCH_LIST_DATA", {
type: this.type
})
.then(() => {
if (this.page < 0 || this.page > this.maxPage) {
this.$router.replace(`/${this.type}/1`)
return
}
this.transition =
from === -1 ? null : to > from ? "slide-left" : "slide-right"
this.displayedPage = to
this.displayedItems = this.$store.getters.activeItems
this.$nuxt.$loading.finish()
.dispatch("FETCH_FEED", {
feed: this.feed,
page: this.page + 1,
prefetch: true
})
.catch(() => {})
this.transition =
from === -1 ? null : to > from ? "slide-left" : "slide-right"
this.displayedPage = to
}
}
}
......
<script>
import createListView from "~/components/createListView"
export default createListView("ask")
</script>
<script>
import { validFeeds } from "~/common/api"
export default {
fetch({ redirect }) {
redirect("/top")
redirect("/" + validFeeds[0])
}
}
</script>
<template>
<div v-if="item" class="item-view view" >
<template v-if="item">
<div class="item-view-header">
<a :href="item.url" target="_blank">
<h1 v-html="item.title" />
</a>
<span v-if="item.url" class="host">
({{ item.url | host }})
</span>
<p class="meta">
{{ item.score }} points | by
<router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
{{ item.time | timeAgo }} ago
</p>
</div>
<div class="item-view-comments">
<div class="item-view view" >
<div class="item-view-header">
<a :href="item.url" target="_blank">
<h1 v-html="item.title" />
</a>
<span v-if="item.url" class="host">
({{ item.url | host }})
</span>
<p class="meta">
{{ item.points }} points | by
<router-link :to="'/user/' + item.user">{{ item.user }}</router-link>
{{ item.time | timeAgo }} ago
</p>
</div>
<div class="item-view-comments">
<lazy-wrapper :loading="item.loading">
<p class="item-view-comments-header">
{{ item.kids ? item.descendants + ' comments' : 'No comments yet.' }}
<spinner :show="loading"/>
{{ item.comments ? item.comments.length + ' comments' : 'No comments yet.' }}
</p>
<ul v-if="!loading" class="comment-children">
<comment v-for="id in item.kids" :key="id" :id="id"/>
<ul class="comment-children">
<comment v-for="comment in item.comments" :key="comment.id" :comment="comment"/>
</ul>
</div>
</template>
</lazy-wrapper>
</div>
</div>
</template>
<script>
import Spinner from "~/components/Spinner.vue"
import Comment from "~/components/Comment.vue"
import Comment from "~/components/comment.vue"
import LazyWrapper from "~/components/lazy-wrapper"
export default {
name: "ItemView",
components: { Spinner, Comment },
data: () => ({
loading: true
}),
components: { Comment, LazyWrapper },
head() {
return {
......@@ -46,54 +41,17 @@ export default {
},
computed: {
id() {
return this.$route.params.id
},
item() {
return this.$store.state.items[this.$route.params.id]
return this.$store.state.items[this.id]
}
},
// We only fetch the item itself before entering the view, because
// it might take a long time to load threads with hundreds of comments
// due to how the HN Firebase API works.
asyncData({ store, route: { params: { id } } }) {
return store.dispatch("FETCH_ITEMS", { ids: [id] })
},
// refetch comments if item changed
watch: {
item: "fetchComments"
},
// Fetch comments when mounted on the client
beforeMount() {
this.fetchComments()
},
methods: {
fetchComments() {
this.loading = true
fetchComments(this.$store, this.item).then(() => {
this.loading = false
})
}
}
}
// recursively fetch all descendent comments
function fetchComments(store, item) {
if (item && item.kids) {
return store
.dispatch("FETCH_ITEMS", {
ids: item.kids
})
.then(() =>
Promise.all(
item.kids.map(id => {
return fetchComments(store, store.state.items[id])
})
)
)
fetch({ store, params: { id } }) {
return store.dispatch("FETCH_ITEM", { id })
}
return Promise.resolve()
}
</script>
......
<script>
import createListView from "~/components/createListView"
export default createListView("job")
</script>
<script>
import createListView from "~/components/createListView"
export default createListView("new")
</script>
<script>
import createListView from "~/components/createListView"
export default createListView("show")
</script>
<script>
import createListView from "~/components/createListView"
export default createListView("top")
</script>
......@@ -2,28 +2,34 @@
<div class="user-view view">
<template v-if="user">
<h1>User : {{ user.id }}</h1>
<ul class="meta">
<li>
<span class="label">Created:</span> {{ user.created | timeAgo }} ago</li>
<li>
<span class="label">Karma:</span> {{ user.karma }}</li>
<li v-if="user.about" class="about" v-html="user.about" />
</ul>
<lazy-wrapper :loading="user.loading">
<ul class="meta">
<li>
<span class="label">Created:</span> {{ user.created_time | timeAgo }} ago</li>
<li>
<span class="label">Karma:</span> {{ user.karma || '-' }}</li>
<li v-if="user.about" class="about" v-html="user.about" />
</ul>
</lazy-wrapper>
<p class="links">
<a :href="'https://news.ycombinator.com/submitted?id=' + user.id">submissions</a> |
<a :href="'https://news.ycombinator.com/threads?id=' + user.id">comments</a>
</p>
</template>
<template v-else-if="user === false">
<template v-else>
<h1>User not found.</h1>
</template>
</div>
</template>
<script>
import LazyWrapper from "~/components/lazy-wrapper"
export default {
name: "UserView",
components: { LazyWrapper },
computed: {
user() {
return this.$store.state.users[this.$route.params.id]
......@@ -31,9 +37,7 @@ export default {
},
head() {
return {
title: this.user.id || "User not found"
}
return this.user ? this.user.id : "User not found"
},
fetch({ store, route: { params: { id } } }) {
......
// Import common components to optimize chunks size
import "~/components/createListView"
import { sync } from "vuex-router-sync"
export default function({ app, store }) {
sync(store, app.router)
}
import { fetchUser, fetchItems, fetchIdsByType } from "../api"
export default {
// ensure data for rendering given list type
FETCH_LIST_DATA: ({ commit, dispatch }, { type }) => {
commit("SET_ACTIVE_TYPE", { type })
return fetchIdsByType(type)
.then(ids => commit("SET_LIST", { type, ids }))
.then(() => dispatch("ENSURE_ACTIVE_ITEMS"))
},
// ensure all active items are fetched
ENSURE_ACTIVE_ITEMS: ({ dispatch, getters }) => {
return dispatch("FETCH_ITEMS", {
ids: getters.activeIds
})
},
FETCH_ITEMS: ({ commit, state }, { ids }) => {
// on the client, the store itself serves as a cache.
// only fetch items that we do not already have, or has expired (3 minutes)
const now = Date.now()
ids = ids.filter(id => {
const item = state.items[id]
if (!item) {
return true
}
if (now - item.__lastUpdated > 1000 * 60 * 3) {
return true
}
return false
})
if (ids.length) {
return fetchItems(ids).then(items => commit("SET_ITEMS", { items }))
} else {
return Promise.resolve()
}
},
FETCH_USER: ({ commit, state }, { id }) => {
return state.users[id]
? Promise.resolve(state.users[id])
: fetchUser(id).then(user => commit("SET_USER", { id, user }))
}
}
export default {
// ids of the items that should be currently displayed based on
// current list type and current pagination
activeIds(state) {
const { activeType, itemsPerPage, lists } = state
if (!activeType) {
return []
}
const page = Number(state.route.params.page) || 1
const start = (page - 1) * itemsPerPage
const end = page * itemsPerPage
return lists[activeType].slice(start, end)
},
// items that should be currently displayed.
// this Array may not be fully fetched.
activeItems(state, getters) {
return getters.activeIds.map(id => state.items[id]).filter(_ => _)
}
}
import Vuex from "vuex"
import Vue from "vue"
import actions from "./actions"
import mutations from "./mutations"
import getters from "./getters"
import { validFeeds } from "~/common/api"
import { lazy } from "~/common/utils"
import { CancelToken } from "axios"
export default () => {
return new Vuex.Store({
state: {
activeType: null,
itemsPerPage: 20,
export default {
// =================================================
// State
// =================================================
state: () => {
const state = {
items: {
/* [id: number]: Item */
},
users: {
/* [id: string]: User */
},
lists: {
top: [
/* number */
],
new: [],
show: [],
ask: [],
job: []
feeds: {
/* [page: number] : [ [id: number] ] */
}
}
validFeeds.forEach(feed => {
state.feeds[feed] = {}
})
return state
},
// =================================================
// Actions
// =================================================
actions: {
FETCH_FEED({ commit, state }, { feed, page, prefetch }) {
if (!prefetch) {
if (this.feedCancelSource) {
this.feedCancelSource.cancel(
"priorotize feed: " + feed + " page: " + page
)
}
this.feedCancelSource = CancelToken.source()
}
return lazy(
items => {
const ids = items.map(item => item.id)
commit("SET_FEED", { feed, ids, page })
commit("SET_ITEMS", { items })
},
() =>
this.$axios.$get(`/api/${feed}/${page}.json`, {
cancelToken: this.feedCancelSource && this.feedCancelSource.token
}),
(state.feeds[feed][page] || []).map(id => state.items[id])
)
},
FETCH_ITEM({ commit, state }, { id }) {
return lazy(
item => commit("SET_ITEM", { item }),
() => this.$axios.$get(`/api/item/${id}.json`),
Object.assign({ id, loading: true, comments: [] }, state.items[id])
)
},
FETCH_USER({ state, commit }, { id }) {
return lazy(
user => commit("SET_USER", { id, user }),
() => this.$axios.$get(`/api/user/${id}.json`),
Object.assign({ id, loading: true }, state.users[id])
)
}
},
// =================================================
// Mutations
// =================================================
mutations: {
SET_FEED: (state, { feed, ids, page }) => {
Vue.set(state.feeds[feed], page, ids)
},
SET_ITEM: (state, { item }) => {
if (item) {
Vue.set(state.items, item.id, item)
}
},
SET_ITEMS: (state, { items }) => {
items.forEach(item => {
if (item) {
Vue.set(state.items, item.id, item)
}
})
},
actions,
mutations,
getters
})
SET_USER: (state, { id, user }) => {
Vue.set(state.users, id, user || false) /* false means user not found */
}
}
}
import Vue from "vue"
export default {
SET_ACTIVE_TYPE: (state, { type }) => {
state.activeType = type
},
SET_LIST: (state, { type, ids }) => {
state.lists[type] = ids
},
SET_ITEMS: (state, { items }) => {
items.forEach(item => {
if (item) {
Vue.set(state.items, item.id, item)
}
})
},
SET_USER: (state, { id, user }) => {
Vue.set(state.users, id, user || false) /* false means user not found */
}
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment