mirror of
https://github.com/BillyOutlast/posthog.com.git
synced 2026-02-04 03:11:21 +01:00
feat: migrate Squeak to Strapi (#5622)
* remove org providers * merge questions components * merge authentication components * merge question form components * move main login * move Squeak.tsx * merge question * update exports * split out replies component * begin rewriting question component * add the ability to fetch by permalink * move gatsby-source-squeak to ts * working questions table * working question permalink pages! * add images to questions table * basic login working * working topics pages * move topiGroup fetching * working topic groups * working login state * remove unused apiHost and organizationId params * fix useUser hook * small tweaks * rename Question.permalink to SqueakQuestion.permalink * remove reply resolver * remove useOrg hook * add back old CommunityQuestions components * update roadmap queries * fetch roadmaps * remove console.log * small tweaks * remove key * add squeak migration script * only fetch questions that match the current slug * unify useQuestions hook * fix build * change markdown component * search: removing padding * working reply! * expand question by default * replace username with email * working sign up * working question sidebar + remove pre-rendered question pages * add back pre-rendered question component pages * uncomment Changelog query * display multiple teams * remove updatedAt field * don't access avatar if it's null * fix question profile links * only add replies with profiles * fail when no question * fixes * remove ErrorBoundary from QuestionForm * remove old changelog page * delete ErrorBoundary component * working question posting * gravatar urls * working profiles * use questions component * reset password * display profile info in dropdown * post question to specific page * remove excess padding on replies * remove changelog from dropdown * make registering and signing in work * avatar fixes * login / registration error messages * more avatar fixes * update slugs in queries * fail on non-ok request to `/me` * add edit profiles * set all user roles to authenticated * upload avatars * use getAvatarURL * only show edit profile button on active user * fix question pages * working subscribe and unsubscribe endpoints * refactor useUser logic separate out logic to fetch and save the current user * working subscriptions * chore: remove misc console logs * use fetchUser in EditProfile * ensure all questions have profiles * add more misc to migration sql * use useRoadmap hook in Roadmap * working team roadmaps * show login when trying to subscribe as anon * fix profile TODO * add sortBy newest, popular, and activity * add back homepage roadmap * remove unused Changelog component * bring back reply actions * fix collapsed avatars * fix badge text * small ui improvements * mobile edit profile * add teams to profile sidebar * also show questions that user has replied to * hide discussions if no questions exist * reduce required reply count to show collapsed view * fix: return correct user from login and signUp methods * remove unused lib/api * add body to form preview * button labels on questionform * question page modal initial view * fix blur bug * try not passing slug on questions page * dark mode colors * dark mode fix * error messages * correct type of slugs object * more onBlur fixes * add logged in user email and add edit profile button back * remove log --------- Co-authored-by: Eli Kinsey <eli@ekinsey.dev> Co-authored-by: Cory Watilo <cww@watilo.com>
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
GATSBY_POSTHOG_API_KEY=XXXXXXXX_YOUR_DEBUG_KEY_XXXXXXXX
|
||||
GATSBY_POSTHOG_API_HOST=http://localhost:8000
|
||||
|
||||
GATSBY_SQUEAK_ORG_ID=a898bcf2-c5b9-4039-82a0-a00220a8c626
|
||||
GATSBY_SQUEAK_API_HOST=https://squeak.cloud
|
||||
GATSBY_SQUEAK_API_HOST=https://squeak.posthog.cc
|
||||
|
||||
GATSBY_ALGOLIA_APP_ID=7VNQB5W0TX
|
||||
GATSBY_ALGOLIA_SEARCH_API_KEY=e9ff9279dc8771a35a26d586c73c20a8
|
||||
GATSBY_ALGOLIA_INDEX_NAME=dev_posthog_com
|
||||
BILLING_SERVICE_URL=https://billing.posthog.com
|
||||
# BILLING_SERVICE_URL=http://localhost:8100
|
||||
WAIT_FOR_FLAGS=0
|
||||
WAIT_FOR_FLAGS=0
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
GATSBY_POSTHOG_API_KEY=sTMFPsFhdP1Ssg
|
||||
GATSBY_POSTHOG_API_HOST=https://app.posthog.com
|
||||
|
||||
GATSBY_SQUEAK_ORG_ID=a898bcf2-c5b9-4039-82a0-a00220a8c626
|
||||
GATSBY_SQUEAK_API_HOST=https://squeak.cloud
|
||||
GATSBY_SQUEAK_API_HOST=https://squeak.posthog.cc
|
||||
|
||||
GATSBY_ALGOLIA_APP_ID=7VNQB5W0TX
|
||||
GATSBY_ALGOLIA_SEARCH_API_KEY=e9ff9279dc8771a35a26d586c73c20a8
|
||||
GATSBY_ALGOLIA_INDEX_NAME=dev_posthog_com
|
||||
BILLING_SERVICE_URL=https://billing.posthog.com
|
||||
WAIT_FOR_FLAGS=1
|
||||
WAIT_FOR_FLAGS=1
|
||||
|
||||
@@ -28,7 +28,6 @@ module.exports = {
|
||||
resolve: `gatsby-source-squeak`,
|
||||
options: {
|
||||
apiHost: process.env.GATSBY_SQUEAK_API_HOST,
|
||||
organizationId: process.env.GATSBY_SQUEAK_ORG_ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -98,7 +98,7 @@ module.exports = {
|
||||
{
|
||||
query: `
|
||||
{
|
||||
questions: allQuestion(filter: {permalink: {ne: null}}) {
|
||||
questions: allSqueakQuestion(filter: {permalink: {ne: null}}) {
|
||||
nodes {
|
||||
id
|
||||
title: subject
|
||||
|
||||
@@ -23,25 +23,6 @@ export const createResolvers: GatsbyNode['createResolvers'] = ({ createResolvers
|
||||
},
|
||||
},
|
||||
},
|
||||
Reply: {
|
||||
teamMember: {
|
||||
type: `Mdx`,
|
||||
resolve: async (source, args, context, info) => {
|
||||
const team = context.nodeModel.runQuery({
|
||||
type: `Mdx`,
|
||||
query: {
|
||||
filter: {
|
||||
frontmatter: { name: { eq: source?.fullName } },
|
||||
fields: { slug: { regex: '/^/team/' } },
|
||||
},
|
||||
},
|
||||
firstOnly: true,
|
||||
})
|
||||
const teamMember = await team
|
||||
return teamMember
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
createResolvers(resolvers)
|
||||
}
|
||||
|
||||
@@ -28,11 +28,12 @@ export const onCreateNode: GatsbyNode['onCreateNode'] = async ({
|
||||
if (node.internal.type === `MarkdownRemark` || node.internal.type === 'Mdx') {
|
||||
const parent = getNode(node.parent)
|
||||
if (
|
||||
parent?.internal.type === 'Reply' ||
|
||||
parent?.internal.type === 'SqueakReply' ||
|
||||
parent?.internal.type === 'PostHogPull' ||
|
||||
parent?.internal.type === 'PostHogIssue'
|
||||
)
|
||||
return
|
||||
|
||||
const slug = createFilePath({ node, getNode, basePath: `pages` })
|
||||
|
||||
createNodeField({
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"puppeteer-core": "^13.0.1",
|
||||
"qs": "^6.11.1",
|
||||
"query-string": "^6.13.1",
|
||||
"rc-slider": "^9.7.2",
|
||||
"react": "^16.13.1",
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
const { createRemoteFileNode } = require(`gatsby-source-filesystem`)
|
||||
const fetch = require('node-fetch')
|
||||
const slugify = require('slugify')
|
||||
|
||||
exports.sourceNodes = async ({ actions, createContentDigest, createNodeId, cache, store }, pluginOptions) => {
|
||||
const { apiHost, organizationId } = pluginOptions
|
||||
const { createNode, createParentChildLink } = actions
|
||||
|
||||
const getQuestions = async () => {
|
||||
const response = await fetch(
|
||||
`${apiHost}/api/v1/questions?organizationId=${organizationId}&perPage=1000&published=true`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status !== 200) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { questions } = await response.json()
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
const createReplies = (node, replies) => {
|
||||
for (const reply of replies) {
|
||||
const { body, profile: user, id, subject, created_at } = reply
|
||||
const replyId = createNodeId(`reply-${id}`)
|
||||
const replyNode = {
|
||||
id: replyId,
|
||||
parent: null,
|
||||
children: [],
|
||||
internal: {
|
||||
type: `Reply`,
|
||||
contentDigest: createContentDigest(body),
|
||||
content: body,
|
||||
mediaType: 'text/markdown',
|
||||
},
|
||||
name: user?.first_name || 'Anonymous',
|
||||
imageURL: user?.avatar,
|
||||
subject,
|
||||
created_at: new Date(created_at),
|
||||
}
|
||||
createNode(replyNode)
|
||||
createParentChildLink({ parent: node, child: replyNode })
|
||||
}
|
||||
}
|
||||
|
||||
const questions = await getQuestions()
|
||||
|
||||
questions.forEach((question) => {
|
||||
const node = {
|
||||
id: createNodeId(`question-${question.id}`),
|
||||
parent: null,
|
||||
children: [],
|
||||
internal: {
|
||||
type: `Question`,
|
||||
contentDigest: createContentDigest(question),
|
||||
},
|
||||
...question,
|
||||
}
|
||||
|
||||
createNode(node)
|
||||
createReplies(node, question.replies)
|
||||
})
|
||||
|
||||
const topics = await fetch(`${apiHost}/api/topics?organizationId=${organizationId}`).then((res) => res.json())
|
||||
|
||||
topics.forEach((topic) => {
|
||||
const { label, id } = topic
|
||||
const node = {
|
||||
id: createNodeId(`squeak-topic-${label}`),
|
||||
parent: null,
|
||||
children: [],
|
||||
internal: {
|
||||
type: `SqueakTopic`,
|
||||
contentDigest: createContentDigest(topic),
|
||||
},
|
||||
label: label,
|
||||
topicId: id,
|
||||
slug: slugify(label, { lower: true }),
|
||||
}
|
||||
createNode(node)
|
||||
})
|
||||
|
||||
const topicGroups = await fetch(`${apiHost}/api/topic-groups?organizationId=${organizationId}`).then((res) =>
|
||||
res.json()
|
||||
)
|
||||
|
||||
topicGroups.forEach((topicGroup) => {
|
||||
const { label, id, topic } = topicGroup
|
||||
|
||||
const node = {
|
||||
id: createNodeId(`squeak-topic-group-${label}`),
|
||||
parent: null,
|
||||
children: [],
|
||||
internal: {
|
||||
type: `SqueakTopicGroup`,
|
||||
contentDigest: createContentDigest(topicGroup),
|
||||
},
|
||||
label: label,
|
||||
topicId: id,
|
||||
topics: topic.map((topic) => {
|
||||
return {
|
||||
...topic,
|
||||
slug: slugify(topic.label, { lower: true }),
|
||||
}
|
||||
}),
|
||||
slug: slugify(label, { lower: true }),
|
||||
}
|
||||
createNode(node)
|
||||
})
|
||||
|
||||
const roadmap = await fetch(`${apiHost}/api/roadmap?organizationId=${organizationId}`).then((res) => res.json())
|
||||
|
||||
for (const roadmapItem of roadmap) {
|
||||
const { title, github_urls, image, id } = roadmapItem
|
||||
|
||||
const node = {
|
||||
...roadmapItem,
|
||||
roadmapId: id,
|
||||
id: createNodeId(`squeak-roadmap-${title}`),
|
||||
parent: null,
|
||||
children: [],
|
||||
internal: {
|
||||
type: `SqueakRoadmap`,
|
||||
contentDigest: createContentDigest(roadmapItem),
|
||||
},
|
||||
}
|
||||
|
||||
if (image) {
|
||||
const url = `https://res.cloudinary.com/${image.cloud_name}/v${image.version}/${image.publicId}.${image.format}`
|
||||
|
||||
const fileNode = await createRemoteFileNode({
|
||||
url,
|
||||
parentNodeId: node.id,
|
||||
createNode,
|
||||
createNodeId,
|
||||
cache,
|
||||
store,
|
||||
})
|
||||
node.thumbnail___NODE = fileNode?.id
|
||||
}
|
||||
|
||||
const otherLinks = github_urls.filter((url) => !url.includes('github.com'))
|
||||
node.otherLinks = otherLinks
|
||||
if (github_urls.length > 0 && process.env.GITHUB_API_KEY) {
|
||||
node.githubPages = await Promise.all(
|
||||
github_urls
|
||||
.filter((url) => url.includes('github.com'))
|
||||
.map((url) => {
|
||||
const split = url.split('/')
|
||||
const type = split[5]
|
||||
const number = split[6]
|
||||
const org = split[3]
|
||||
const repo = split[4]
|
||||
const ghURL = `https://api.github.com/repos/${org}/${repo}/issues/${number}`
|
||||
return fetch(ghURL, {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITHUB_API_KEY}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.reactions) {
|
||||
data.reactions.plus1 = data.reactions['+1']
|
||||
data.reactions.minus1 = data.reactions['-1']
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
})
|
||||
)
|
||||
}
|
||||
createNode(node)
|
||||
}
|
||||
}
|
||||
|
||||
exports.onCreateNode = async ({ node, actions, store, cache, createNodeId }) => {
|
||||
const { createNode } = actions
|
||||
if (node.internal.type === 'Reply') {
|
||||
async function createImageNode(imageURL) {
|
||||
return createRemoteFileNode({
|
||||
url: imageURL,
|
||||
parentNodeId: node.id,
|
||||
createNode,
|
||||
createNodeId,
|
||||
cache,
|
||||
store,
|
||||
}).catch((e) => console.error(e))
|
||||
}
|
||||
if (node.imageURL) {
|
||||
const imageNode = await createImageNode(node.imageURL)
|
||||
node.avatar___NODE = imageNode && imageNode.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.createSchemaCustomization = async ({ actions }) => {
|
||||
const { createTypes } = actions
|
||||
|
||||
createTypes(`
|
||||
type Question implements Node {
|
||||
subject: String
|
||||
slug: [String]
|
||||
imageURL: String
|
||||
replies: [Reply]
|
||||
avatar: File @link(from: "avatar___NODE")
|
||||
}
|
||||
|
||||
type Reply implements Node {
|
||||
avatar: File @link(from: "avatar___NODE")
|
||||
fullName: String
|
||||
subject: String
|
||||
}
|
||||
|
||||
type SqueakTeam {
|
||||
name: String,
|
||||
}
|
||||
|
||||
type SqueakGitHubReactions {
|
||||
hooray: Int,
|
||||
heart: Int,
|
||||
eyes: Int,
|
||||
_1: Int,
|
||||
plus1: Int,
|
||||
minus1: Int
|
||||
}
|
||||
|
||||
type SqueakGitHubPage {
|
||||
title: String,
|
||||
html_url: String,
|
||||
number: Int,
|
||||
closed_at: Date,
|
||||
reactions: SqueakGitHubReactions,
|
||||
}
|
||||
|
||||
type SqueakRoadmap implements Node {
|
||||
title: String,
|
||||
category: String
|
||||
beta_available: Boolean,
|
||||
complete: Boolean,
|
||||
description: String,
|
||||
team: SqueakTeam,
|
||||
otherLinks: [String],
|
||||
githubPages: [SqueakGitHubPage],
|
||||
milestone: Boolean,
|
||||
thumbnail: File @link(from: "thumbnail___NODE")
|
||||
}
|
||||
`)
|
||||
}
|
||||
413
plugins/gatsby-source-squeak/gatsby-node.ts
Normal file
413
plugins/gatsby-source-squeak/gatsby-node.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { GatsbyNode } from 'gatsby'
|
||||
import fetch from 'node-fetch'
|
||||
import qs from 'qs'
|
||||
|
||||
export const sourceNodes: GatsbyNode['sourceNodes'] = async (
|
||||
{ actions, createContentDigest, createNodeId, cache },
|
||||
pluginOptions
|
||||
) => {
|
||||
const { apiHost } = pluginOptions
|
||||
const { createNode } = actions
|
||||
|
||||
// Fetch all profiles
|
||||
let page = 1
|
||||
while (true) {
|
||||
let profileQuery = qs.stringify(
|
||||
{
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: 100,
|
||||
},
|
||||
populate: ['avatar'],
|
||||
},
|
||||
{
|
||||
encodeValuesOnly: true, // prettify URL
|
||||
}
|
||||
)
|
||||
|
||||
const profiles = await fetch(`${apiHost}/api/profiles?${profileQuery}`).then((res) => res.json())
|
||||
|
||||
for (const profile of profiles.data) {
|
||||
const { avatar, ...profileData } = profile.attributes
|
||||
|
||||
createNode({
|
||||
type: `SqueakProfile`,
|
||||
id: createNodeId(`squeak-profile-${profile.id}`),
|
||||
squeakId: profile.id,
|
||||
internal: {
|
||||
contentDigest: createContentDigest(profileData),
|
||||
type: `SqueakProfile`,
|
||||
},
|
||||
avatar: avatar.data?.attributes,
|
||||
...profileData,
|
||||
})
|
||||
|
||||
/*async function createImageNode(imageURL) {
|
||||
return createRemoteFileNode({
|
||||
url: imageURL,
|
||||
parentNodeId: node.id,
|
||||
createNode,
|
||||
createNodeId,
|
||||
cache,
|
||||
store,
|
||||
}).catch((e) => console.error(e))
|
||||
}
|
||||
if (node.imageURL) {
|
||||
const imageNode = await createImageNode(node.imageURL)
|
||||
node.avatar___NODE = imageNode && imageNode.id
|
||||
}*/
|
||||
}
|
||||
|
||||
if (profiles.meta.pagination.page >= profiles.meta.pagination.pageCount) {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
// Fetch all questions
|
||||
page = 1
|
||||
while (true) {
|
||||
let questionQuery = qs.stringify({
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: 100,
|
||||
},
|
||||
populate: {
|
||||
profile: {
|
||||
fields: ['id'],
|
||||
},
|
||||
replies: {
|
||||
populate: {
|
||||
profile: {
|
||||
fields: ['id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
topics: {
|
||||
fields: ['id'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const questions = await fetch(`${apiHost}/api/questions?${questionQuery}`).then((res) => res.json())
|
||||
|
||||
for (let question of questions.data) {
|
||||
const { topics, replies, profile, ...rest } = question.attributes
|
||||
|
||||
if (!profile.data?.id) {
|
||||
console.warn(`Question ${question.id} has no profile`)
|
||||
continue
|
||||
}
|
||||
|
||||
const filteredReplies = replies.data.filter((reply) => reply.attributes.profile.data?.id)
|
||||
|
||||
createNode({
|
||||
type: `SqueakQuestion`,
|
||||
id: createNodeId(`squeak-question-${question.id}`),
|
||||
squeakId: question.id,
|
||||
internal: {
|
||||
contentDigest: createContentDigest(question),
|
||||
type: `SqueakQuestion`,
|
||||
},
|
||||
...(profile.data && { profile: { id: createNodeId(`squeak-profile-${profile.data.id}`) } }),
|
||||
replies: filteredReplies.map((reply) => ({
|
||||
id: createNodeId(`squeak-reply-${reply.id}`),
|
||||
})),
|
||||
topics: topics.data.map((topic) => ({
|
||||
id: createNodeId(`squeak-topic-${topic.id}`),
|
||||
})),
|
||||
...rest,
|
||||
})
|
||||
|
||||
for (let reply of filteredReplies) {
|
||||
const { profile, ...replyData } = reply.attributes
|
||||
|
||||
createNode({
|
||||
type: `SqueakReply`,
|
||||
id: createNodeId(`squeak-reply-${reply.id}`),
|
||||
squeakId: reply.id,
|
||||
internal: {
|
||||
contentDigest: createContentDigest(replyData.body),
|
||||
type: `SqueakReply`,
|
||||
content: replyData.body,
|
||||
mediaType: 'text/markdown',
|
||||
},
|
||||
...(profile.data && { profile: { id: createNodeId(`squeak-profile-${profile.data.id}`) } }),
|
||||
...replyData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (questions.meta.pagination.page >= questions.meta.pagination.pageCount) {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
// Fetch all topic groups
|
||||
let query = qs.stringify({
|
||||
populate: {
|
||||
topics: {
|
||||
fields: ['id'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const topicGroups = await fetch(`${apiHost}/api/topic-groups?${query}`).then((res) => res.json())
|
||||
|
||||
topicGroups.data.forEach((topicGroup) => {
|
||||
const { topics, ...rest } = topicGroup.attributes
|
||||
|
||||
const node = {
|
||||
id: createNodeId(`squeak-topic-group-${topicGroup.id}`),
|
||||
internal: {
|
||||
type: `SqueakTopicGroup`,
|
||||
contentDigest: createContentDigest(topicGroup),
|
||||
},
|
||||
...rest,
|
||||
topics: topics.data.map((topic) => ({
|
||||
id: createNodeId(`squeak-topic-${topic.id}`),
|
||||
})),
|
||||
}
|
||||
createNode(node)
|
||||
})
|
||||
|
||||
// Fetch all topics
|
||||
let topicQuery = qs.stringify(
|
||||
{
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
encodeValuesOnly: true, // prettify URL
|
||||
}
|
||||
)
|
||||
|
||||
const topics = await fetch(`${apiHost}/api/topics?${topicQuery}`).then((res) => res.json())
|
||||
|
||||
for (const topic of topics.data) {
|
||||
createNode({
|
||||
id: createNodeId(`squeak-topic-${topic.id}`),
|
||||
squeakId: topic.id,
|
||||
internal: {
|
||||
type: `SqueakTopic`,
|
||||
contentDigest: createContentDigest(topic),
|
||||
},
|
||||
...topic.attributes,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch all teams
|
||||
let teamQuery = qs.stringify(
|
||||
{
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
},
|
||||
populate: {
|
||||
roadmaps: {
|
||||
fields: ['id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
encodeValuesOnly: true, // prettify URL
|
||||
}
|
||||
)
|
||||
|
||||
const teams = await fetch(`${apiHost}/api/teams?${teamQuery}`).then((res) => res.json())
|
||||
|
||||
for (const team of teams.data) {
|
||||
const { roadmaps, ...rest } = team.attributes
|
||||
|
||||
createNode({
|
||||
id: createNodeId(`squeak-team-${team.id}`),
|
||||
squeakId: team.id,
|
||||
internal: {
|
||||
type: `SqueakTeam`,
|
||||
contentDigest: createContentDigest(team),
|
||||
},
|
||||
...rest,
|
||||
roadmaps: roadmaps.data.map((roadmap) => ({
|
||||
id: createNodeId(`squeak-roadmap-${roadmap.id}`),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch all roadmaps
|
||||
let roadmapQuery = qs.stringify({
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
},
|
||||
populate: {
|
||||
teams: {
|
||||
fields: ['id'],
|
||||
},
|
||||
image: {
|
||||
fields: ['id', 'url'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const roadmaps = await fetch(`${apiHost}/api/roadmaps?${roadmapQuery}`).then((res) => res.json())
|
||||
|
||||
for (const roadmap of roadmaps.data) {
|
||||
const { teams, githubUrls, image, ...rest } = roadmap.attributes
|
||||
|
||||
const node = {
|
||||
id: createNodeId(`squeak-roadmap-${roadmap.id}`),
|
||||
squeakId: roadmap.id,
|
||||
internal: {
|
||||
type: `SqueakRoadmap`,
|
||||
contentDigest: createContentDigest(roadmap.attributes),
|
||||
},
|
||||
...rest,
|
||||
...(image.data && { id: createNodeId(`squeak-image-${image.data.id}`), url: image.data.attributes.url }),
|
||||
teams: roadmap.attributes.teams.data.map((team) => ({
|
||||
id: createNodeId(`squeak-team-${team.id}`),
|
||||
})),
|
||||
}
|
||||
|
||||
/*if (image) {
|
||||
const url = `https://res.cloudinary.com/${image.cloud_name}/v${image.version}/${image.publicId}.${image.format}`
|
||||
|
||||
const fileNode = await createRemoteFileNode({
|
||||
url,
|
||||
parentNodeId: node.id,
|
||||
createNode,
|
||||
createNodeId,
|
||||
cache,
|
||||
})
|
||||
node.thumbnail___NODE = fileNode?.id
|
||||
}*/
|
||||
|
||||
if (githubUrls.length > 0 && process.env.GITHUB_API_KEY) {
|
||||
node.githubPages = await Promise.all(
|
||||
githubUrls
|
||||
.filter((url) => url.includes('github.com'))
|
||||
.map((url) => {
|
||||
const [owner, repo, type, issueNum] = url.split('/').slice(3)
|
||||
const ghURL = `https://api.github.com/repos/${owner}/${repo}/issues/${issueNum}`
|
||||
|
||||
return fetch(ghURL, {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITHUB_API_KEY}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.reactions) {
|
||||
data.reactions.plus1 = data.reactions['+1']
|
||||
data.reactions.minus1 = data.reactions['-1']
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
})
|
||||
)
|
||||
} else {
|
||||
node.githubPages = []
|
||||
}
|
||||
|
||||
createNode(node)
|
||||
}
|
||||
}
|
||||
|
||||
export const createSchemaCustomization: GatsbyNode['createSchemaCustomization'] = async ({ actions }) => {
|
||||
const { createTypes } = actions
|
||||
|
||||
createTypes(`
|
||||
type StrapiImage implements Node {
|
||||
id: ID!
|
||||
url: String!
|
||||
}
|
||||
|
||||
type SqueakProfile implements Node {
|
||||
id: ID!
|
||||
squeakId: Int!
|
||||
firstName: String
|
||||
lastName: String
|
||||
}
|
||||
|
||||
type SqueakQuestion implements Node {
|
||||
id: ID!
|
||||
squeakId: Int!
|
||||
body: String!
|
||||
createdAt: Date! @dateformat
|
||||
profile: SqueakProfile! @link(by: "id", from: "profile.id")
|
||||
replies: [SqueakReply!] @link(by: "id", from: "replies.id")
|
||||
topics: [SqueakTopic!] @link(by: "id", from: "topics.id")
|
||||
}
|
||||
|
||||
type SqueakReply implements Node {
|
||||
id: ID!
|
||||
squeakId: Int!
|
||||
body: String!
|
||||
createdAt: Date! @dateformat
|
||||
profile: SqueakProfile! @link(by: "id", from: "profile.id")
|
||||
question: SqueakQuestion! @link(from: "id", to: "question")
|
||||
}
|
||||
|
||||
type SqueakTopicGroup implements Node {
|
||||
id: ID!
|
||||
squeakId: Int!
|
||||
slug: String
|
||||
label: String!
|
||||
topics: [SqueakTopic!] @link(by: "id", from: "topics.id")
|
||||
}
|
||||
|
||||
type SqueakTopic implements Node {
|
||||
id: ID!
|
||||
squeakId: Int!
|
||||
slug: String!
|
||||
label: String!
|
||||
}
|
||||
|
||||
type SqueakTeam implements Node {
|
||||
id: ID!
|
||||
squeakId: Int!
|
||||
name: String!
|
||||
profiles: [SqueakProfile!] @link(by: "id", from: "profiles.id")
|
||||
roadmaps: [SqueakRoadmap!] @link(by: "id", from: "roadmaps.id")
|
||||
}
|
||||
|
||||
type SqueakRoadmap implements Node {
|
||||
id: ID!
|
||||
squeakId: Int!
|
||||
title: String!
|
||||
description: String!
|
||||
image: StrapiImage
|
||||
slug: String!
|
||||
dateCompleted: Date @dateformat
|
||||
projectedCompletion: Date @dateformat
|
||||
category: String!
|
||||
milestone: Boolean!
|
||||
completed: Boolean!
|
||||
betaAvailable: Boolean!
|
||||
githubUrls: [String!]!
|
||||
githubPages: [GithubPage!]!
|
||||
teams: [SqueakTeam!] @link(by: "id", from: "teams.id")
|
||||
}
|
||||
|
||||
type GithubPage {
|
||||
title: String
|
||||
html_url: String
|
||||
number: String
|
||||
closed_at: String
|
||||
reactions: GithubReactions
|
||||
}
|
||||
|
||||
type GithubReactions {
|
||||
hooray: Int
|
||||
heart: Int
|
||||
eyes: Int
|
||||
plus1: Int
|
||||
minus1: Int
|
||||
}
|
||||
|
||||
`)
|
||||
}
|
||||
250
squeak_migration.sql
Normal file
250
squeak_migration.sql
Normal file
@@ -0,0 +1,250 @@
|
||||
-- Importing existing records
|
||||
|
||||
-- Import users
|
||||
SELECT
|
||||
DISTINCT users.email,
|
||||
sp.first_name AS first_name,
|
||||
sp.last_name AS last_name,
|
||||
users.email AS username,
|
||||
'local' AS provider,
|
||||
users.encrypted_password AS password,
|
||||
users.confirmed_at IS NULL AS confirmed,
|
||||
false AS blocked,
|
||||
users.created_at,
|
||||
1 AS created_by_id,
|
||||
1 AS updated_by_id
|
||||
FROM users
|
||||
INNER JOIN (
|
||||
SELECT * FROM squeak_profiles WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626'
|
||||
) sp on users.id = sp.user_id;
|
||||
|
||||
-- Import profiles
|
||||
-- ALTER SEQUENCE profiles_id_seq RESTART WITH 1;
|
||||
SELECT row_number() OVER (ORDER BY squeak_profiles.created_at) AS id,
|
||||
first_name,
|
||||
last_name,
|
||||
biography,
|
||||
company,
|
||||
company_role,
|
||||
github,
|
||||
linkedin,
|
||||
location,
|
||||
twitter,
|
||||
website,
|
||||
now() AS published_at,
|
||||
squeak_profiles.created_at
|
||||
FROM squeak_profiles
|
||||
-- INNER JOIN users u on squeak_profiles.user_id = u.id
|
||||
-- INNER JOIN up_users uu on u.email = uu.email
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626'
|
||||
ORDER BY squeak_profiles.created_at;
|
||||
|
||||
-- Import existing avatars (Manually done by adjusting OFFSET and LIMIT
|
||||
SELECT *
|
||||
FROM (SELECT DISTINCT avatar
|
||||
FROM squeak_profiles
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626'
|
||||
AND length(avatar) > 0
|
||||
AND avatar NOT ILIKE '%avatars.slack-edge.com%') ad
|
||||
OFFSET 100 LIMIT 10;
|
||||
|
||||
-- Import questions
|
||||
SELECT
|
||||
squeak_messages.id AS id,
|
||||
subject,
|
||||
CASE published WHEN TRUE THEN now() END AS published_at,
|
||||
permalink,
|
||||
resolved,
|
||||
now() AS updated_at,
|
||||
reply.body,
|
||||
created_at
|
||||
FROM squeak_messages
|
||||
INNER JOIN (
|
||||
SELECT id, message_id, body
|
||||
FROM (SELECT id,
|
||||
body,
|
||||
message_id,
|
||||
rank() OVER (
|
||||
PARTITION BY message_id ORDER BY created_at ASC
|
||||
) rank
|
||||
FROM squeak_replies) replies
|
||||
WHERE replies.rank = 1
|
||||
) reply ON reply.message_id = squeak_messages.id
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import question slugs
|
||||
SELECT id, unnest(slug) AS slug FROM squeak_messages WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import replies
|
||||
SELECT ranked_replies.id, body, created_at, CASE published WHEN TRUE THEN now() END AS published_at
|
||||
FROM (SELECT *,
|
||||
rank() OVER (PARTITION BY message_id ORDER BY created_at ASC) rank
|
||||
FROM squeak_replies
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626') ranked_replies
|
||||
WHERE rank > 1;
|
||||
|
||||
-- Import topics
|
||||
SELECT id, label, created_at, now() AS updated_at, now() AS published_at, 1 AS created_by_id, 1 AS updated_by_id
|
||||
FROM squeak_topics
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import topic groups
|
||||
SELECT id, label, created_at, now() AS published_at, 1 AS created_by_id, 1 AS updated_by_id
|
||||
FROM squeak_topic_groups
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import roadmaps
|
||||
SELECT id,
|
||||
title,
|
||||
date_completed,
|
||||
complete,
|
||||
NULL AS slug,
|
||||
description,
|
||||
projected_completion_date AS projected_complete,
|
||||
array_to_json(github_urls)::jsonb AS github_urls,
|
||||
category,
|
||||
milestone,
|
||||
beta_available,
|
||||
created_at,
|
||||
1 AS created_by_id,
|
||||
1 AS updated_by_id,
|
||||
now() AS published_at
|
||||
FROM squeak_roadmaps
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import teams
|
||||
SELECT id, name, created_at, now() AS published_at, 1 AS created_by_id, 1 AS updated_by_id
|
||||
FROM squeak_teams
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
|
||||
-- Link records together
|
||||
|
||||
|
||||
-- Connect profiles to users
|
||||
SELECT uu.id AS user_id, profile_id FROM (
|
||||
SELECT u.email, row_number() OVER (ORDER BY squeak_profiles.created_at) AS profile_id FROM squeak_profiles
|
||||
LEFT JOIN users u on squeak_profiles.user_id = u.id
|
||||
WHERE squeak_profiles.organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626'
|
||||
ORDER BY squeak_profiles.created_at
|
||||
) profiles
|
||||
INNER JOIN up_users uu on profiles.email = uu.email;
|
||||
|
||||
-- Import connections between profiles and questions
|
||||
SELECT rank_id AS profile_id, squeak_messages.id AS question_id FROM squeak_messages
|
||||
INNER JOIN (
|
||||
SELECT *, row_number() OVER (ORDER BY created_at) rank_id FROM squeak_profiles WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626' ORDER BY created_at
|
||||
) ranked_profiles ON profile_id = ranked_profiles.id
|
||||
WHERE squeak_messages.organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Link avatars to profiles
|
||||
SELECT
|
||||
files.id AS file_id,
|
||||
rp.rank_id AS related_id,
|
||||
'api::profile.profile' AS related_type,
|
||||
'avatar' AS field
|
||||
FROM files
|
||||
INNER JOIN squeak_profiles sp ON sp.avatar ILIKE '%' || name || '%'
|
||||
INNER JOIN (
|
||||
SELECT *, row_number() OVER (ORDER BY created_at) rank_id FROM squeak_profiles WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626' ORDER BY created_at
|
||||
) rp on rp.id = sp.id;
|
||||
|
||||
-- Import links between topics and topic groups
|
||||
SELECT id AS topic_id, topic_group_id
|
||||
FROM squeak_topics
|
||||
WHERE topic_group_id IS NOT NULL
|
||||
AND organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import connections between questions and topics
|
||||
SELECT question_id, topic_id, sm.created_at FROM squeak_question_topics
|
||||
INNER JOIN squeak_messages sm on squeak_question_topics.question_id = sm.id
|
||||
WHERE sm.organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import connection between profiles and teams into team_members_links
|
||||
SELECT st.id AS team_id,
|
||||
rank_id AS profile_id
|
||||
FROM users
|
||||
INNER JOIN (SELECT *, row_number() OVER (ORDER BY created_at) AS rank_id
|
||||
FROM squeak_profiles
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626'
|
||||
ORDER BY created_at) sp on users.id = sp.user_id
|
||||
INNER JOIN squeak_teams st on sp.team_id = st.id
|
||||
WHERE sp.organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import connections between teams and roadmaps
|
||||
SELECT id AS roadmap_id, "teamId" AS team_id
|
||||
FROM squeak_roadmaps
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626' AND "teamId" IS NOT NULL;
|
||||
|
||||
-- Import connections between replies and profiles
|
||||
SELECT DISTINCT ranked_replies.id AS reply_id, rank_id AS profile_id FROM (
|
||||
SELECT
|
||||
*,
|
||||
rank() OVER (PARTITION BY message_id ORDER BY created_at ASC) rank
|
||||
FROM squeak_replies
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626'
|
||||
) ranked_replies
|
||||
INNER JOIN (
|
||||
SELECT *, row_number() OVER (ORDER BY created_at) AS rank_id FROM squeak_profiles WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626' ORDER BY created_at
|
||||
) profiles ON profile_id = profiles.id
|
||||
INNER JOIN (
|
||||
SELECT up_users.id, u.id AS user_id FROM up_users
|
||||
INNER JOIN users u ON u.email = up_users.email
|
||||
) joined_users ON joined_users.user_id = profiles.user_id
|
||||
WHERE rank > 1;
|
||||
|
||||
-- Import connections between replies and questions
|
||||
SELECT ranked_replies.id AS reply_id, message_id AS question_id FROM (
|
||||
SELECT
|
||||
*,
|
||||
rank() OVER (PARTITION BY message_id ORDER BY created_at ASC) rank
|
||||
FROM squeak_replies
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626'
|
||||
) ranked_replies
|
||||
WHERE rank > 1;
|
||||
|
||||
-- Import connections between questions and slugs
|
||||
SELECT
|
||||
id AS entity_id,
|
||||
id AS component_id,
|
||||
'questions.slugs' AS component_type,
|
||||
'slugs' AS field
|
||||
FROM squeak_messages WHERE organization_id='a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Import connections between questions and resolutions
|
||||
SELECT id AS question_id, resolved_reply_id AS reply_id FROM squeak_messages WHERE resolved_reply_id IS NOT NULL AND organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626';
|
||||
|
||||
-- Set all user roles to 'Authenticated'
|
||||
SELECT id AS user_id, 1 AS role_id FROM up_users;
|
||||
|
||||
|
||||
|
||||
-- Miscellaneous
|
||||
|
||||
-- Ensure all questions have profiles
|
||||
SELECT * FROM squeak_messages WHERE profile_id IS NULL;
|
||||
|
||||
UPDATE squeak_messages AS sm SET
|
||||
profile_id = qs.profile_id
|
||||
FROM (SELECT squeak_messages.id AS id,
|
||||
sr.profile_id AS profile_id
|
||||
FROM squeak_messages
|
||||
INNER JOIN (SELECT *,
|
||||
rank() OVER (PARTITION BY message_id ORDER BY created_at ASC) rank
|
||||
FROM squeak_replies
|
||||
WHERE organization_id = 'a898bcf2-c5b9-4039-82a0-a00220a8c626') sr
|
||||
on squeak_messages.id = sr.message_id
|
||||
WHERE squeak_messages.profile_id IS NULL
|
||||
AND rank = 1) qs WHERE qs.id = sm.id;
|
||||
|
||||
|
||||
-- Ensure all questions marked as 'resolved' have a corresponding reply
|
||||
UPDATE questions SET resolved = false
|
||||
WHERE questions.id IN (
|
||||
SELECT questions.id AS id
|
||||
FROM questions
|
||||
LEFT JOIN questions_resolved_by_links qrbl on questions.id = qrbl.question_id
|
||||
WHERE resolved = True AND qrbl.reply_id IS NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createHubSpotContact, squeakProfileLink } from 'lib/utils'
|
||||
import React from 'react'
|
||||
import { Squeak } from 'components/Squeak'
|
||||
|
||||
export default function CommunityQuestions() {
|
||||
return (
|
||||
<div className="max-w-[600px] mt-12">
|
||||
<h3 id="squeak-questions" className="mb-4">
|
||||
Questions?
|
||||
</h3>
|
||||
<Squeak
|
||||
profileLink={squeakProfileLink}
|
||||
onSignUp={(user) => createHubSpotContact(user)}
|
||||
apiHost={process.env.GATSBY_SQUEAK_API_HOST}
|
||||
organizationId={process.env.GATSBY_SQUEAK_ORG_ID}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/components/CommunityQuestions/index.tsx
Normal file
14
src/components/CommunityQuestions/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createHubSpotContact } from 'lib/utils'
|
||||
import React from 'react'
|
||||
import { Squeak } from 'components/Squeak'
|
||||
|
||||
export default function CommunityQuestions() {
|
||||
return (
|
||||
<div className="max-w-[600px] mt-12">
|
||||
<h3 id="squeak-questions" className="mb-4">
|
||||
Questions?
|
||||
</h3>
|
||||
<Squeak />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,11 +17,11 @@ export default function Timeline() {
|
||||
allSqueakRoadmap: { nodes },
|
||||
} = useStaticQuery(graphql`
|
||||
query {
|
||||
allSqueakRoadmap(filter: { milestone: { eq: true } }, sort: { fields: date_completed }) {
|
||||
allSqueakRoadmap(filter: { milestone: { eq: true } }, sort: { fields: dateCompleted }) {
|
||||
nodes {
|
||||
date_completed(formatString: "YYYY-MM-DD")
|
||||
dateCompleted(formatString: "YYYY-MM-DD")
|
||||
title
|
||||
projected_completion_date(formatString: "YYYY-MM-DD")
|
||||
projectedCompletion(formatString: "YYYY-MM-DD")
|
||||
category
|
||||
}
|
||||
}
|
||||
@@ -30,21 +30,21 @@ export default function Timeline() {
|
||||
|
||||
const pastEvents = groupBy(
|
||||
nodes.filter((node) => {
|
||||
const date = node.date_completed || node.projected_completion_date
|
||||
const date = node.dateCompleted || node.projectedCompletion
|
||||
return date && new Date(date) < new Date()
|
||||
}),
|
||||
(node) => {
|
||||
const date = new Date(node.date_completed || node.projected_completion_date)
|
||||
const date = new Date(node.dateCompleted || node.projectedCompletion)
|
||||
return date.getUTCFullYear()
|
||||
}
|
||||
)
|
||||
const futureEvents = groupBy(
|
||||
nodes.filter((node) => {
|
||||
const date = node.date_completed || node.projected_completion_date
|
||||
const date = node.dateCompleted || node.projectedCompletion
|
||||
return date && new Date(date) > new Date()
|
||||
}),
|
||||
(node) => {
|
||||
const date = new Date(node.date_completed || node.projected_completion_date)
|
||||
const date = new Date(node.dateCompleted || node.projectedCompletion)
|
||||
return date.getUTCFullYear()
|
||||
}
|
||||
)
|
||||
@@ -77,11 +77,11 @@ export default function Timeline() {
|
||||
<div className="max-w-screen-2xl mx-auto mdlg:grid grid-cols-3 shadow-xl">
|
||||
{Object.keys(pastEvents).map((year) => {
|
||||
const pastMonths = groupBy(pastEvents[year], (node) => {
|
||||
const date = new Date(node.date_completed || node.projected_completion_date)
|
||||
const date = new Date(node.dateCompleted || node.projectedCompletion)
|
||||
return months[date.getUTCMonth()]
|
||||
})
|
||||
const futureQuarters = groupBy(futureEvents[year], (node) => {
|
||||
const date = node.date_completed || node.projected_completion_date
|
||||
const date = node.dateCompleted || node.projectedCompletion
|
||||
return Math.floor(new Date(date).getUTCMonth() / 3 + 1)
|
||||
})
|
||||
return (
|
||||
|
||||
@@ -2052,7 +2052,7 @@ export const Markdown = (props: any) => {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g fill="#000">
|
||||
<g fill="currentColor">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="m15 10c-2.7614 0-5 2.2386-5 5v98c0 2.761 2.2386 5 5 5h178c2.761 0 5-2.239 5-5v-98c0-2.7614-2.239-5-5-5zm-15 5c0-8.28427 6.71573-15 15-15h178c8.284 0 15 6.71573 15 15v98c0 8.284-6.716 15-15 15h-178c-8.28427 0-15-6.716-15-15z"
|
||||
|
||||
@@ -22,10 +22,7 @@ const Layout = ({ children, className = '' }: { children: React.ReactNode; class
|
||||
|
||||
return (
|
||||
<SearchProvider>
|
||||
<UserProvider
|
||||
apiHost={process.env.GATSBY_SQUEAK_API_HOST as string}
|
||||
organizationId={process.env.GATSBY_SQUEAK_ORG_ID as string}
|
||||
>
|
||||
<UserProvider>
|
||||
<div className={className}>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function CallToAction({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
to: string
|
||||
className: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<CTA to={to} type="outline" size="sm" className={` !transition-all ${className}`}>
|
||||
|
||||
@@ -4,11 +4,12 @@ import React from 'react'
|
||||
import Header from '../Header'
|
||||
import RightCol from '../RightCol'
|
||||
import CallToAction from '../CallToAction'
|
||||
import { TwoCol, Wrapper } from '../Wrapper'
|
||||
import { Block, TwoCol, Wrapper } from '../Wrapper'
|
||||
import { graphql, navigate, useStaticQuery } from 'gatsby'
|
||||
import slugify from 'slugify'
|
||||
import { Avatar, Login } from '../../../../pages/community'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import getAvatarURL from '../../../Squeak/util/getAvatar'
|
||||
|
||||
interface ColMenuItems {
|
||||
title: string
|
||||
@@ -19,14 +20,15 @@ interface ColMenuItems {
|
||||
const Profile = () => {
|
||||
const { user } = useUser()
|
||||
const profile = user?.profile
|
||||
|
||||
return profile ? (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mt-4 mb-3">
|
||||
<Avatar src={profile.avatar} className="w-[40px] h-[40px]" />
|
||||
<Avatar src={getAvatarURL(profile)} className="w-[40px] h-[40px]" />
|
||||
<div>
|
||||
{
|
||||
<p className="m-0 font-semibold dark:text-white">
|
||||
{[profile?.first_name, profile?.last_name].filter(Boolean).join(' ')}
|
||||
{[profile.firstName, profile.lastName].filter(Boolean).join(' ')}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -140,12 +142,9 @@ export default function Docs({ referenceElement }: { referenceElement: HTMLDivEl
|
||||
<span>{label}</span>
|
||||
<span className="text-black dark:text-white opacity-50 font-semibold">
|
||||
(
|
||||
{
|
||||
allTopics.find(
|
||||
(topic) =>
|
||||
topic.topic === label
|
||||
)?.count
|
||||
}
|
||||
{allTopics.find(
|
||||
(topic) => topic.topic === label
|
||||
)?.count || '0'}
|
||||
)
|
||||
</span>
|
||||
</Link>
|
||||
@@ -166,32 +165,17 @@ export default function Docs({ referenceElement }: { referenceElement: HTMLDivEl
|
||||
</CallToAction>
|
||||
</div>
|
||||
</div>
|
||||
<TwoCol
|
||||
left={{
|
||||
title: 'Roadmap',
|
||||
cta: {
|
||||
url: '/roadmap',
|
||||
label: 'Browse roadmap',
|
||||
},
|
||||
children: (
|
||||
|
||||
<div className="border-t border-gray-accent-light border-dashed">
|
||||
<div className="max-w-3xl mx-auto xl:max-w-auto">
|
||||
<Block title="Roadmap" cta={{ url: '/roadmap', label: 'Browse roadmap' }}>
|
||||
<p className="m-0 text-[14px] dark:text-white">
|
||||
See what we're building, and help us decide what to build next.
|
||||
</p>
|
||||
),
|
||||
}}
|
||||
right={{
|
||||
title: 'Changelog',
|
||||
cta: {
|
||||
url: '/roadmap/changelog',
|
||||
label: 'Browse changelog',
|
||||
},
|
||||
children: (
|
||||
<p className="m-0 text-[14px] dark:text-white">
|
||||
Take a trip down memory lane of our top company and product milestones.
|
||||
</p>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-7 md:px-6 lg:px-9 border-t md:border-b-0 border-b md:mb-0 mb-4 border-gray-accent-light border-dashed">
|
||||
<div className="grid sm:grid-cols-2 items-center">
|
||||
<div>
|
||||
@@ -267,8 +251,8 @@ const query = graphql`
|
||||
}
|
||||
}
|
||||
}
|
||||
questions: allQuestion {
|
||||
allTopics: group(field: topics___topic___label) {
|
||||
questions: allSqueakQuestion {
|
||||
allTopics: group(field: topics___label) {
|
||||
topic: fieldValue
|
||||
count: totalCount
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const Block = ({
|
||||
cta: { url: string; label: string }
|
||||
}) => {
|
||||
return (
|
||||
<div className="py-6 md:px-6 xl:px-12 flex flex-col">
|
||||
<div className="py-6 md:px-6 xl:px-9 flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-[18px] font-bold mt-0 mb-2 text-black/70">{title}</h3>
|
||||
<div>{children}</div>
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import AnimatedBurger from 'components/AnimatedBurger'
|
||||
import Link from 'components/Link'
|
||||
import Logo from 'components/Logo'
|
||||
import { Search } from 'components/Icons/Icons'
|
||||
import { useSearch } from 'components/Search/SearchContext'
|
||||
import { motion } from 'framer-motion'
|
||||
import { graphql, useStaticQuery } from 'gatsby'
|
||||
import { useBreakpoint } from 'gatsby-plugin-breakpoints'
|
||||
import React, { useState } from 'react'
|
||||
import MenuItem from './MenuItem'
|
||||
import { OrgProvider } from 'components/Squeak'
|
||||
|
||||
export default function MainNav() {
|
||||
const data = useStaticQuery(graphql`
|
||||
query MainNavQuery {
|
||||
navsJson {
|
||||
main {
|
||||
title
|
||||
url
|
||||
classes
|
||||
hideBorder
|
||||
sub {
|
||||
component
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const [expanded, expandMenu] = useState(false)
|
||||
const [referenceElement, setReferenceElement] = useState(null)
|
||||
const menu = data?.navsJson?.main
|
||||
const breakpoints = useBreakpoint()
|
||||
const variants = {
|
||||
hidden: { height: 0 },
|
||||
shown: { height: 'auto' },
|
||||
}
|
||||
const menuLength = menu.length
|
||||
const halfMenu = Math.floor(menuLength / 2)
|
||||
|
||||
const { open } = useSearch()
|
||||
|
||||
return (
|
||||
<OrgProvider
|
||||
value={{
|
||||
organizationId: 'a898bcf2-c5b9-4039-82a0-a00220a8c626',
|
||||
apiHost: 'https://squeak.cloud',
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center max-w-screen-3xl mx-auto lg:relative">
|
||||
<button
|
||||
className="active:top-[0.5px] active:scale-[.98] lg:hidden bg-gray-accent-light dark:bg-gray-accent-dark w-[36px] h-[36px] flex items-center justify-center rounded-full"
|
||||
onClick={() => open('mobile-header')}
|
||||
>
|
||||
<Search className="w-[16px] h-[16px]" />
|
||||
</button>
|
||||
<Link
|
||||
className="text-primary hover:text-primary dark:text-primary-dark dark:hover:text-primary-dark block lg:hidden"
|
||||
to="/"
|
||||
>
|
||||
<Logo />
|
||||
</Link>
|
||||
<AnimatedBurger
|
||||
className="active:top-[0.5px] active:scale-[.98] bg-gray-accent-light dark:bg-gray-accent-dark lg:hidden w-[36px] h-[36px] flex items-center justify-center rounded-full"
|
||||
onClick={() => expandMenu(!expanded)}
|
||||
active={expanded}
|
||||
/>
|
||||
{(expanded || !breakpoints.md) && (
|
||||
<motion.nav
|
||||
className="lg:static absolute w-full left-0 top-full lg:overflow-visible overflow-hidden hidden lg:block"
|
||||
variants={breakpoints.md && variants}
|
||||
initial="hidden"
|
||||
animate="shown"
|
||||
>
|
||||
<div
|
||||
ref={setReferenceElement}
|
||||
className="z-50 flex justify-between lg:items-center items-start flex-col lg:flex-row bg-white dark:bg-gray-accent-dark lg:bg-transparent lg:dark:bg-transparent font-nav lg:py-0 py-5 text-white lg:dark:text-white lg:text-almost-black max-w-screen-3xl mx-auto lg:-mx-3"
|
||||
>
|
||||
<ul className="flex-1 flex flex-col lg:flex-row list-none m-0 p-0 w-full space-x-[1px] lg:w-auto">
|
||||
{menu.slice(0, halfMenu).map((menuItem, index) => {
|
||||
return (
|
||||
<MenuItem referenceElement={referenceElement} key={index} menuItem={menuItem} />
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{!breakpoints.md && (
|
||||
<Link
|
||||
className="text-primary hover:text-primary dark:text-primary-dark dark:hover:text-primary-dark hidden lg:block
|
||||
relative
|
||||
hover:scale-[1.01]
|
||||
active:top-[0.5px]
|
||||
active:scale-[.99]"
|
||||
to="/"
|
||||
>
|
||||
<Logo />
|
||||
</Link>
|
||||
)}
|
||||
<ul className="flex-1 flex flex-col lg:flex-row list-none m-0 p-0 w-full lg:w-auto justify-end">
|
||||
{menu.slice(halfMenu, menu.length).map((menuItem, index) => {
|
||||
return (
|
||||
<MenuItem referenceElement={referenceElement} key={index} menuItem={menuItem} />
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</motion.nav>
|
||||
)}
|
||||
</div>
|
||||
</OrgProvider>
|
||||
)
|
||||
}
|
||||
99
src/components/MainNav/index.tsx
Normal file
99
src/components/MainNav/index.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import AnimatedBurger from 'components/AnimatedBurger'
|
||||
import Link from 'components/Link'
|
||||
import Logo from 'components/Logo'
|
||||
import { Search } from 'components/Icons/Icons'
|
||||
import { useSearch } from 'components/Search/SearchContext'
|
||||
import { motion } from 'framer-motion'
|
||||
import { graphql, useStaticQuery } from 'gatsby'
|
||||
import { useBreakpoint } from 'gatsby-plugin-breakpoints'
|
||||
import React, { useState } from 'react'
|
||||
import MenuItem from './MenuItem'
|
||||
|
||||
export default function MainNav() {
|
||||
const data = useStaticQuery(graphql`
|
||||
query MainNavQuery {
|
||||
navsJson {
|
||||
main {
|
||||
title
|
||||
url
|
||||
classes
|
||||
hideBorder
|
||||
sub {
|
||||
component
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const [expanded, expandMenu] = useState(false)
|
||||
const [referenceElement, setReferenceElement] = useState(null)
|
||||
const menu = data?.navsJson?.main
|
||||
const breakpoints = useBreakpoint()
|
||||
const variants = {
|
||||
hidden: { height: 0 },
|
||||
shown: { height: 'auto' },
|
||||
}
|
||||
const menuLength = menu.length
|
||||
const halfMenu = Math.floor(menuLength / 2)
|
||||
|
||||
const { open } = useSearch()
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center max-w-screen-3xl mx-auto lg:relative">
|
||||
<button
|
||||
className="active:top-[0.5px] active:scale-[.98] lg:hidden bg-gray-accent-light dark:bg-gray-accent-dark w-[36px] h-[36px] flex items-center justify-center rounded-full"
|
||||
onClick={() => open('mobile-header')}
|
||||
>
|
||||
<Search className="w-[16px] h-[16px]" />
|
||||
</button>
|
||||
<Link
|
||||
className="text-primary hover:text-primary dark:text-primary-dark dark:hover:text-primary-dark block lg:hidden"
|
||||
to="/"
|
||||
>
|
||||
<Logo />
|
||||
</Link>
|
||||
<AnimatedBurger
|
||||
className="active:top-[0.5px] active:scale-[.98] bg-gray-accent-light dark:bg-gray-accent-dark lg:hidden w-[36px] h-[36px] flex items-center justify-center rounded-full"
|
||||
onClick={() => expandMenu(!expanded)}
|
||||
active={expanded}
|
||||
/>
|
||||
{(expanded || !breakpoints.md) && (
|
||||
<motion.nav
|
||||
className="lg:static absolute w-full left-0 top-full lg:overflow-visible overflow-hidden hidden lg:block"
|
||||
variants={breakpoints.md && variants}
|
||||
initial="hidden"
|
||||
animate="shown"
|
||||
>
|
||||
<div
|
||||
ref={setReferenceElement}
|
||||
className="z-50 flex justify-between lg:items-center items-start flex-col lg:flex-row bg-white dark:bg-gray-accent-dark lg:bg-transparent lg:dark:bg-transparent font-nav lg:py-0 py-5 text-white lg:dark:text-white lg:text-almost-black max-w-screen-3xl mx-auto lg:-mx-3"
|
||||
>
|
||||
<ul className="flex-1 flex flex-col lg:flex-row list-none m-0 p-0 w-full space-x-[1px] lg:w-auto">
|
||||
{menu.slice(0, halfMenu).map((menuItem, index) => {
|
||||
return <MenuItem referenceElement={referenceElement} key={index} menuItem={menuItem} />
|
||||
})}
|
||||
</ul>
|
||||
{!breakpoints.md && (
|
||||
<Link
|
||||
className="text-primary hover:text-primary dark:text-primary-dark dark:hover:text-primary-dark hidden lg:block
|
||||
relative
|
||||
hover:scale-[1.01]
|
||||
active:top-[0.5px]
|
||||
active:scale-[.99]"
|
||||
to="/"
|
||||
>
|
||||
<Logo />
|
||||
</Link>
|
||||
)}
|
||||
<ul className="flex-1 flex flex-col lg:flex-row list-none m-0 p-0 w-full lg:w-auto justify-end">
|
||||
{menu.slice(halfMenu, menu.length).map((menuItem, index) => {
|
||||
return <MenuItem referenceElement={referenceElement} key={index} menuItem={menuItem} />
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</motion.nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export default function Post({ children }: { children: React.ReactNode }) {
|
||||
: `minmax(auto, ${contentWidth}px) minmax(max-content, 1fr)`,
|
||||
}}
|
||||
className={
|
||||
'sm:pt-4 sm:pb-4 pb-0 sm:border-b border-gray-accent-light dark:border-gray-accent-dark border-dashed lg:grid lg:grid-flow-col items-center'
|
||||
'pt-4 sm:pb-4 pb-0 sm:border-b border-gray-accent-light dark:border-gray-accent-dark border-dashed lg:grid lg:grid-flow-col items-center'
|
||||
}
|
||||
>
|
||||
<div className={`${contentContainerClasses} grid-cols-1`}>
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Form, Field, Formik } from 'formik'
|
||||
import Button from 'components/CommunityQuestions/Button'
|
||||
import Icons, { Markdown } from 'components/Icons'
|
||||
import * as Yup from 'yup'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
|
||||
const fields = {
|
||||
first_name: {
|
||||
type: 'fname',
|
||||
label: 'First name',
|
||||
},
|
||||
last_name: {
|
||||
type: 'lname',
|
||||
label: 'Last name',
|
||||
},
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
},
|
||||
linkedin: {
|
||||
label: 'LinkedIn',
|
||||
},
|
||||
biography: {
|
||||
component: (
|
||||
<Field
|
||||
minRows={6}
|
||||
as={TextareaAutosize}
|
||||
type="text"
|
||||
name="biography"
|
||||
placeholder="280 characters or less..."
|
||||
className="py-2 px-4 text-lg rounded-md w-full dark:text-primary border-gray-accent-light border mb-2"
|
||||
/>
|
||||
),
|
||||
className: 'col-span-2',
|
||||
},
|
||||
}
|
||||
|
||||
function capitalizeFirstLetter(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1)
|
||||
}
|
||||
|
||||
const ValidationSchema = Yup.object().shape({
|
||||
first_name: Yup.string().required('Required'),
|
||||
last_name: Yup.string().required('Required'),
|
||||
website: Yup.string().url('Invalid URL').nullable(),
|
||||
github: Yup.string().url('Invalid URL').nullable(),
|
||||
linkedin: Yup.string().url('Invalid URL').nullable(),
|
||||
twitter: Yup.string().url('Invalid URL').nullable(),
|
||||
biography: Yup.string().max(3000, 'Please limit your bio to 3,000 characters, you wordsmith!').nullable(),
|
||||
})
|
||||
|
||||
export default function EditProfile({ profile, onSubmit }) {
|
||||
if (!profile) return null
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { first_name, last_name, website, github, linkedin, twitter, biography, id } = profile
|
||||
|
||||
const handleSubmit = async (values, { setSubmitting, resetForm }) => {
|
||||
setSubmitting(true)
|
||||
const profile = await fetch(
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/profiles/${id}?organizationId=${process.env.GATSBY_SQUEAK_ORG_ID}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(values),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
).then((res) => res.json())
|
||||
setSubmitting(false)
|
||||
onSubmit && onSubmit(profile)
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{ first_name, last_name, website, github, linkedin, twitter, biography }}
|
||||
validationSchema={ValidationSchema}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ isSubmitting, isValid, values, errors, setFieldValue, submitForm }) => {
|
||||
return (
|
||||
<Form className="m-0">
|
||||
<h2>Update profile</h2>
|
||||
<p>
|
||||
Tip: Be sure to use full URLs when adding links to your website, GitHub, LinkedIn and
|
||||
Twitter (start with https)
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 m-0">
|
||||
{Object.keys(values).map((key) => {
|
||||
const error = errors[key]
|
||||
const field = fields[key]
|
||||
const label = field?.label || capitalizeFirstLetter(key.replaceAll('_', ' '))
|
||||
return (
|
||||
<div className={field?.className ?? ''} key={key}>
|
||||
<label htmlFor={key}>{label}</label>
|
||||
{field?.component || (
|
||||
<Field
|
||||
className="py-2 px-4 text-lg rounded-md w-full dark:text-primary border-gray-accent-light border m-0"
|
||||
type={field?.type || 'text'}
|
||||
name={key}
|
||||
placeholder={label}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<span className="text-red font-semibold inline-block my-1">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className=" text-sm flex items-center space-x-2">
|
||||
<Markdown />
|
||||
<span>Markdown is allowed - even encouraged!</span>
|
||||
</p>
|
||||
|
||||
<Button loading={isSubmitting} disabled={isSubmitting || !isValid} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import React, { useState } from 'react'
|
||||
import { Dialog } from '@headlessui/react'
|
||||
import { Close } from 'components/Icons/Icons'
|
||||
|
||||
import { Form } from 'components/Squeak'
|
||||
import { QuestionForm } from 'components/Squeak'
|
||||
|
||||
type QuestionFormProps = {
|
||||
onSubmit: () => void
|
||||
@@ -30,12 +30,7 @@ export default function Questions(props: QuestionFormProps): JSX.Element {
|
||||
<Close className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<Form
|
||||
apiHost={process.env.GATSBY_SQUEAK_API_HOST as string}
|
||||
organizationId={process.env.GATSBY_SQUEAK_ORG_ID as string}
|
||||
initialView="question-form"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<QuestionForm initialView="question-form" formType="question" onSubmit={handleSubmit} />
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import SidebarSection from 'components/PostLayout/SidebarSection'
|
||||
import slugify from 'slugify'
|
||||
import React from 'react'
|
||||
import Link from 'components/Link'
|
||||
import { Question } from './index'
|
||||
import { QuestionData, StrapiRecord } from 'lib/strapi'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
|
||||
export const QuestionSidebar = ({ question }: { question: Question | undefined }) => {
|
||||
type QuestionSidebarProps = {
|
||||
question: StrapiRecord<QuestionData> | undefined
|
||||
}
|
||||
|
||||
export const QuestionSidebar = (props: QuestionSidebarProps) => {
|
||||
const { user } = useUser()
|
||||
|
||||
const { id, attributes: question } = props.question || {}
|
||||
|
||||
return question ? (
|
||||
<div>
|
||||
<SidebarSection title="Posted by">
|
||||
<div className="flex items-center space-x-2">
|
||||
{question.profile.avatar ? (
|
||||
<img className="w-8 h-8 rounded-full" src={question.profile.avatar} />
|
||||
{question.profile?.data?.attributes?.avatar?.data?.attributes?.url ? (
|
||||
<img
|
||||
className="w-8 h-8 rounded-full"
|
||||
src={question.profile.data.attributes.avatar.data.attributes.url}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
className="w-8 h-8 rounded-full bg-gray-accent-light"
|
||||
@@ -29,31 +38,33 @@ export const QuestionSidebar = ({ question }: { question: Question | undefined }
|
||||
></path>
|
||||
</svg>
|
||||
)}
|
||||
<Link to={`/community/profiles/${question.profile.id}`}>
|
||||
{question.profile.first_name
|
||||
? `${question.profile.first_name} ${question.profile.last_name}`
|
||||
<Link to={`/community/profiles/${question.profile?.data?.id}`}>
|
||||
{question.profile?.data?.attributes?.firstName
|
||||
? `${question.profile?.data?.attributes?.firstName} ${question.profile?.data?.attributes?.lastName}`
|
||||
: 'Anonymous'}
|
||||
</Link>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
|
||||
{question.topics.length > 0 && (
|
||||
{question?.topics?.data && question.topics.data.length > 0 && (
|
||||
<SidebarSection title="Topics">
|
||||
<div className="flex items-center space-x-2">
|
||||
{question.topics.map((topic) => (
|
||||
<Link
|
||||
key={topic.topic.label}
|
||||
to={`/questions/topics/${slugify(topic.topic.label, { lower: true })}`}
|
||||
>
|
||||
{topic.topic.label}
|
||||
{question.topics.data.map((topic) => (
|
||||
<Link key={topic.id} to={`/questions/topics/${topic.attributes.slug}`}>
|
||||
{topic.attributes.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{user?.isModerator && (
|
||||
<SidebarSection>
|
||||
<Link to={`https://squeak.cloud/question/${question.id}`}>View in Squeak!</Link>
|
||||
<Link
|
||||
to={`https://squeak.posthog.cc/admin/content-manager/collectionType/api::question.question/${id}`}
|
||||
>
|
||||
View in Squeak!
|
||||
</Link>
|
||||
</SidebarSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
import { dayFormat, dateToDays } from '../../utils'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import Link from 'components/Link'
|
||||
import { Question } from './index'
|
||||
import { QuestionData, StrapiResult } from 'lib/strapi'
|
||||
import getAvatarURL from '../Squeak/util/getAvatar'
|
||||
|
||||
type QuestionsTableProps = {
|
||||
questions: Question[]
|
||||
questions: Omit<StrapiResult<QuestionData[]>, 'meta'>
|
||||
isLoading: boolean
|
||||
size: number
|
||||
setSize: (size: number | ((_size: number) => number)) => any
|
||||
fetchMore: () => void
|
||||
hideLoadMore?: boolean
|
||||
className?: string
|
||||
}
|
||||
@@ -18,27 +15,33 @@ type QuestionsTableProps = {
|
||||
export const QuestionsTable = ({
|
||||
questions,
|
||||
isLoading,
|
||||
setSize,
|
||||
fetchMore,
|
||||
hideLoadMore,
|
||||
className = '',
|
||||
}: QuestionsTableProps) => {
|
||||
return (
|
||||
<ul className="m-0 p-0">
|
||||
<li className="divide-y divide-gray-accent-light divide-dashed dark:divide-gray-accent-dark list-none">
|
||||
{questions.length > 0
|
||||
? questions.filter(Boolean).map((question) => {
|
||||
const latestReply = question.replies[question.replies.length - 1]
|
||||
{questions.data.length > 0
|
||||
? questions.data.filter(Boolean).map((question) => {
|
||||
const {
|
||||
attributes: { profile, subject, permalink, body, replies },
|
||||
} = question
|
||||
|
||||
return (
|
||||
const numReplies = replies?.data?.length || 0
|
||||
|
||||
const avatar = getAvatarURL(profile?.data?.attributes)
|
||||
|
||||
return profile ? (
|
||||
<div key={question.id} className={`grid xl:grid-cols-4 sm:gap-2 py-4 ${className}`}>
|
||||
<div className="hidden sm:block">
|
||||
<a
|
||||
href={`/community/profiles/${question.profile.id}`}
|
||||
href={`/community/profiles/${profile.data.id}`}
|
||||
className="flex items-center text-sm mt-0.5 space-x-1 text-primary group"
|
||||
>
|
||||
<div className={`w-5 h-5 overflow-hidden rounded-full flex-shrink-0`}>
|
||||
{question.profile.avatar ? (
|
||||
<img className="w-full h-full" alt="" src={question.profile.avatar} />
|
||||
{avatar ? (
|
||||
<img className="w-full h-full" alt="" src={avatar} />
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 40 40"
|
||||
@@ -58,23 +61,23 @@ export const QuestionsTable = ({
|
||||
)}
|
||||
</div>
|
||||
<span className="text-primary dark:text-primary-dark font-medium opacity-60 group-hover:opacity-100 line-clamp-1">
|
||||
{question.profile.first_name} {question.profile.last_name}
|
||||
{profile.data.attributes.firstName} {profile.data.attributes.lastName}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Link
|
||||
to={`/questions/${question.permalink}`}
|
||||
to={`/questions/${permalink}`}
|
||||
className="block font-bold whitespace-normal leading-snug"
|
||||
>
|
||||
{question.subject}
|
||||
{subject}
|
||||
</Link>
|
||||
<a
|
||||
href={`/community/profiles/${question.profile.id}`}
|
||||
href={`/community/profiles/${profile.data.id}`}
|
||||
className="flex items-center text-sm mt-0.5 space-x-1 text-primary group sm:hidden"
|
||||
>
|
||||
<div className={`w-5 h-5 overflow-hidden rounded-full flex-shrink-0`}>
|
||||
{question.profile.avatar ? (
|
||||
{/*profile.avatar ? (
|
||||
<img className="w-full h-full" alt="" src={question.profile.avatar} />
|
||||
) : (
|
||||
<svg
|
||||
@@ -92,10 +95,10 @@ export const QuestionsTable = ({
|
||||
fill="#BFBFBC"
|
||||
></path>
|
||||
</svg>
|
||||
)}
|
||||
)}*/}
|
||||
</div>
|
||||
<div className="text-primary dark:text-primary-dark font-medium opacity-60 group-hover:opacity-100 line-clamp-1 my-1">
|
||||
{question.profile.first_name} {question.profile.last_name}
|
||||
{profile.data.attributes.firstName} {profile.data.attributes.lastName}
|
||||
</div>
|
||||
</a>
|
||||
{/*
|
||||
@@ -104,7 +107,7 @@ export const QuestionsTable = ({
|
||||
{dayFormat(dateToDays(latestReply.created_at))}
|
||||
</span>
|
||||
*/}
|
||||
{question.topics.map(({ topic }: { topic: { id: string; label: string } }) => {
|
||||
{/*{question.topics.map(({ topic }: { topic: { id: string; label: string } }) => {
|
||||
return (
|
||||
<Link
|
||||
key={topic.id}
|
||||
@@ -116,23 +119,19 @@ export const QuestionsTable = ({
|
||||
{topic.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
})}*/}
|
||||
<p className="break-words whitespace-normal line-clamp-2 text-sm m-0 mt-1">
|
||||
{question.replies[0].body}
|
||||
{body}
|
||||
</p>
|
||||
<Link
|
||||
to={`/questions/${question.permalink}`}
|
||||
to={`/questions/${permalink}`}
|
||||
className="whitespace-nowrap text-sm font-semibold"
|
||||
>
|
||||
{question.replies.length === 1 || question.replies.length > 2 ? (
|
||||
<>{question.replies.length - 1} replies</>
|
||||
) : (
|
||||
<>{question.replies.length - 1} reply</>
|
||||
)}
|
||||
{numReplies} {numReplies === 1 ? 'reply' : 'replies'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
})
|
||||
: new Array(10).fill(0).map((_, i) => (
|
||||
<li key={i} className="">
|
||||
@@ -156,7 +155,7 @@ export const QuestionsTable = ({
|
||||
<li className="py-2 list-none">
|
||||
<button
|
||||
className="p-3 block w-full hover:bg-gray-accent-light text-primary/75 dark:text-primary-dark/75 hover:text-red rounded text-[15px] font-bold bg-gray-accent-light dark:bg-gray-accent-dark relative active:top-[0.5px] active:scale-[.99]"
|
||||
onClick={() => setSize((size) => size + 1)}
|
||||
onClick={fetchMore}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Load more
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export type Question = {
|
||||
id: string
|
||||
permalink: string
|
||||
published: boolean
|
||||
subject: string
|
||||
topics: {
|
||||
topic: {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
}[]
|
||||
profile: {
|
||||
id: string
|
||||
avatar: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
replies: {
|
||||
id: string
|
||||
body: string
|
||||
published: boolean
|
||||
profile: {
|
||||
id: string
|
||||
avatar: string
|
||||
}
|
||||
created_at: string
|
||||
}[]
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import Layout from 'components/Layout'
|
||||
import { graphql, useStaticQuery } from 'gatsby'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import groupBy from 'lodash.groupby'
|
||||
import { SEO } from 'components/seo'
|
||||
import PostLayout from 'components/PostLayout'
|
||||
import Link from 'components/Link'
|
||||
import Checkbox from 'components/Checkbox'
|
||||
import community from 'sidebars/community.json'
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
interface IGitHubPage {
|
||||
title: string
|
||||
html_url: string
|
||||
number: string
|
||||
closed_at: string
|
||||
reactions: {
|
||||
hooray: number
|
||||
heart: number
|
||||
eyes: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ITeam {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface IRoadmap {
|
||||
complete: boolean
|
||||
date_completed: string
|
||||
title: string
|
||||
description: string
|
||||
team: ITeam
|
||||
githubPages: IGitHubPage[]
|
||||
projected_completion_date: string
|
||||
}
|
||||
|
||||
function group(nodes) {
|
||||
return groupBy(
|
||||
nodes
|
||||
.filter((node: IRoadmap) => node.date_completed)
|
||||
.sort((a, b) => new Date(b.date_completed) > new Date(a.date_completed)),
|
||||
(node) =>
|
||||
`${months[new Date(node.date_completed).getUTCMonth()]} ${new Date(node.date_completed).getUTCFullYear()}`
|
||||
)
|
||||
}
|
||||
|
||||
export default function Changelog() {
|
||||
const {
|
||||
allSqueakRoadmap: { nodes, teams, categories },
|
||||
} = useStaticQuery(query)
|
||||
const [roadmap, setRoadmap] = useState(group(nodes))
|
||||
const [filters, setFilters] = useState({})
|
||||
|
||||
const handleCategoryChange = (e, type, checked) => {
|
||||
const newFilters = { ...filters }
|
||||
const { value } = e.target
|
||||
if (checked) {
|
||||
if (newFilters[type]) {
|
||||
newFilters[type].push(value)
|
||||
} else {
|
||||
newFilters[type] = [value]
|
||||
}
|
||||
} else {
|
||||
if (newFilters[type] && newFilters[type].includes(value)) {
|
||||
newFilters[type].splice(newFilters[type].indexOf(value), 1)
|
||||
}
|
||||
}
|
||||
setFilters(newFilters)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const newRoadmap = nodes.filter((goal) =>
|
||||
Object.keys(filters).some((filter) => filters[filter].includes(goal[filter]?.name || goal[filter]))
|
||||
)
|
||||
setRoadmap(group(newRoadmap.length > 0 ? newRoadmap : nodes))
|
||||
}, [filters])
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="PostHog Changelog" />
|
||||
<PostLayout
|
||||
darkMode={false}
|
||||
contentWidth={'100%'}
|
||||
article={false}
|
||||
title={'Changelog'}
|
||||
hideSurvey
|
||||
menu={community}
|
||||
>
|
||||
<h1 className="font-bold text-5xl mb-2 lg:mt-0">Changelog</h1>
|
||||
<p className="text-black/80">Here's a history of everything we've built.</p>
|
||||
<div className="border-y border-dashed border-gray-accent-light py-4 flex space-x-6 flex-nowrap overflow-auto whitespace-nowrap scrollbar-hide">
|
||||
<div>
|
||||
<h5 className="m-0 mb-2">Type</h5>
|
||||
<ul className="list-none m-0 p-0 flex space-x-4">
|
||||
{categories.map(({ fieldValue }, index) => {
|
||||
const checked = filters?.category?.includes(fieldValue) || false
|
||||
return (
|
||||
<li key={fieldValue}>
|
||||
<Checkbox
|
||||
className="!text-sm"
|
||||
checked={checked}
|
||||
onChange={(e) => handleCategoryChange(e, 'category', !checked)}
|
||||
value={fieldValue}
|
||||
id={`category-${index}`}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="m-0 mb-2">Team</h5>
|
||||
<ul className="list-none m-0 p-0 flex space-x-4">
|
||||
{teams.map(({ fieldValue }, index) => {
|
||||
const checked = filters?.team?.includes(fieldValue) || false
|
||||
return (
|
||||
<li key={fieldValue}>
|
||||
<Checkbox
|
||||
className="!text-sm"
|
||||
checked={checked}
|
||||
onChange={(e) => handleCategoryChange(e, 'team', !checked)}
|
||||
value={fieldValue}
|
||||
id={`team-${index}`}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="list-none p-0 m-0 mt-12">
|
||||
{Object.keys(roadmap).map((date, index) => {
|
||||
return (
|
||||
<li key={date}>
|
||||
<div className="flex space-x-6">
|
||||
<div className="pb-8 relative">
|
||||
<p
|
||||
className={`m-0 bg-white px-2 rounded-md max-w-[60px] text-center font-semibold text-sm ${
|
||||
index + 1 !== Object.keys(roadmap).length
|
||||
? 'before:border before:border-gray-accent-light before:border-dashed before:h-full before:left-1/2 before:-translate-x-1/2 before:absolute before:z-[-1]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{date}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="list-none p-0 m-0 grid gap-y-1 pb-8 ">
|
||||
{roadmap[date].map(({ title, githubPages, otherLinks, team }: IRoadmap) => {
|
||||
const url =
|
||||
(githubPages?.length > 0 && githubPages[0]?.html_url) ||
|
||||
(otherLinks?.length > 0 && otherLinks[0])
|
||||
return (
|
||||
<li key={title} className="flex items-center space-x-2">
|
||||
<span className="font-semibold">
|
||||
{url ? <Link to={url}>{title}</Link> : title}
|
||||
</span>
|
||||
{team && <span className="text-black/50">{team.name}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</PostLayout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const query = graphql`
|
||||
{
|
||||
allSqueakRoadmap(sort: { fields: date_completed }, filter: { date_completed: { ne: null } }) {
|
||||
nodes {
|
||||
category
|
||||
complete
|
||||
date_completed
|
||||
title
|
||||
description
|
||||
team {
|
||||
name
|
||||
}
|
||||
githubPages {
|
||||
title
|
||||
html_url
|
||||
number
|
||||
closed_at
|
||||
reactions {
|
||||
hooray
|
||||
heart
|
||||
eyes
|
||||
}
|
||||
}
|
||||
projected_completion_date
|
||||
}
|
||||
teams: group(field: team___name) {
|
||||
fieldValue
|
||||
}
|
||||
categories: group(field: category) {
|
||||
fieldValue
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -1,82 +1,164 @@
|
||||
import { Check, ClosedIssue, OpenIssue, Plus } from 'components/Icons/Icons'
|
||||
import Link from 'components/Link'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { IRoadmap } from '.'
|
||||
import { Login } from 'components/Squeak'
|
||||
import { Authentication } from 'components/Squeak'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import Spinner from 'components/Spinner'
|
||||
import { useToast } from '../../hooks/toast'
|
||||
import { GatsbyImage, getImage } from 'gatsby-plugin-image'
|
||||
import useSWR from 'swr'
|
||||
import qs from 'qs'
|
||||
|
||||
type RoadmapSubscriptions = {
|
||||
data: {
|
||||
id: number
|
||||
attributes: {
|
||||
roadmapSubscriptions: {
|
||||
data: {
|
||||
id: number
|
||||
}[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function InProgress(props: IRoadmap & { className?: string; more?: boolean; stacked?: boolean }) {
|
||||
const { addToast } = useToast()
|
||||
const { user } = useUser()
|
||||
const { user, getJwt } = useUser()
|
||||
const [more, setMore] = useState(props.more ?? false)
|
||||
const [showAuth, setShowAuth] = useState(false)
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { title, githubPages, description, beta_available, thumbnail, roadmapId } = props
|
||||
const { title, githubPages, description, betaAvailable, image, squeakId } = props
|
||||
const completedIssues = githubPages && githubPages?.filter((page) => page.closed_at)
|
||||
const percentageComplete = githubPages && Math.round((completedIssues.length / githubPages?.length) * 100)
|
||||
|
||||
async function subscribe(email: string) {
|
||||
setLoading(true)
|
||||
if (email) {
|
||||
const res = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/roadmap/subscribe`, {
|
||||
const query = qs.stringify(
|
||||
{
|
||||
fields: ['id'],
|
||||
populate: {
|
||||
roadmapSubscriptions: {
|
||||
fields: ['id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
encodeValuesOnly: true, // prettify URL
|
||||
}
|
||||
)
|
||||
|
||||
const { data, mutate } = useSWR<RoadmapSubscriptions>(
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/profiles/${user?.profile?.id}?${query}`,
|
||||
async (url: string) => {
|
||||
if (!user) return { data: { attributes: { roadmapSubscriptions: [] } } }
|
||||
return fetch(url).then((r) => r.json())
|
||||
}
|
||||
)
|
||||
|
||||
const { data: roadmapData } = data || {}
|
||||
|
||||
const subscribed = roadmapData?.attributes?.roadmapSubscriptions?.data?.some(({ id }) => id === squeakId)
|
||||
|
||||
async function subscribe() {
|
||||
if (!roadmapData) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const token = await getJwt()
|
||||
|
||||
if (!token) {
|
||||
setShowAuth(true)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/roadmap/${props.squeakId}/subscribe`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id: roadmapId, organizationId: process.env.GATSBY_SQUEAK_ORG_ID }),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSubscribed(true)
|
||||
setShowAuth(false)
|
||||
addToast({ message: `Subscribed to ${title}. We’ll email you with updates!` })
|
||||
mutate({
|
||||
data: {
|
||||
id: roadmapData?.id,
|
||||
attributes: {
|
||||
roadmapSubscriptions: {
|
||||
data: [
|
||||
...roadmapData?.attributes?.roadmapSubscriptions?.data,
|
||||
{
|
||||
id: props.squeakId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
addToast({ error: true, message: 'Whoops! Something went wrong.' })
|
||||
}
|
||||
} else {
|
||||
setShowAuth(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function unsubscribe(email: string) {
|
||||
setLoading(true)
|
||||
if (email) {
|
||||
const res = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/roadmap/unsubscribe`, {
|
||||
async function unsubscribe() {
|
||||
try {
|
||||
if (!roadmapData) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const token = await getJwt()
|
||||
|
||||
if (!token) {
|
||||
setShowAuth(true)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/roadmap/${props.squeakId}/unsubscribe`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id: roadmapId, organizationId: process.env.GATSBY_SQUEAK_ORG_ID }),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSubscribed(false)
|
||||
setShowAuth(false)
|
||||
addToast({ message: `Unsubscribed from ${title}. You will no longer receive updates.` })
|
||||
mutate({
|
||||
data: {
|
||||
id: roadmapData?.id,
|
||||
attributes: {
|
||||
roadmapSubscriptions: {
|
||||
data: roadmapData?.attributes?.roadmapSubscriptions?.data.filter(
|
||||
({ id }) => id !== props.squeakId
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
addToast({ error: true, message: 'Whoops! Something went wrong.' })
|
||||
}
|
||||
} else {
|
||||
setShowAuth(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setSubscribed(user?.profile?.subscriptions?.some((sub) => sub?.id === roadmapId))
|
||||
}
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`border-t border-dashed border-gray-accent-light first:border-t-0 px-4 py-4 sm:py-2 xl:pb-4 bg-white rounded-sm shadow-xl ${
|
||||
@@ -95,12 +177,14 @@ export function InProgress(props: IRoadmap & { className?: string; more?: boolea
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{thumbnail && (
|
||||
{image && (
|
||||
<div className="sm:flex-shrink-0">
|
||||
<GatsbyImage className="shadow-md" image={getImage(thumbnail)} alt="" />
|
||||
<img src={image.url} className="shadow-md" alt="" />
|
||||
{/*<GatsbyImage className="shadow-md" image={getImage(thumbnail)} alt="" />*/}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{githubPages && (
|
||||
<div className="mt-4 mb-4">
|
||||
<h5 className="text-sm mb-2 font-semibold opacity-60 !mt-0">Progress</h5>
|
||||
@@ -112,6 +196,7 @@ export function InProgress(props: IRoadmap & { className?: string; more?: boolea
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{githubPages && more && (
|
||||
<ul className="list-none m-0 p-0 pb-4 grid gap-y-2 mt-4">
|
||||
{githubPages.map((page) => {
|
||||
@@ -131,6 +216,7 @@ export function InProgress(props: IRoadmap & { className?: string; more?: boolea
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="sm:flex-[0_0_250px] xl:flex-1 flex sm:justify-end xl:justify-start">
|
||||
<div className="mt-2 w-full">
|
||||
{showAuth ? (
|
||||
@@ -147,16 +233,17 @@ export function InProgress(props: IRoadmap & { className?: string; more?: boolea
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Login
|
||||
onSubmit={(data: { email: string }) => subscribe(data?.email)}
|
||||
apiHost={process.env.GATSBY_SQUEAK_API_HOST}
|
||||
organizationId={process.env.GATSBY_SQUEAK_ORG_ID}
|
||||
<Authentication
|
||||
initialView="sign-in"
|
||||
onAuth={() => subscribe()}
|
||||
showBanner={false}
|
||||
showProfile={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => (subscribed ? unsubscribe(user?.email) : subscribe(user?.email))}
|
||||
onClick={() => (subscribed ? unsubscribe() : subscribe())}
|
||||
className="text-[15px] inline-flex items-center space-x-2 py-2 px-4 rounded-sm bg-gray-accent-light text-black hover:text-black font-bold active:top-[0.5px] active:scale-[.98] w-auto"
|
||||
>
|
||||
<span className="w-[24px] h-[24px] flex items-center justify-center bg-blue/10 text-blue rounded-full">
|
||||
@@ -171,7 +258,7 @@ export function InProgress(props: IRoadmap & { className?: string; more?: boolea
|
||||
<span>
|
||||
{subscribed
|
||||
? 'Unsubscribe'
|
||||
: beta_available
|
||||
: betaAvailable
|
||||
? 'Get early access'
|
||||
: 'Subscribe for updates'}
|
||||
</span>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import Layout from 'components/Layout'
|
||||
import { graphql, useStaticQuery } from 'gatsby'
|
||||
import React from 'react'
|
||||
import groupBy from 'lodash.groupby'
|
||||
import Link from 'components/Link'
|
||||
import { SEO } from 'components/seo'
|
||||
import PostLayout from 'components/PostLayout'
|
||||
import { UnderConsideration } from './UnderConsideration'
|
||||
import { InProgress } from './InProgress'
|
||||
import { OrgProvider } from 'components/Squeak'
|
||||
import { ImageDataLike, StaticImage } from 'gatsby-plugin-image'
|
||||
import { StaticImage } from 'gatsby-plugin-image'
|
||||
import community from 'sidebars/community.json'
|
||||
import { useRoadmap } from 'hooks/useRoadmap'
|
||||
|
||||
interface IGitHubPage {
|
||||
title: string
|
||||
@@ -26,24 +24,27 @@ interface IGitHubPage {
|
||||
|
||||
interface ITeam {
|
||||
name: string
|
||||
roadmaps: IRoadmap[]
|
||||
}
|
||||
|
||||
export interface IRoadmap {
|
||||
beta_available: boolean
|
||||
complete: boolean
|
||||
date_completed: string
|
||||
squeakId: number
|
||||
title: string
|
||||
description: string
|
||||
team: ITeam
|
||||
betaAvailable: boolean
|
||||
complete: boolean
|
||||
dateCompleted: string
|
||||
image?: {
|
||||
url: string
|
||||
}
|
||||
projectedCompletion: string
|
||||
githubPages: IGitHubPage[]
|
||||
projected_completion_date: string
|
||||
roadmapId: BigInt
|
||||
thumbnail: ImageDataLike
|
||||
}
|
||||
|
||||
const Complete = (props: { title: string; githubPages: IGitHubPage[]; otherLinks: string[] }) => {
|
||||
const { title, githubPages, otherLinks } = props
|
||||
const url = (githubPages?.length > 0 && githubPages[0]?.html_url) || (otherLinks?.length > 0 && otherLinks[0])
|
||||
/*const Complete = (props: { title: string; githubPages: IGitHubPage[] }) => {
|
||||
const { title, githubPages } = props
|
||||
const url = githubPages?.length > 0 && githubPages[0]?.html_url
|
||||
|
||||
return (
|
||||
<li className="text-base font-semibold">
|
||||
{url ? (
|
||||
@@ -58,7 +59,7 @@ const Complete = (props: { title: string; githubPages: IGitHubPage[]; otherLinks
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
}*/
|
||||
|
||||
export const Section = ({
|
||||
title,
|
||||
@@ -66,15 +67,15 @@ export const Section = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string | React.ReactNode
|
||||
description: string | React.ReactNode
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<div className={`xl:px-7 2xl:px-8 px-5 py-8 ${className ?? ''}`}>
|
||||
<h3 className="text-xl m-0">{title}</h3>
|
||||
<p className="text-[15px] m-0 text-black/60 mb-4">{description}</p>
|
||||
{description && <p className="text-[15px] m-0 text-black/60 mb-4">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -94,27 +95,35 @@ export const CardContainer = ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
|
||||
export default function Roadmap() {
|
||||
const {
|
||||
allSqueakRoadmap: { nodes },
|
||||
} = useStaticQuery(query)
|
||||
const teams = useRoadmap()
|
||||
|
||||
const underConsideration = groupBy(
|
||||
nodes.filter(
|
||||
(node: IRoadmap) =>
|
||||
!node.date_completed &&
|
||||
!node.projected_completion_date &&
|
||||
node.githubPages &&
|
||||
node.githubPages.length > 0
|
||||
),
|
||||
({ team }: { team: ITeam }) => team?.name
|
||||
)
|
||||
const inProgress = groupBy(
|
||||
nodes.filter((node: IRoadmap) => !node.date_completed && node.projected_completion_date),
|
||||
({ team }: { team: ITeam }) => team?.name
|
||||
)
|
||||
const complete = groupBy(
|
||||
const underConsideration: ITeam[] = teams
|
||||
.map((team) => {
|
||||
return {
|
||||
...team,
|
||||
roadmaps: team.roadmaps.filter(
|
||||
(roadmap) =>
|
||||
!roadmap.dateCompleted &&
|
||||
!roadmap.projectedCompletion &&
|
||||
roadmap.githubPages &&
|
||||
roadmap.githubPages.length > 0
|
||||
),
|
||||
}
|
||||
})
|
||||
.filter((team) => team.roadmaps.length > 0)
|
||||
|
||||
const inProgress: ITeam[] = teams
|
||||
.map((team) => {
|
||||
return {
|
||||
...team,
|
||||
roadmaps: team.roadmaps.filter((roadmap) => !roadmap.complete && roadmap.projectedCompletion),
|
||||
}
|
||||
})
|
||||
.filter((team) => team.roadmaps.length > 0)
|
||||
|
||||
/*const complete = groupBy(
|
||||
nodes.filter((node: IRoadmap) => {
|
||||
const goalDate = node.date_completed && new Date(node.date_completed)
|
||||
const goalDate = node.dateCompleted && new Date(node.dateCompleted)
|
||||
const currentDate = new Date()
|
||||
const currentQuarter = Math.floor(currentDate.getMonth() / 3 + 1)
|
||||
const goalQuarter = goalDate && Math.floor(goalDate.getMonth() / 3 + 1)
|
||||
@@ -123,100 +132,95 @@ export default function Roadmap() {
|
||||
)
|
||||
}),
|
||||
({ team }: { team: ITeam }) => team?.name
|
||||
)
|
||||
)*/
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="PostHog Roadmap" />
|
||||
<OrgProvider
|
||||
value={{
|
||||
organizationId: process.env.GATSBY_SQUEAK_ORG_ID as string,
|
||||
apiHost: process.env.GATSBY_SQUEAK_API_HOST as string,
|
||||
}}
|
||||
>
|
||||
<div className="border-t border-dashed border-gray-accent-light">
|
||||
<PostLayout
|
||||
contentWidth={'100%'}
|
||||
article={false}
|
||||
title={'Roadmap'}
|
||||
hideSurvey
|
||||
menu={community}
|
||||
darkMode={false}
|
||||
contentContainerClassName="lg:-mb-12 -mb-8"
|
||||
>
|
||||
<div className="relative">
|
||||
<h1 className="font-bold text-5xl mx-8 lg:-mt-8 xl:-mt-0">Roadmap</h1>
|
||||
<figure className="-mt-8 sm:-mt-20 xl:-mt-32 mb-0">
|
||||
<StaticImage
|
||||
className="w-full"
|
||||
imgClassName="w-full aspect-auto"
|
||||
placeholder="blurred"
|
||||
alt={`Look at those views!'`}
|
||||
src="./images/hike-hog.png"
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 xl:divide-x xl:gap-y-0 gap-y-6 divide-gray-accent-light divide-dashed">
|
||||
<Section
|
||||
title="Under consideration"
|
||||
description="The top features we might build next. Your feedback is requested."
|
||||
>
|
||||
<CardContainer>
|
||||
{Object.keys(underConsideration)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
return (
|
||||
<Card key={key} team={key}>
|
||||
<CardContainer>
|
||||
{underConsideration[key]?.map((node: IRoadmap) => {
|
||||
return <UnderConsideration key={node.title} {...node} />
|
||||
})}
|
||||
</CardContainer>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</CardContainer>
|
||||
</Section>
|
||||
<Section
|
||||
title="In progress"
|
||||
description={
|
||||
<>
|
||||
Here’s what we're building <strong>right now</strong>. (We choose milestones
|
||||
using community feedback.)
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CardContainer>
|
||||
{Object.keys(inProgress)
|
||||
.sort((a, b) =>
|
||||
inProgress[a].some((goal) => goal.beta_available)
|
||||
? -1
|
||||
: inProgress[b].some((goal) => goal.beta_available)
|
||||
? 1
|
||||
: 0
|
||||
<div className="border-t border-dashed border-gray-accent-light">
|
||||
<PostLayout
|
||||
contentWidth={'100%'}
|
||||
article={false}
|
||||
title={'Roadmap'}
|
||||
hideSurvey
|
||||
menu={community}
|
||||
darkMode={false}
|
||||
contentContainerClassName="lg:-mb-12 -mb-8"
|
||||
>
|
||||
<div className="relative">
|
||||
<h1 className="font-bold text-5xl mx-8 lg:-mt-8 xl:-mt-0">Roadmap</h1>
|
||||
<figure className="-mt-8 sm:-mt-20 xl:-mt-32 mb-0">
|
||||
<StaticImage
|
||||
className="w-full"
|
||||
imgClassName="w-full aspect-auto"
|
||||
placeholder="blurred"
|
||||
alt={`Look at those views!'`}
|
||||
src="./images/hike-hog.png"
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 xl:divide-x xl:gap-y-0 gap-y-6 divide-gray-accent-light divide-dashed">
|
||||
<Section
|
||||
title="Under consideration"
|
||||
description="The top features we might build next. Your feedback is requested."
|
||||
>
|
||||
<CardContainer>
|
||||
{underConsideration.sort().map((team) => {
|
||||
return (
|
||||
<Card key={team.name} team={team.name}>
|
||||
<CardContainer>
|
||||
{team.roadmaps.map((node) => {
|
||||
return <UnderConsideration key={node.title} {...node} />
|
||||
})}
|
||||
</CardContainer>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</CardContainer>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="In progress"
|
||||
description={
|
||||
<>
|
||||
Here’s what we're building <strong>right now</strong>. (We choose milestones using
|
||||
community feedback.)
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CardContainer>
|
||||
{inProgress
|
||||
.sort((a, b) =>
|
||||
a.roadmaps.some((goal) => goal.betaAvailable)
|
||||
? -1
|
||||
: b.roadmaps.some((goal) => goal.betaAvailable)
|
||||
? 1
|
||||
: 0
|
||||
)
|
||||
.map((team) => {
|
||||
return (
|
||||
<Card key={team.name} team={team.name}>
|
||||
<CardContainer>
|
||||
{team.roadmaps.map((node) => {
|
||||
return <InProgress stacked key={node.title} {...node} />
|
||||
})}
|
||||
</CardContainer>
|
||||
</Card>
|
||||
)
|
||||
.map((key) => {
|
||||
return (
|
||||
<Card key={key} team={key}>
|
||||
<CardContainer>
|
||||
{inProgress[key]?.map((node: IRoadmap) => {
|
||||
return <InProgress stacked key={node.title} {...node} />
|
||||
})}
|
||||
</CardContainer>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</CardContainer>
|
||||
</Section>
|
||||
<Section
|
||||
title="Recently shipped"
|
||||
// description="Here's what was included in our last array."
|
||||
className=""
|
||||
>
|
||||
<p className="p-4 border border-dashed border-gray-accent-light rounded-sm text-[15px]">
|
||||
Check out <Link to="/blog/categories/product-updates">product updates</Link> on our
|
||||
blog to see what we've shipped recently.
|
||||
</p>
|
||||
{/*
|
||||
})}
|
||||
</CardContainer>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Recently shipped"
|
||||
// description="Here's what was included in our last array."
|
||||
className=""
|
||||
>
|
||||
<p className="p-4 border border-dashed border-gray-accent-light rounded-sm text-[15px]">
|
||||
Check out <Link to="/blog/categories/product-updates">product updates</Link> on our blog
|
||||
to see what we've shipped recently.
|
||||
</p>
|
||||
{/*
|
||||
hidden until we have more historical content loaded
|
||||
<CardContainer>
|
||||
{Object.keys(complete)
|
||||
@@ -234,48 +238,10 @@ export default function Roadmap() {
|
||||
})}
|
||||
</CardContainer>
|
||||
*/}
|
||||
</Section>
|
||||
</div>
|
||||
</PostLayout>
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</Section>
|
||||
</div>
|
||||
</PostLayout>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const query = graphql`
|
||||
{
|
||||
allSqueakRoadmap {
|
||||
nodes {
|
||||
roadmapId
|
||||
beta_available
|
||||
complete
|
||||
date_completed
|
||||
title
|
||||
description
|
||||
team {
|
||||
name
|
||||
}
|
||||
thumbnail {
|
||||
childImageSharp {
|
||||
gatsbyImageData(width: 75, placeholder: NONE, quality: 100)
|
||||
}
|
||||
}
|
||||
otherLinks
|
||||
githubPages {
|
||||
title
|
||||
html_url
|
||||
number
|
||||
closed_at
|
||||
reactions {
|
||||
hooray
|
||||
heart
|
||||
eyes
|
||||
plus1
|
||||
}
|
||||
}
|
||||
projected_completion_date
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useOrg } from '../hooks/useOrg'
|
||||
import Avatar from './Avatar'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
|
||||
import type { User } from 'hooks/useUser'
|
||||
import ForgotPassword from './auth/ForgotPassword'
|
||||
import Avatar from './Avatar'
|
||||
import SignUp from './auth/SignUp'
|
||||
import SignIn from './auth/SignIn'
|
||||
import ResetPassword from './auth/ResetPassword'
|
||||
|
||||
import { Theme } from './Theme'
|
||||
type AuthenticationProps = {
|
||||
handleMessageSubmit: (message: any) => Promise<void> | void
|
||||
formValues?: any
|
||||
setParentView?: (view: string | null) => void
|
||||
initialView?: string
|
||||
buttonText?: Record<string, string>
|
||||
banner?: {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
onSignUp?: () => void
|
||||
showBanner?: boolean
|
||||
showProfile?: boolean
|
||||
handleMessageSubmit?: (values: any, user: User | null) => void
|
||||
onAuth?: () => void
|
||||
}
|
||||
|
||||
export const Authentication = ({
|
||||
handleMessageSubmit,
|
||||
formValues,
|
||||
setParentView,
|
||||
initialView = 'sign-in',
|
||||
buttonText = { login: 'Login', signUp: 'Sign up' },
|
||||
banner,
|
||||
onSignUp,
|
||||
showBanner = true,
|
||||
showProfile = true,
|
||||
handleMessageSubmit,
|
||||
onAuth,
|
||||
}: AuthenticationProps) => {
|
||||
const { organizationId, apiHost } = useOrg()
|
||||
const [view, setView] = useState(initialView)
|
||||
const [message, setMessage] = useState(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleForgotPassword = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
@@ -39,92 +40,96 @@ export const Authentication = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Avatar />
|
||||
{formValues && (
|
||||
<div className="squeak-post-preview-container">
|
||||
<div className="squeak-post-preview">
|
||||
{formValues?.subject && <h3>{formValues.subject}</h3>}
|
||||
{formValues.question}
|
||||
</div>
|
||||
<div className="squeak-button-container">
|
||||
<button onClick={() => setParentView?.('question-form')}>Edit post</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="squeak-authentication-form-container">
|
||||
{banner && (
|
||||
<div className="squeak-authentication-form-message">
|
||||
<h4>{banner.title}</h4>
|
||||
<p>{banner.body}</p>
|
||||
<root.div ref={containerRef}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
{showProfile && <Avatar />}
|
||||
{formValues && (
|
||||
<div className="squeak-post-preview-container">
|
||||
<div className="squeak-post-preview">
|
||||
{formValues?.subject && <h3>{formValues.subject}</h3>}
|
||||
{formValues?.body}
|
||||
</div>
|
||||
<div className="squeak-button-container">
|
||||
<button onClick={() => setParentView?.('question-form')}>Edit post</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="squeak-authentication-form">
|
||||
<div className="squeak-authentication-navigation">
|
||||
<button className={view === 'sign-in' ? 'active' : ''} onClick={() => setView('sign-in')}>
|
||||
Login
|
||||
</button>
|
||||
<button className={view === 'sign-up' ? 'active' : ''} onClick={() => setView('sign-up')}>
|
||||
Signup
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
opacity: view === 'forgot-password' || view === 'reset-password' ? 0 : 1,
|
||||
}}
|
||||
className={`squeak-authentication-navigation-rail ${view}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="squeak-authentication-form-wrapper">
|
||||
{message && <p className="squeak-auth-error">{message}</p>}
|
||||
|
||||
{
|
||||
{
|
||||
'sign-in': (
|
||||
<SignIn
|
||||
buttonText={buttonText.login}
|
||||
formValues={formValues}
|
||||
handleMessageSubmit={handleMessageSubmit}
|
||||
setMessage={setMessage}
|
||||
apiHost={apiHost}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
),
|
||||
'sign-up': (
|
||||
<SignUp
|
||||
buttonText={buttonText.signUp}
|
||||
formValues={formValues}
|
||||
handleMessageSubmit={handleMessageSubmit}
|
||||
setMessage={setMessage}
|
||||
organizationId={organizationId}
|
||||
apiHost={apiHost}
|
||||
onSignUp={onSignUp}
|
||||
/>
|
||||
),
|
||||
'forgot-password': (
|
||||
<ForgotPassword
|
||||
setParentView={setParentView}
|
||||
setMessage={setMessage}
|
||||
apiHost={apiHost}
|
||||
/>
|
||||
),
|
||||
'reset-password': (
|
||||
<ResetPassword
|
||||
setParentView={setParentView}
|
||||
setMessage={setMessage}
|
||||
apiHost={apiHost}
|
||||
/>
|
||||
),
|
||||
}[view]
|
||||
}
|
||||
{view !== 'forgot-password' && view !== 'reset-password' && (
|
||||
<button onClick={handleForgotPassword} className="squeak-forgot-password">
|
||||
Forgot password
|
||||
<div style={showProfile ? { marginLeft: 50 } : {}} className={`squeak-authentication-form-container`}>
|
||||
{showBanner && (
|
||||
<div className="squeak-authentication-form-message">
|
||||
<h4>Please signup to post.</h4>
|
||||
<p>Create an account to ask questions & help others.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="squeak-authentication-form">
|
||||
<div className="squeak-authentication-navigation">
|
||||
<button className={view === 'sign-in' ? 'active' : ''} onClick={() => setView('sign-in')}>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
<button className={view === 'sign-up' ? 'active' : ''} onClick={() => setView('sign-up')}>
|
||||
Signup
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
opacity: view === 'forgot-password' || view === 'reset-password' ? 0 : 1,
|
||||
}}
|
||||
className={`squeak-authentication-navigation-rail ${view}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="squeak-authentication-form-wrapper">
|
||||
{message && <p className="squeak-auth-error">{message}</p>}
|
||||
|
||||
{
|
||||
{
|
||||
'sign-in': (
|
||||
<SignIn
|
||||
buttonText={buttonText.login}
|
||||
setMessage={setMessage}
|
||||
onSubmit={(user) => {
|
||||
if (formValues) {
|
||||
handleMessageSubmit?.(formValues, user)
|
||||
} else {
|
||||
setParentView?.(null)
|
||||
}
|
||||
|
||||
onAuth?.()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
'sign-up': (
|
||||
<SignUp
|
||||
buttonText={buttonText.signUp}
|
||||
setMessage={setMessage}
|
||||
onSubmit={(user) => {
|
||||
if (formValues) {
|
||||
handleMessageSubmit?.(formValues, user)
|
||||
} else {
|
||||
setParentView?.(null)
|
||||
}
|
||||
|
||||
onAuth?.()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
'forgot-password': (
|
||||
<ForgotPassword setParentView={setParentView} setMessage={setMessage} />
|
||||
),
|
||||
'reset-password': (
|
||||
<ResetPassword setParentView={setParentView} setMessage={setMessage} />
|
||||
),
|
||||
}[view]
|
||||
}
|
||||
{view !== 'forgot-password' && view !== 'reset-password' && (
|
||||
<button onClick={handleForgotPassword} className="squeak-forgot-password">
|
||||
Forgot password
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</root.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react'
|
||||
|
||||
export const Days = ({ created }: { created: string }) => {
|
||||
export const Days = ({ created }: { created: string | undefined }) => {
|
||||
if (!created) {
|
||||
return null
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
const posted = new Date(created)
|
||||
const diff = today.getTime() - posted.getTime()
|
||||
|
||||
236
src/components/Squeak/components/EditProfile.tsx
Normal file
236
src/components/Squeak/components/EditProfile.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Form, Field, Formik, FormikHandlers } from 'formik'
|
||||
import Button from 'components/CommunityQuestions/Button'
|
||||
import { Markdown } from 'components/Icons'
|
||||
import * as Yup from 'yup'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import { Avatar as DefaultAvatar } from '../../../pages/community'
|
||||
import getAvatarURL from '../util/getAvatar'
|
||||
|
||||
const fields = {
|
||||
avatar: {
|
||||
label: 'Avatar',
|
||||
component: Avatar,
|
||||
hideLabel: true,
|
||||
className: 'flex-grow flex items-end',
|
||||
},
|
||||
firstName: {
|
||||
type: 'fname',
|
||||
label: 'First name',
|
||||
className: 'w-[calc(50%-40px)] grid items-end',
|
||||
},
|
||||
lastName: {
|
||||
type: 'lname',
|
||||
label: 'Last name',
|
||||
className: 'w-[calc(50%-40px)] grid items-end',
|
||||
},
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
},
|
||||
linkedin: {
|
||||
label: 'LinkedIn',
|
||||
},
|
||||
biography: {
|
||||
component: () => (
|
||||
<Field
|
||||
minRows={6}
|
||||
rows={6}
|
||||
as={TextareaAutosize}
|
||||
type="text"
|
||||
name="biography"
|
||||
placeholder="280 characters or less..."
|
||||
className="py-2 px-4 text-lg rounded-md w-full dark:text-primary border-gray-accent-light border mb-2"
|
||||
/>
|
||||
),
|
||||
className: 'w-full',
|
||||
},
|
||||
}
|
||||
|
||||
function capitalizeFirstLetter(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
const ValidationSchema = Yup.object().shape({
|
||||
firstName: Yup.string().required('Required'),
|
||||
lastName: Yup.string().required('Required'),
|
||||
website: Yup.string().url('Invalid URL').nullable(),
|
||||
github: Yup.string().url('Invalid URL').nullable(),
|
||||
linkedin: Yup.string().url('Invalid URL').nullable(),
|
||||
twitter: Yup.string().url('Invalid URL').nullable(),
|
||||
biography: Yup.string().max(3000, 'Please limit your bio to 3,000 characters, you wordsmith!').nullable(),
|
||||
})
|
||||
|
||||
type EditProfileProps = {
|
||||
onSubmit?: (() => void) | (() => Promise<void>)
|
||||
}
|
||||
|
||||
function Avatar({ values, setFieldValue }) {
|
||||
const inputRef = useRef()
|
||||
const [imageURL, setImageURL] = useState(values?.avatar)
|
||||
|
||||
const handleChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
setFieldValue('avatar', file)
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
reader?.result && setImageURL(reader.result)
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!values.avatar && inputRef?.current) {
|
||||
inputRef.current.value = null
|
||||
}
|
||||
setImageURL(values.avatar)
|
||||
}, [values.avatar])
|
||||
|
||||
return (
|
||||
<div className="relative w-full aspect-square rounded-full flex justify-center items-center border border-gray-accent-light dark:border-gray-accent-dark text-black/50 dark:text-white/50 overflow-hidden group">
|
||||
{imageURL ? (
|
||||
<img className="w-full h-full absolute inset-0 object-cover" src={imageURL} />
|
||||
) : (
|
||||
<DefaultAvatar className="w-[60px] h-[60px] absolute bottom-0" />
|
||||
)}
|
||||
<div
|
||||
className={`grid ${
|
||||
imageURL ? 'grid-cols-2' : 'grid-cols-1'
|
||||
} items-center w-full h-full z-10 bg-white/90 dark:bg-black/80 divide divide-x divide-dashed divide-gray-accent-light opacity-0 group-hover:opacity-100 transition-opacity`}
|
||||
>
|
||||
{imageURL && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setFieldValue('avatar', null)
|
||||
}}
|
||||
className="w-full h-full flex items-center justify-center text-4xl group"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<div className="relative w-full h-full flex items-center justify-center group">
|
||||
<span className="text-3xl">↑</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
onChange={handleChange}
|
||||
accept=".jpg, .png, .gif, .jpeg"
|
||||
className="opacity-0 absolute w-full h-full top-0 left-0 cursor-pointer"
|
||||
name="avatar"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EditProfile: React.FC<EditProfileProps> = ({ onSubmit }) => {
|
||||
const { user, fetchUser, isLoading, getJwt } = useUser()
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const { firstName, lastName, website, github, linkedin, twitter, biography, id } = user?.profile || {}
|
||||
|
||||
const avatar = getAvatarURL(user?.profile)
|
||||
|
||||
// TODO: Move this logic into the useUser hook
|
||||
const handleSubmit = async ({ avatar, ...values }, { setSubmitting }) => {
|
||||
setSubmitting(true)
|
||||
const JWT = await getJwt()
|
||||
let image = avatar
|
||||
if (avatar && typeof avatar === 'object') {
|
||||
const formData = new FormData()
|
||||
formData.append('files', avatar)
|
||||
const uploadedImage = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/upload`, {
|
||||
method: 'post',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${JWT}`,
|
||||
},
|
||||
}).then((res) => res.json())
|
||||
if (uploadedImage?.length > 0) {
|
||||
image = uploadedImage[0]
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
data: {
|
||||
...values,
|
||||
...((image && typeof image !== 'string') || image === null ? { avatar: image?.id ?? null } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
const { data, error } = await fetch(
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/profiles/${id}?populate=avatar`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${JWT}`,
|
||||
},
|
||||
}
|
||||
).then((res) => res.json())
|
||||
|
||||
setSubmitting(false)
|
||||
if (data) {
|
||||
await fetchUser(JWT)
|
||||
onSubmit?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{ avatar, firstName, lastName, website, github, linkedin, twitter, biography }}
|
||||
validationSchema={ValidationSchema}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ isSubmitting, isValid, values, errors, setFieldValue }) => {
|
||||
return (
|
||||
<Form className="m-0">
|
||||
<h2>Update profile</h2>
|
||||
<p className="border border-dashed border-gray-accent-light dark:border-gray-accent-dark p-4 rounded-md mb-4">
|
||||
<strong>Tip:</strong> Be sure to use full URLs when adding links to your website, GitHub,
|
||||
LinkedIn and Twitter (start with https)
|
||||
</p>
|
||||
<div className="flex flex-wrap m-0">
|
||||
{Object.keys(values).map((key) => {
|
||||
const error = errors[key]
|
||||
const field = fields[key]
|
||||
const label = field?.label || capitalizeFirstLetter(key.replaceAll('_', ' '))
|
||||
|
||||
return (
|
||||
<div className={`${field?.className ?? 'w-1/2'} p-2`} key={key}>
|
||||
{!field?.hideLabel && <label htmlFor={key}>{label}</label>}
|
||||
{(field?.component && field.component({ values, setFieldValue })) || (
|
||||
<Field
|
||||
className="py-2 px-4 text-lg rounded-md w-full dark:text-primary border-gray-accent-light border m-0"
|
||||
type={field?.type || 'text'}
|
||||
name={key}
|
||||
placeholder={label}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<span className="text-red font-semibold inline-block my-1">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className=" text-sm flex items-center space-x-2 mb-4">
|
||||
<Markdown />
|
||||
<span>Markdown is allowed - even encouraged!</span>
|
||||
</p>
|
||||
|
||||
<Button loading={isLoading} disabled={isSubmitting || !isValid} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export default class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: any, errorInfo: any) {
|
||||
console.error(error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return null
|
||||
} else {
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Provider as QuestionProvider } from '../hooks/useQuestion'
|
||||
import { useQuestion } from '../hooks/useQuestion'
|
||||
import { Provider as OrgProvider } from '../hooks/useOrg'
|
||||
import root from 'react-shadow/styled-components'
|
||||
import { Theme } from './Theme'
|
||||
|
||||
import ErrorBoundary from './ErrorBoundary'
|
||||
import QuestionForm from './QuestionForm'
|
||||
import Reply from './Reply'
|
||||
import { useRef } from 'react'
|
||||
|
||||
const getBadge = (questionAuthorId: string, replyAuthorId: string, replyAuthorRole: string) => {
|
||||
if (replyAuthorRole === 'admin' || replyAuthorRole === 'moderator') {
|
||||
return 'Moderator'
|
||||
}
|
||||
|
||||
if (!questionAuthorId || !replyAuthorId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return questionAuthorId === replyAuthorId ? 'Author' : null
|
||||
}
|
||||
|
||||
type RepliesProps = {
|
||||
question: Question
|
||||
}
|
||||
|
||||
const Replies = ({ question }: RepliesProps) => {
|
||||
const { resolved, onSubmit } = useQuestion()
|
||||
const { replies } = question
|
||||
|
||||
return (
|
||||
<>
|
||||
{replies && replies.length - 1 > 0 && (
|
||||
<ul className={`squeak-replies`}>
|
||||
{replies.slice(1).map((reply) => {
|
||||
const replyAuthorMetadata =
|
||||
reply?.profile?.profiles_readonly?.[0] || reply?.profile?.metadata?.[0]
|
||||
|
||||
const badgeText = getBadge(
|
||||
replies[0].profile?.id,
|
||||
reply?.profile?.id,
|
||||
replyAuthorMetadata?.role
|
||||
)
|
||||
|
||||
return (
|
||||
<li
|
||||
key={reply.id}
|
||||
className={`${false ? 'squeak-solution' : ''} ${
|
||||
!reply.published ? 'squeak-reply-unpublished' : ''
|
||||
}`}
|
||||
>
|
||||
<Reply className="squeak-post-reply" {...reply} badgeText={badgeText} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{resolved ? (
|
||||
<div className="squeak-locked-message">
|
||||
<p>This thread has been closed</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="squeak-reply-form-container">
|
||||
{/* @ts-ignore */}
|
||||
<QuestionForm onSubmit={onSubmit} messageID={question.id} formType="reply" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type Reply = {
|
||||
id: string
|
||||
profile: Record<string, any>
|
||||
created_at: string
|
||||
body: string
|
||||
published: boolean
|
||||
}
|
||||
|
||||
type Question = {
|
||||
id: string
|
||||
subject: string
|
||||
permalink: string | null
|
||||
published: boolean
|
||||
replies: Reply[]
|
||||
}
|
||||
|
||||
export type QuestionProps = {
|
||||
onSubmit: (question: any) => void
|
||||
onResolve: (resolved: boolean, replyId: string | null) => void
|
||||
apiHost: string
|
||||
organizationId: string
|
||||
question: Question
|
||||
}
|
||||
|
||||
export const FullQuestion = ({ onSubmit, onResolve, apiHost, organizationId, question }: QuestionProps) => {
|
||||
const [firstReply] = question?.replies || []
|
||||
|
||||
/*const getQuestion = async () => {
|
||||
const permalink = window.location.pathname
|
||||
// @ts-ignore
|
||||
const { response, data: question } =
|
||||
(await get(apiHost, '/api/question', {
|
||||
organizationId,
|
||||
permalink
|
||||
})) || {}
|
||||
|
||||
if (response?.status !== 200) return null
|
||||
|
||||
return question
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!question && permalink_base) {
|
||||
getQuestion().then((question) => {
|
||||
setQuestion(question?.question)
|
||||
setReplies(question?.replies || [])
|
||||
})
|
||||
}
|
||||
}, [organizationId, question, permalink_base])
|
||||
|
||||
useEffect(() => {
|
||||
setQuestion(other?.question)
|
||||
}, [other?.question])*/
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{/* @ts-ignore */}
|
||||
<root.div ref={containerRef}>
|
||||
<OrgProvider value={{ organizationId, apiHost }}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
<div className="squeak-question-container">
|
||||
<Reply className="squeak-post" subject={question.subject} {...firstReply} />
|
||||
<QuestionProvider
|
||||
onSubmit={onSubmit}
|
||||
question={question}
|
||||
replies={question.replies}
|
||||
onResolve={onResolve}
|
||||
>
|
||||
<Replies question={question} />
|
||||
</QuestionProvider>
|
||||
</div>
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</root.div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export const Markdown = ({ children }: { children: string }) => {
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
className="squeak-post-markdown"
|
||||
children={children}
|
||||
components={{
|
||||
pre: ({ children }) => {
|
||||
return (
|
||||
@@ -37,7 +36,9 @@ export const Markdown = ({ children }: { children: string }) => {
|
||||
return <a rel="nofollow" {...props} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
17
src/components/Squeak/components/Profile.tsx
Normal file
17
src/components/Squeak/components/Profile.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
import { StrapiRecord, ProfileData } from 'lib/strapi'
|
||||
import Avatar from './Avatar'
|
||||
import getAvatarURL from '../util/getAvatar'
|
||||
|
||||
type ProfileProps = {
|
||||
profile?: StrapiRecord<ProfileData>
|
||||
}
|
||||
|
||||
export const Profile = ({ profile }: ProfileProps) => {
|
||||
return profile?.attributes ? (
|
||||
<a className="squeak-profile-link" href={`/community/profiles/${profile.id}`}>
|
||||
<Avatar image={getAvatarURL(profile?.attributes)} />
|
||||
<strong className="squeak-author-name">{profile.attributes.firstName || 'Anonymous'}</strong>
|
||||
</a>
|
||||
) : null
|
||||
}
|
||||
@@ -1,201 +1,96 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useRef, createContext } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
|
||||
import { useOrg } from '../hooks/useOrg'
|
||||
import { useQuestion, Provider as QuestionProvider } from '../hooks/useQuestion'
|
||||
import Avatar from './Avatar'
|
||||
import QuestionForm from './QuestionForm'
|
||||
import Reply from './Reply'
|
||||
import { Theme } from './Theme'
|
||||
import { Replies } from './Replies'
|
||||
import { Profile } from './Profile'
|
||||
import { QuestionData, StrapiRecord } from 'lib/strapi'
|
||||
import Days from './Days'
|
||||
import Markdown from './Markdown'
|
||||
import { QuestionForm } from './QuestionForm'
|
||||
import { useQuestion } from '../hooks/useQuestion'
|
||||
import QuestionSkeleton from './QuestionSkeleton'
|
||||
|
||||
const getBadge = (questionAuthorId: string, replyAuthorId: string, replyAuthorRole: string) => {
|
||||
if (replyAuthorRole === 'admin' || replyAuthorRole === 'moderator') {
|
||||
return 'Moderator'
|
||||
}
|
||||
|
||||
if (!questionAuthorId || !replyAuthorId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return questionAuthorId === replyAuthorId ? 'Author' : null
|
||||
type QuestionProps = {
|
||||
// TODO: Deal with id possibly being undefined at first
|
||||
id: number
|
||||
question?: StrapiRecord<QuestionData>
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
const Collapsed = ({ setExpanded }: { setExpanded: (expanded: boolean) => void }) => {
|
||||
const { replies, resolvedBy, questionAuthorId } = useQuestion()
|
||||
const reply = replies[replies.findIndex((reply: any) => reply?.id === resolvedBy)] || replies[replies.length - 1]
|
||||
const replyCount = replies.length - 2
|
||||
const maxAvatars = Math.min(replyCount, 3)
|
||||
const replyAuthorMetadata = reply?.profile?.profiles_readonly?.[0] || reply?.profile?.metadata?.[0]
|
||||
export const CurrentQuestionContext = createContext<any>({})
|
||||
|
||||
const badgeText = getBadge(questionAuthorId, reply?.profile?.id, replyAuthorMetadata?.role)
|
||||
|
||||
const avatars: any[] = []
|
||||
|
||||
for (let reply of replies.slice(1)) {
|
||||
if (avatars.length >= maxAvatars) break
|
||||
const avatar = reply?.profile?.avatar
|
||||
if (avatar && !avatars.includes(avatar)) {
|
||||
avatars.push(avatar)
|
||||
}
|
||||
}
|
||||
|
||||
if (avatars.length < maxAvatars) {
|
||||
avatars.push(...Array(maxAvatars - avatars.length))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<li>
|
||||
<div className="squeak-other-replies-container">
|
||||
{avatars.map((avatar) => {
|
||||
return (
|
||||
<Avatar
|
||||
key={`${reply?.message_id}-${reply?.id}-${reply?.profile?.id}-${avatar}`}
|
||||
image={avatar}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<button className="squeak-other-replies" onClick={() => setExpanded(true)}>
|
||||
View {replyCount} other {replyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li
|
||||
key={reply?.id}
|
||||
className={`${resolvedBy === reply?.id ? 'squeak-solution' : ''} ${
|
||||
!reply?.published ? 'squeak-reply-unpublished' : ''
|
||||
}`}
|
||||
>
|
||||
<Reply className="squeak-post-reply" {...reply} badgeText={badgeText} />
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Expanded = () => {
|
||||
const question = useQuestion()
|
||||
const replies = question.replies?.slice(1)
|
||||
const { resolvedBy, questionAuthorId } = question
|
||||
|
||||
return (
|
||||
<>
|
||||
{replies.map((reply: any) => {
|
||||
const replyAuthorMetadata = reply?.profile?.profiles_readonly?.[0] || reply?.profile?.metadata?.[0]
|
||||
|
||||
const badgeText = getBadge(questionAuthorId, reply?.profile?.id, replyAuthorMetadata?.role)
|
||||
|
||||
return (
|
||||
<li
|
||||
key={reply.id}
|
||||
className={`${resolvedBy === reply.id ? 'squeak-solution' : ''} ${
|
||||
!reply.published ? 'squeak-reply-unpublished' : ''
|
||||
}`}
|
||||
>
|
||||
<Reply className="squeak-post-reply" {...reply} badgeText={badgeText} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Replies = ({ expanded, setExpanded }: { expanded: boolean; setExpanded: (expanded: boolean) => void }) => {
|
||||
const { resolved, replies, onSubmit, question } = useQuestion()
|
||||
|
||||
return (
|
||||
<>
|
||||
{replies && replies.length - 1 > 0 && (
|
||||
<ul className={`squeak-replies ${resolved ? 'squeak-thread-resolved' : ''}`}>
|
||||
{expanded || replies.length <= 2 ? <Expanded /> : <Collapsed setExpanded={setExpanded} />}
|
||||
</ul>
|
||||
)}
|
||||
{resolved ? (
|
||||
<div className="squeak-locked-message">
|
||||
<p>This thread has been closed</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="squeak-reply-form-container">
|
||||
{/* @ts-ignore */}
|
||||
<QuestionForm onSubmit={onSubmit} messageID={question.id} formType="reply" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type Reply = {
|
||||
id: string
|
||||
profile: Record<string, any>
|
||||
created_at: string
|
||||
body: string
|
||||
badgeText: string
|
||||
published: boolean
|
||||
}
|
||||
|
||||
type Question = {
|
||||
id: string
|
||||
subject: string
|
||||
permalink: string | null
|
||||
published: boolean
|
||||
replies: Reply[]
|
||||
}
|
||||
|
||||
export type QuestionProps = {
|
||||
onSubmit: (question: any) => void
|
||||
onResolve: (resolved: boolean, replyId: string | null) => void
|
||||
apiHost: string
|
||||
question?: Question
|
||||
}
|
||||
|
||||
export default function Question({ onSubmit, onResolve, apiHost, ...other }: QuestionProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [question, setQuestion] = useState(other?.question)
|
||||
const [replies, setReplies] = useState(other?.question?.replies || [])
|
||||
const [firstReply] = replies
|
||||
export const Question = (props: QuestionProps) => {
|
||||
const { id, question } = props
|
||||
const [expanded, setExpanded] = useState(props.expanded || false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// TODO: Default to question data if passed in
|
||||
const {
|
||||
organizationId,
|
||||
config: { permalink_base, permalinks_enabled },
|
||||
} = useOrg()
|
||||
question: questionData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
reply,
|
||||
handlePublishReply,
|
||||
handleResolve,
|
||||
handleReplyDelete,
|
||||
} = useQuestion(id, { data: question })
|
||||
|
||||
/*const getQuestion = async () => {
|
||||
const permalink = window.location.pathname
|
||||
// @ts-ignore
|
||||
const { response, data: question } =
|
||||
(await get(apiHost, '/api/question', {
|
||||
organizationId,
|
||||
permalink
|
||||
})) || {}
|
||||
|
||||
if (response?.status !== 200) return null
|
||||
|
||||
return question
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!question && permalink_base) {
|
||||
getQuestion().then((question) => {
|
||||
setQuestion(question?.question)
|
||||
setReplies(question?.replies || [])
|
||||
})
|
||||
if (isLoading) {
|
||||
return <QuestionSkeleton />
|
||||
}
|
||||
}, [organizationId, question, permalink_base])
|
||||
|
||||
useEffect(() => {
|
||||
setQuestion(other?.question)
|
||||
}, [other?.question])*/
|
||||
if (isError) {
|
||||
return <div>Error: {JSON.stringify(error)}</div>
|
||||
}
|
||||
|
||||
return question ? (
|
||||
<div className="squeak-question-container">
|
||||
<Reply
|
||||
permalink={permalinks_enabled && question?.permalink && `/${permalink_base}/${question?.permalink}`}
|
||||
className="squeak-post"
|
||||
subject={question.subject}
|
||||
{...firstReply}
|
||||
/>
|
||||
<QuestionProvider onSubmit={onSubmit} question={question} replies={replies} onResolve={onResolve}>
|
||||
<Replies expanded={expanded} setExpanded={setExpanded} />
|
||||
</QuestionProvider>
|
||||
</div>
|
||||
) : null
|
||||
if (!questionData) {
|
||||
return <div>Question not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<root.div ref={containerRef}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<CurrentQuestionContext.Provider
|
||||
value={{
|
||||
question: { id, ...(questionData?.attributes ?? {}) },
|
||||
handlePublishReply,
|
||||
handleResolve,
|
||||
handleReplyDelete,
|
||||
}}
|
||||
>
|
||||
<div className="squeak">
|
||||
<div className="squeak-question-container squeak-post">
|
||||
<div className="squeak-post-author">
|
||||
<Profile profile={questionData.attributes.profile?.data} />
|
||||
|
||||
<Days created={questionData.attributes.createdAt} />
|
||||
</div>
|
||||
<div className="squeak-post-content">
|
||||
<h3 className="squeak-subject">
|
||||
<a href={`/questions/${questionData.attributes.permalink}`} className="!no-underline">
|
||||
{questionData.attributes.subject}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<Markdown>{questionData.attributes.body}</Markdown>
|
||||
</div>
|
||||
|
||||
<Replies expanded={expanded} setExpanded={setExpanded} />
|
||||
|
||||
{questionData.attributes.resolved ? (
|
||||
<div className="squeak-locked-message">
|
||||
<p>This thread has been closed</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="squeak-reply-form-container">
|
||||
<QuestionForm questionId={questionData.id} formType="reply" reply={reply} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CurrentQuestionContext.Provider>
|
||||
</root.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import root from 'react-shadow/styled-components'
|
||||
import { useUser, User } from 'hooks/useUser'
|
||||
|
||||
import { useOrg } from '../hooks/useOrg'
|
||||
import { useQuestion } from '../hooks/useQuestion'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import { post } from '../lib/api'
|
||||
import { Approval } from './Approval'
|
||||
import Authentication from './Authentication'
|
||||
import Avatar from './Avatar'
|
||||
import Logo from './Logo'
|
||||
import RichText from './RichText'
|
||||
import { Theme } from './Theme'
|
||||
import getAvatarURL from '../util/getAvatar'
|
||||
|
||||
type QuestionFormValues = {
|
||||
subject: string
|
||||
body: string
|
||||
}
|
||||
|
||||
type QuestionFormMainProps = {
|
||||
title?: string
|
||||
onSubmit: any
|
||||
onSubmit: (values: QuestionFormValues, user: User | null) => void
|
||||
subject: boolean
|
||||
loading: boolean
|
||||
initialValues: any
|
||||
formType?: string
|
||||
initialValues?: Partial<QuestionFormValues> | null
|
||||
formType?: 'question' | 'reply'
|
||||
}
|
||||
|
||||
function QuestionFormMain({
|
||||
@@ -29,31 +34,19 @@ function QuestionFormMain({
|
||||
formType,
|
||||
}: QuestionFormMainProps) {
|
||||
const { user, logout } = useUser()
|
||||
const { profileLink } = useOrg()
|
||||
const handleSubmit = async (values: any) => {
|
||||
onSubmit &&
|
||||
(await onSubmit(
|
||||
{
|
||||
...values,
|
||||
email: user?.email,
|
||||
firstName: user?.profile?.first_name,
|
||||
lastName: user?.profile?.last_name,
|
||||
},
|
||||
formType
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="squeak-form-frame">
|
||||
{title && <h2>{title}</h2>}
|
||||
<Formik
|
||||
initialValues={{
|
||||
subject: '',
|
||||
question: '',
|
||||
body: '',
|
||||
...initialValues,
|
||||
}}
|
||||
validate={(values) => {
|
||||
const errors: any = {}
|
||||
if (!values.question) {
|
||||
if (!values.body) {
|
||||
errors.question = 'Required'
|
||||
}
|
||||
if (subject && !values.subject) {
|
||||
@@ -61,54 +54,47 @@ function QuestionFormMain({
|
||||
}
|
||||
return errors
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(values) => onSubmit(values, user)}
|
||||
>
|
||||
{({ setFieldValue, isValid }) => {
|
||||
return (
|
||||
<Form className="squeak-form">
|
||||
<Avatar
|
||||
url={user?.profile && profileLink && profileLink(user?.profile)}
|
||||
image={user?.profile?.avatar}
|
||||
/>
|
||||
<Avatar image={getAvatarURL(user?.profile)} />
|
||||
|
||||
<div className="">
|
||||
<div className="squeak-inputs-wrapper">
|
||||
{subject && (
|
||||
<>
|
||||
<Field
|
||||
required
|
||||
id="subject"
|
||||
name="subject"
|
||||
placeholder="Title"
|
||||
maxLength="140"
|
||||
/>
|
||||
<hr />
|
||||
</>
|
||||
)}
|
||||
<div className="squeak-form-richtext">
|
||||
<RichText
|
||||
setFieldValue={setFieldValue}
|
||||
initialValue={initialValues?.question}
|
||||
<div className="squeak-inputs-wrapper">
|
||||
{subject && (
|
||||
<>
|
||||
<Field
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
required
|
||||
id="subject"
|
||||
name="subject"
|
||||
placeholder="Title"
|
||||
maxLength="140"
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
</>
|
||||
)}
|
||||
<div className="squeak-form-richtext">
|
||||
<RichText setFieldValue={setFieldValue} initialValue={initialValues?.body} />
|
||||
</div>
|
||||
<span className="squeak-reply-buttons-row">
|
||||
<button
|
||||
className="squeak-post-button"
|
||||
style={loading || !isValid ? { opacity: '.5' } : {}}
|
||||
disabled={loading || !isValid}
|
||||
type="submit"
|
||||
>
|
||||
{user ? 'Post' : 'Login & post'}
|
||||
</button>
|
||||
<div className="squeak-by-line">
|
||||
by
|
||||
<a href="https://squeak.posthog.com?utm_source=post-form">
|
||||
<Logo />
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<span className="squeak-reply-buttons-row">
|
||||
<button
|
||||
className="squeak-post-button"
|
||||
style={loading || !isValid ? { opacity: '.5' } : {}}
|
||||
disabled={loading || !isValid}
|
||||
type="submit"
|
||||
>
|
||||
{user ? 'Post' : 'Login & post'}
|
||||
</button>
|
||||
<div className="squeak-by-line">
|
||||
by
|
||||
<a href="https://squeak.posthog.com?utm_source=post-form">
|
||||
<Logo />
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
</Form>
|
||||
)
|
||||
}}
|
||||
@@ -118,26 +104,28 @@ function QuestionFormMain({
|
||||
}
|
||||
|
||||
type QuestionFormProps = {
|
||||
slug?: string
|
||||
formType: string
|
||||
messageID?: string
|
||||
onSubmit: (values: any, formType: string) => void
|
||||
onSignUp?: () => void
|
||||
questionId?: number
|
||||
reply: (body: string) => Promise<void>
|
||||
onSubmit?: (values: any, formType: string) => void
|
||||
initialView?: string
|
||||
}
|
||||
|
||||
export default function QuestionForm({
|
||||
export const QuestionForm = ({
|
||||
slug,
|
||||
formType = 'question',
|
||||
messageID,
|
||||
questionId,
|
||||
initialView,
|
||||
reply,
|
||||
onSubmit,
|
||||
onSignUp,
|
||||
}: QuestionFormProps) {
|
||||
const { organizationId, apiHost, profileLink } = useOrg()
|
||||
const { user, logout } = useUser()
|
||||
const [formValues, setFormValues] = useState(null)
|
||||
}: QuestionFormProps) => {
|
||||
const { user, getJwt, logout } = useUser()
|
||||
const [formValues, setFormValues] = useState<QuestionFormValues | null>(null)
|
||||
const [view, setView] = useState<string | null>(initialView || null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { handleReply } = useQuestion()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const buttonText =
|
||||
formType === 'question' ? (
|
||||
<span>Ask a question</span>
|
||||
@@ -147,58 +135,55 @@ export default function QuestionForm({
|
||||
</span>
|
||||
)
|
||||
|
||||
const insertReply = async ({ body, messageID }: { body: string; messageID: string }) => {
|
||||
// @ts-ignore
|
||||
const { data } = await post(apiHost, '/api/reply', {
|
||||
body,
|
||||
organizationId,
|
||||
messageId: messageID,
|
||||
})
|
||||
return data
|
||||
}
|
||||
const createQuestion = async ({ subject, body }: QuestionFormValues) => {
|
||||
const token = await getJwt()
|
||||
|
||||
const insertMessage = async ({ subject, body, userID }: any) => {
|
||||
// @ts-ignore
|
||||
const { data } = await post(apiHost, '/api/question', {
|
||||
const data = {
|
||||
subject,
|
||||
body,
|
||||
organizationId,
|
||||
slug: window.location.pathname.replace(/\/$/, ''),
|
||||
resolved: false,
|
||||
slugs: [] as { slug: string }[],
|
||||
permalink: '',
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
data.slugs = [
|
||||
{
|
||||
slug,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const res = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/questions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
}),
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const handleMessageSubmit = async (values: any) => {
|
||||
const handleMessageSubmit = async (values: QuestionFormValues, user: User | null) => {
|
||||
setLoading(true)
|
||||
const userID = user?.id
|
||||
if (userID) {
|
||||
let view: any = null
|
||||
|
||||
if (user) {
|
||||
if (formType === 'question') {
|
||||
const { published: messagePublished } = await insertMessage({
|
||||
subject: values.subject,
|
||||
body: values.question,
|
||||
})
|
||||
if (!messagePublished) {
|
||||
view = 'approval'
|
||||
}
|
||||
await createQuestion(values)
|
||||
}
|
||||
|
||||
if (formType === 'reply' && messageID) {
|
||||
const data = await insertReply({
|
||||
body: values.question,
|
||||
messageID,
|
||||
})
|
||||
handleReply(data)
|
||||
if (!data.published) {
|
||||
view = 'approval'
|
||||
}
|
||||
if (formType === 'reply' && questionId) {
|
||||
reply(values.body)
|
||||
}
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(values, formType)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setView(view)
|
||||
setView(null)
|
||||
setFormValues(null)
|
||||
} else {
|
||||
setFormValues(values)
|
||||
@@ -207,65 +192,56 @@ export default function QuestionForm({
|
||||
}
|
||||
}
|
||||
|
||||
return view ? (
|
||||
{
|
||||
'question-form': (
|
||||
<QuestionFormMain
|
||||
subject={formType === 'question'}
|
||||
initialValues={formValues}
|
||||
loading={loading}
|
||||
onSubmit={handleMessageSubmit}
|
||||
/>
|
||||
),
|
||||
auth: (
|
||||
<Authentication
|
||||
banner={{
|
||||
title: 'Please signup to post.',
|
||||
body: 'Create an account to ask questions & help others.',
|
||||
}}
|
||||
buttonText={{
|
||||
login: 'Login & post question',
|
||||
signUp: 'Sign up & post question',
|
||||
}}
|
||||
setParentView={setView}
|
||||
formValues={formValues}
|
||||
handleMessageSubmit={handleMessageSubmit}
|
||||
onSignUp={onSignUp}
|
||||
/>
|
||||
),
|
||||
login: (
|
||||
<Authentication
|
||||
setParentView={setView}
|
||||
formValues={formValues}
|
||||
handleMessageSubmit={() => setView(null)}
|
||||
onSignUp={onSignUp}
|
||||
/>
|
||||
),
|
||||
approval: <Approval handleConfirm={() => setView(null)} />,
|
||||
}[view]
|
||||
) : (
|
||||
<div className="squeak-reply-buttons">
|
||||
<Avatar url={user?.profile && profileLink && profileLink(user?.profile)} image={user?.profile?.avatar} />
|
||||
<button
|
||||
className={formType === 'reply' ? 'squeak-reply-skeleton' : 'squeak-ask-button'}
|
||||
onClick={() => setView('question-form')}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
{formType === 'question' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (user) {
|
||||
logout()
|
||||
} else {
|
||||
setView('login')
|
||||
}
|
||||
}}
|
||||
className="squeak-auth-button"
|
||||
>
|
||||
{user ? 'Logout' : 'Login'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<root.div ref={containerRef}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
{view ? (
|
||||
{
|
||||
'question-form': (
|
||||
<QuestionFormMain
|
||||
subject={formType === 'question'}
|
||||
initialValues={formValues}
|
||||
loading={loading}
|
||||
onSubmit={handleMessageSubmit}
|
||||
/>
|
||||
),
|
||||
auth: (
|
||||
<Authentication
|
||||
buttonText={{ login: 'Login & post', signUp: 'Sign up & post' }}
|
||||
setParentView={setView}
|
||||
formValues={formValues}
|
||||
handleMessageSubmit={handleMessageSubmit}
|
||||
/>
|
||||
),
|
||||
approval: <Approval handleConfirm={() => setView(null)} />,
|
||||
}[view]
|
||||
) : (
|
||||
<div className="squeak-reply-buttons">
|
||||
<Avatar image={getAvatarURL(user?.profile)} />
|
||||
<button
|
||||
className={formType === 'reply' ? 'squeak-reply-skeleton' : 'squeak-ask-button'}
|
||||
onClick={() => setView('question-form')}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
{formType === 'question' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (user) {
|
||||
logout()
|
||||
} else {
|
||||
setView('auth')
|
||||
}
|
||||
}}
|
||||
className="squeak-auth-button"
|
||||
>
|
||||
{user ? 'Logout' : 'Login'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</root.div>
|
||||
)
|
||||
}
|
||||
|
||||
17
src/components/Squeak/components/QuestionSkeleton.tsx
Normal file
17
src/components/Squeak/components/QuestionSkeleton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function QuestionSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="w-[40px] h-[40px] bg-black dark:bg-white opacity-20 rounded-full flex-shrink-0" />
|
||||
<div className="w-full">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-[17px] bg-black dark:bg-white opacity-20 w-[40px] rounded-md" />
|
||||
<div className="h-[17px] bg-black dark:bg-white opacity-20 w-[80px] rounded-md" />
|
||||
</div>
|
||||
<div className="w-full bg-black dark:bg-white opacity-20 h-[18px] rounded-md mt-2" />
|
||||
<div className="w-full bg-black dark:bg-white opacity-20 h-[200px] rounded-md mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +1,51 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useOrg } from '../hooks/useOrg'
|
||||
import { post } from '../lib/api'
|
||||
import Question from './Question'
|
||||
import QuestionForm from './QuestionForm'
|
||||
import React, { useRef } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
|
||||
const Topics = ({
|
||||
handleTopicChange,
|
||||
activeTopic,
|
||||
topics,
|
||||
}: {
|
||||
handleTopicChange: (topic: null) => void
|
||||
activeTopic: any
|
||||
topics: any[]
|
||||
}) => {
|
||||
return topics && topics.length > 0 ? (
|
||||
<ul className="squeak-topics-container">
|
||||
<li>
|
||||
<button
|
||||
className={activeTopic === null ? 'squeak-active-topic' : ''}
|
||||
onClick={() => handleTopicChange(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</li>
|
||||
{topics.map((topic) => {
|
||||
return (
|
||||
<li key={topic.label}>
|
||||
<button
|
||||
className={activeTopic.label === topic.label ? 'squeak-active-topic' : ''}
|
||||
onClick={() => handleTopicChange(topic.label)}
|
||||
>
|
||||
{topic.label}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null
|
||||
}
|
||||
import { Question } from './Question'
|
||||
import { QuestionForm } from './QuestionForm'
|
||||
import { Theme } from './Theme'
|
||||
import { useQuestions } from 'hooks/useQuestions'
|
||||
|
||||
type QuestionsProps = {
|
||||
slug?: string
|
||||
limit?: number
|
||||
onSubmit: (values: any, formType: any) => void
|
||||
onLoad: () => void
|
||||
topics: boolean
|
||||
onSignUp: () => void
|
||||
topic: any
|
||||
profileId?: number
|
||||
topicId?: number
|
||||
showForm?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function Questions({ slug, limit = 100, onSubmit, onLoad, topics, onSignUp, topic }: QuestionsProps) {
|
||||
const [activeTopic, setActiveTopic] = useState(topic)
|
||||
const { organizationId, apiHost } = useOrg()
|
||||
const [questions, setQuestions] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [availableTopics, setAvailableTopics] = useState<any[]>([])
|
||||
const [count, setCount] = useState(0)
|
||||
const [start, setStart] = useState(0)
|
||||
|
||||
const getQuestions = async ({ limit, start, topic }: { limit: number; start: number; topic?: string }) => {
|
||||
// @ts-ignore
|
||||
const { response, data } =
|
||||
(await post(apiHost, `/api/questions`, {
|
||||
organizationId,
|
||||
slug,
|
||||
published: true,
|
||||
perPage: limit,
|
||||
start,
|
||||
topic,
|
||||
})) || {}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { questions: [], count: 0 }
|
||||
}
|
||||
|
||||
// returns a structure that looks like: {questions: [{id: 123}], count: 123}
|
||||
return data
|
||||
}
|
||||
|
||||
const getAvailableTopics = (questions: any[]) => {
|
||||
const availableTopics: any[] = []
|
||||
questions.forEach(({ question: { topics } }) => {
|
||||
topics?.forEach((topic: any) => {
|
||||
if (!availableTopics.includes(topic)) availableTopics.push(topic)
|
||||
})
|
||||
})
|
||||
return availableTopics
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getQuestions({ limit, start, topic: activeTopic }).then((data) => {
|
||||
setQuestions([...questions, ...data.questions])
|
||||
setCount(data.count)
|
||||
setAvailableTopics(getAvailableTopics([...questions, ...data.questions]))
|
||||
onLoad?.()
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (values: any, formType: any) => {
|
||||
getQuestions({ limit: 1, start: 0 }).then((data) => {
|
||||
setQuestions([...data.questions, ...questions])
|
||||
setCount(data.count)
|
||||
setStart(start + 1)
|
||||
setAvailableTopics(getAvailableTopics([...questions, ...data.questions]))
|
||||
onSubmit?.(values, formType)
|
||||
})
|
||||
}
|
||||
|
||||
const handleShowMore = () => {
|
||||
getQuestions({ limit, start: start + limit, topic: activeTopic }).then((data) => {
|
||||
setQuestions([...questions, ...data.questions])
|
||||
setCount(data.count)
|
||||
setStart(start + limit)
|
||||
setAvailableTopics(getAvailableTopics([...questions, ...data.questions]))
|
||||
})
|
||||
}
|
||||
|
||||
const handleTopicChange = (topic: any) => {
|
||||
if (topic === activeTopic) return
|
||||
getQuestions({ limit, start: 0, topic }).then((data) => {
|
||||
setStart(0)
|
||||
setQuestions(data.questions)
|
||||
setCount(data.count)
|
||||
setActiveTopic(topic)
|
||||
})
|
||||
}
|
||||
export const Questions = ({ slug, limit, topicId, profileId, showForm = true, title }: QuestionsProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { questions, fetchMore, refresh } = useQuestions({ slug, limit, topicId, profileId })
|
||||
const hasQuestions = questions.data && questions.data.length > 0
|
||||
return (
|
||||
<>
|
||||
{topics && (
|
||||
<Topics topics={availableTopics} handleTopicChange={handleTopicChange} activeTopic={activeTopic} />
|
||||
)}
|
||||
|
||||
{questions && questions.length > 0 && (
|
||||
<>
|
||||
<root.div ref={containerRef}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
{hasQuestions && title && <h3>{title}</h3>}
|
||||
{hasQuestions && (
|
||||
<ul className="squeak-questions">
|
||||
{questions.map((question) => {
|
||||
{questions.data.map((question) => {
|
||||
return (
|
||||
<li key={question.question.id}>
|
||||
<Question onSubmit={handleSubmit} {...question} />
|
||||
<li key={question.id}>
|
||||
<Question id={question.id} question={question} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{start + limit < count && (
|
||||
<button disabled={loading} className="squeak-show-more-questions-button" onClick={handleShowMore}>
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
{/*start + limit < count && (
|
||||
<button disabled={loading} className="squeak-show-more-questions-button" onClick={fetchMore}>
|
||||
Show more
|
||||
</button>
|
||||
)*/}
|
||||
|
||||
{/* @ts-ignore */}
|
||||
<QuestionForm onSignUp={onSignUp} onSubmit={handleSubmit} formType="question" />
|
||||
</>
|
||||
{/* TODO: Pass refresh for now questions */}
|
||||
{showForm && <QuestionForm onSubmit={refresh} formType="question" slug={slug} />}
|
||||
</div>
|
||||
</root.div>
|
||||
)
|
||||
}
|
||||
|
||||
127
src/components/Squeak/components/Replies.tsx
Normal file
127
src/components/Squeak/components/Replies.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useContext } from 'react'
|
||||
import { StrapiData, ReplyData } from 'lib/strapi'
|
||||
import Avatar from './Avatar'
|
||||
import Reply from './Reply'
|
||||
import { CurrentQuestionContext } from './Question'
|
||||
import getAvatarURL from '../util/getAvatar'
|
||||
|
||||
const getBadge = (questionProfileID: string, replyProfileID: string) => {
|
||||
if (!questionProfileID || !replyProfileID) {
|
||||
return null
|
||||
}
|
||||
|
||||
return questionProfileID === replyProfileID ? 'Author' : null
|
||||
}
|
||||
|
||||
type RepliesProps = {
|
||||
expanded: boolean
|
||||
setExpanded: (expanded: boolean) => void
|
||||
}
|
||||
|
||||
export const Replies = ({ expanded, setExpanded }: RepliesProps) => {
|
||||
const {
|
||||
question: { replies, resolved, resolvedBy },
|
||||
} = useContext(CurrentQuestionContext)
|
||||
return replies && replies.data.length > 0 ? (
|
||||
<ul className={`squeak-replies ${resolved ? 'squeak-thread-resolved' : ''}`}>
|
||||
{expanded || replies.data.length < 3 ? (
|
||||
<Expanded replies={replies} resolvedBy={resolvedBy?.data?.id} />
|
||||
) : (
|
||||
<Collapsed replies={replies} setExpanded={setExpanded} resolvedBy={resolvedBy?.data?.id} />
|
||||
)}
|
||||
</ul>
|
||||
) : null
|
||||
}
|
||||
|
||||
type CollapsedProps = {
|
||||
setExpanded: (expanded: boolean) => void
|
||||
replies: StrapiData<ReplyData[]>
|
||||
resolvedBy: number
|
||||
}
|
||||
|
||||
const Collapsed = ({ setExpanded, replies, resolvedBy }: CollapsedProps) => {
|
||||
const reply = replies?.data?.find((reply) => reply?.id === resolvedBy) || replies.data[replies.data.length - 1]
|
||||
const replyCount = replies.data.length
|
||||
const maxAvatars = Math.min(replyCount - 1, 3)
|
||||
const {
|
||||
question: {
|
||||
profile: {
|
||||
data: { id: questionProfileID },
|
||||
},
|
||||
},
|
||||
} = useContext(CurrentQuestionContext)
|
||||
|
||||
const badgeText = getBadge(questionProfileID, reply?.attributes?.profile?.data?.id)
|
||||
const avatars: any[] = []
|
||||
|
||||
for (const reply of replies?.data || []) {
|
||||
if (avatars.length >= maxAvatars) break
|
||||
const avatar = getAvatarURL(reply?.attributes?.profile?.data)
|
||||
if (avatar && !avatars.includes(avatar)) {
|
||||
avatars.push(avatar)
|
||||
}
|
||||
}
|
||||
|
||||
if (avatars.length < maxAvatars) {
|
||||
avatars.push(...Array(maxAvatars - avatars.length))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<li>
|
||||
<div className="squeak-other-replies-container">
|
||||
{avatars.map((avatar, index) => {
|
||||
return <Avatar key={index} image={avatar} />
|
||||
})}
|
||||
|
||||
<button className="squeak-other-replies" onClick={() => setExpanded(true)}>
|
||||
View {replyCount - 1} other {replyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li
|
||||
key={reply?.id}
|
||||
className={`${resolvedBy === reply?.id ? 'squeak-solution' : ''} ${
|
||||
!reply?.attributes?.publishedAt ? 'squeak-reply-unpublished' : ''
|
||||
}`}
|
||||
>
|
||||
<Reply className="squeak-post-reply" reply={reply} badgeText={badgeText} />
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ExpandedProps = {
|
||||
replies: StrapiData<ReplyData[]>
|
||||
resolvedBy: number
|
||||
}
|
||||
|
||||
const Expanded = ({ replies, resolvedBy }: ExpandedProps) => {
|
||||
const {
|
||||
question: {
|
||||
profile: {
|
||||
data: { id: questionProfileID },
|
||||
},
|
||||
},
|
||||
} = useContext(CurrentQuestionContext)
|
||||
|
||||
return (
|
||||
<>
|
||||
{replies.data.map((reply) => {
|
||||
const badgeText = getBadge(questionProfileID, reply?.attributes?.profile?.data?.id)
|
||||
|
||||
return (
|
||||
<li
|
||||
key={reply.id}
|
||||
className={`${resolvedBy === reply.id ? 'squeak-solution' : ''} ${
|
||||
!reply?.attributes?.publishedAt ? 'squeak-reply-unpublished' : ''
|
||||
}`}
|
||||
>
|
||||
<Reply className="squeak-post-reply" reply={reply} badgeText={badgeText} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +1,40 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuestion } from '../hooks/useQuestion'
|
||||
import React, { useContext, useState } from 'react'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import { useOrg } from '../hooks/useOrg'
|
||||
import Avatar from './Avatar'
|
||||
import Days from './Days'
|
||||
|
||||
const Markdown = React.lazy(() => import('./Markdown'))
|
||||
import Markdown from './Markdown'
|
||||
import { StrapiRecord, ReplyData } from 'lib/strapi'
|
||||
import Avatar from './Avatar'
|
||||
import getAvatarURL from '../util/getAvatar'
|
||||
import { CurrentQuestionContext } from './Question'
|
||||
|
||||
type ReplyProps = {
|
||||
id: string
|
||||
profile: Record<string, any>
|
||||
created_at: string
|
||||
body: string
|
||||
subject?: string
|
||||
reply: StrapiRecord<ReplyData>
|
||||
badgeText?: string | null
|
||||
published: boolean
|
||||
permalink?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Reply({
|
||||
profile,
|
||||
created_at,
|
||||
body,
|
||||
subject,
|
||||
badgeText,
|
||||
id,
|
||||
published,
|
||||
permalink,
|
||||
...other
|
||||
}: ReplyProps) {
|
||||
const isSSR = typeof window === 'undefined'
|
||||
export default function Reply({ reply, badgeText }: ReplyProps) {
|
||||
const {
|
||||
id,
|
||||
attributes: { body, createdAt, profile, publishedAt },
|
||||
} = reply
|
||||
|
||||
const question = useQuestion()
|
||||
const { questionAuthorId, resolved, resolvedBy, handleResolve, handlePublish, handleReplyDelete } = question
|
||||
const {
|
||||
question: { resolvedBy, id: questionID, profile: questionProfile, resolved },
|
||||
handlePublishReply,
|
||||
handleResolve,
|
||||
handleReplyDelete,
|
||||
} = useContext(CurrentQuestionContext)
|
||||
const stuff = useContext(CurrentQuestionContext)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const { user } = useUser()
|
||||
const { profileLink } = useOrg()
|
||||
const isModerator = user?.isModerator
|
||||
const isAuthor = user?.id === questionAuthorId
|
||||
const handleDelete = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const isModerator = user?.role?.type === 'moderator'
|
||||
const isAuthor = user?.profile?.id === questionProfile?.data?.id
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (confirmDelete) {
|
||||
handleReplyDelete(id)
|
||||
await handleReplyDelete(id)
|
||||
} else {
|
||||
setConfirmDelete(true)
|
||||
}
|
||||
@@ -52,28 +44,20 @@ export default function Reply({
|
||||
setConfirmDelete(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...other} onClick={handleContainerClick}>
|
||||
return profile?.data ? (
|
||||
<div onClick={handleContainerClick}>
|
||||
<div className="squeak-post-author">
|
||||
{profileLink ? (
|
||||
<a className="squeak-profile-link" href={profileLink(profile)}>
|
||||
<Avatar image={profile?.avatar} />
|
||||
<strong className="squeak-author-name">{profile?.first_name || 'Anonymous'}</strong>
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<Avatar image={profile?.avatar} />
|
||||
<strong className="squeak-author-name">{profile?.first_name || 'Anonymous'}</strong>
|
||||
</>
|
||||
)}
|
||||
|
||||
<a className="squeak-profile-link" href={`/community/profiles/${profile.data.id}`}>
|
||||
<Avatar image={getAvatarURL(profile?.data?.attributes)} />
|
||||
<strong className="squeak-author-name">{profile.data.attributes.firstName || 'Anonymous'}</strong>
|
||||
</a>
|
||||
{badgeText && <span className="squeak-author-badge">{badgeText}</span>}
|
||||
<Days created={created_at} />
|
||||
{resolved && resolvedBy === id && (
|
||||
<Days created={createdAt} />
|
||||
{resolved && resolvedBy?.data?.id === id && (
|
||||
<>
|
||||
<span className="squeak-resolved-badge">Solution</span>
|
||||
{(isAuthor || isModerator) && (
|
||||
<button onClick={() => handleResolve(false)} className="squeak-undo-resolved">
|
||||
<button onClick={() => handleResolve(false, null)} className="squeak-undo-resolved">
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
@@ -81,36 +65,26 @@ export default function Reply({
|
||||
)}
|
||||
</div>
|
||||
<div className="squeak-post-content">
|
||||
{subject && (
|
||||
<h3 className="squeak-subject">{permalink ? <a href={permalink}>{subject}</a> : subject}</h3>
|
||||
)}
|
||||
<Markdown>{body}</Markdown>
|
||||
|
||||
{!isSSR && (
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Markdown>{body}</Markdown>
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{!subject && (
|
||||
<div className="squeak-reply-action-buttons">
|
||||
{!resolved && (isAuthor || isModerator) && (
|
||||
<button onClick={() => handleResolve(true, id)} className="squeak-resolve-button">
|
||||
Mark as solution
|
||||
</button>
|
||||
)}
|
||||
{isModerator && (
|
||||
<button onClick={() => handlePublish(id, !published)} className="squeak-publish-button">
|
||||
{published ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
{isModerator && (
|
||||
<button onClick={handleDelete} className="squeak-delete-button">
|
||||
{confirmDelete ? 'Click again to confirm' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="squeak-reply-action-buttons">
|
||||
{!resolved && (isAuthor || isModerator) && (
|
||||
<button onClick={() => handleResolve(true, id)} className="squeak-resolve-button">
|
||||
Mark as solution
|
||||
</button>
|
||||
)}
|
||||
{isModerator && (
|
||||
<button onClick={() => handlePublishReply(!!publishedAt, id)} className="squeak-publish-button">
|
||||
{publishedAt ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
{isModerator && (
|
||||
<button onClick={handleDelete} className="squeak-delete-button">
|
||||
{confirmDelete ? 'Click again to confirm' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -109,18 +109,19 @@ export default function RichText({ initialValue = '', setFieldValue }: any) {
|
||||
}, [cursor])
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValue('question', value)
|
||||
setFieldValue('body', value)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
name="question"
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
name="body"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
ref={textarea}
|
||||
required
|
||||
id="question"
|
||||
id="body"
|
||||
placeholder="Type more details..."
|
||||
maxLength={2000}
|
||||
/>
|
||||
@@ -140,7 +141,7 @@ export default function RichText({ initialValue = '', setFieldValue }: any) {
|
||||
<a
|
||||
href="https://www.markdownguide.org/cheat-sheet/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noreferrer"
|
||||
title="Supports Markdown syntax"
|
||||
>
|
||||
<MarkdownLogo />
|
||||
|
||||
32
src/components/Squeak/components/Squeak.tsx
Normal file
32
src/components/Squeak/components/Squeak.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { useRef } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
|
||||
import { Questions } from './Questions'
|
||||
import { Theme } from './Theme'
|
||||
|
||||
type SqueakProps = {
|
||||
slug?: string
|
||||
limit?: number
|
||||
topicId?: number
|
||||
}
|
||||
|
||||
export const Squeak = ({ slug, limit, topicId }: SqueakProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const currentSlug = topicId
|
||||
? undefined
|
||||
: slug || typeof window !== 'undefined'
|
||||
? window.location.pathname
|
||||
: undefined
|
||||
|
||||
// TODO: Create hubspot contact on sign-up
|
||||
|
||||
return (
|
||||
<root.div ref={containerRef}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
<Questions limit={limit} slug={currentSlug} topicId={topicId} />
|
||||
</div>
|
||||
</root.div>
|
||||
)
|
||||
}
|
||||
@@ -69,7 +69,6 @@ const Style = createGlobalStyle`
|
||||
}
|
||||
|
||||
.squeak-authentication-form-container {
|
||||
margin-left: 50px;
|
||||
max-width: 600px;
|
||||
|
||||
.squeak-authentication-form-message {
|
||||
@@ -313,13 +312,6 @@ const Style = createGlobalStyle`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replies to questions
|
||||
.squeak-replies {
|
||||
.squeak-post-content {
|
||||
padding-left: calc(25px + 10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.squeak-question-container {
|
||||
@@ -348,6 +340,13 @@ const Style = createGlobalStyle`
|
||||
span {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
button.squeak-reply-skeleton {
|
||||
@@ -600,10 +599,10 @@ const Style = createGlobalStyle`
|
||||
margin: 0 .5rem 0 0;
|
||||
|
||||
a {
|
||||
color: rgba(var(--primary-color), .3);
|
||||
color: rgba(0, 0, 0, .3);
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--primary-color), .4);
|
||||
color: rgba(0, 0, 0, .4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -724,6 +723,7 @@ const Style = createGlobalStyle`
|
||||
margin: -1.5rem -1.5rem 1.5rem;
|
||||
padding: 1.5rem;
|
||||
color: red;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.squeak-return-to-post {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import { useOrg } from '../../hooks/useOrg'
|
||||
import { post } from '../../lib/api'
|
||||
|
||||
type ForgotPasswordProps = {
|
||||
apiHost: string
|
||||
@@ -12,20 +10,24 @@ type ForgotPasswordProps = {
|
||||
const ForgotPassword: React.FC<ForgotPasswordProps> = ({ setMessage, setParentView, apiHost }) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [emailSent, setEmailSent] = useState(false)
|
||||
const { organizationId } = useOrg()
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true)
|
||||
const { error } =
|
||||
(await post(apiHost, '/api/password/forgot', {
|
||||
email: values.email,
|
||||
redirect: window.location.href,
|
||||
organizationId,
|
||||
})) || {}
|
||||
|
||||
const body = {
|
||||
email: values.email,
|
||||
}
|
||||
|
||||
const { error } = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json())
|
||||
|
||||
if (error) {
|
||||
// @ts-ignore
|
||||
setMessage(error.message)
|
||||
setMessage(error?.message)
|
||||
} else {
|
||||
setEmailSent(true)
|
||||
}
|
||||
@@ -33,11 +35,6 @@ const ForgotPassword: React.FC<ForgotPasswordProps> = ({ setMessage, setParentVi
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleReturnToPost = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
setParentView?.('question-form')
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validateOnMount
|
||||
@@ -61,12 +58,6 @@ const ForgotPassword: React.FC<ForgotPasswordProps> = ({ setMessage, setParentVi
|
||||
{emailSent ? (
|
||||
<div>
|
||||
<p>Check your email for password reset instructions</p>
|
||||
<p>
|
||||
<button onClick={handleReturnToPost} className="squeak-return-to-post">
|
||||
Click here
|
||||
</button>{' '}
|
||||
to return to your post
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button style={loading || !isValid ? { opacity: '.5' } : {}} type="submit">
|
||||
|
||||
@@ -1,41 +1,60 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import { post } from '../../lib/api'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import { navigate } from 'gatsby'
|
||||
|
||||
type ResetPasswordProps = {
|
||||
setMessage: (message: any) => void
|
||||
setParentView?: (view: string | null) => void
|
||||
apiHost: string
|
||||
}
|
||||
|
||||
const ResetPassword: React.FC<ResetPasswordProps> = ({ setMessage, setParentView, apiHost }) => {
|
||||
const ResetPassword: React.FC<ResetPasswordProps> = ({ setMessage, setParentView }) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const resetPassword = useRef<HTMLDivElement>(null)
|
||||
const [code, setCode] = useState<null | string>(null)
|
||||
const { login } = useUser()
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
if (!code) return
|
||||
setLoading(true)
|
||||
const { error } =
|
||||
(await post(apiHost, '/api/password/reset', {
|
||||
password: values.password,
|
||||
})) || {}
|
||||
const body = {
|
||||
code,
|
||||
password: values.password,
|
||||
passwordConfirmation: values.password,
|
||||
}
|
||||
const { error, user } = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json())
|
||||
|
||||
if (error) {
|
||||
// @ts-ignore
|
||||
setMessage(error.message)
|
||||
setMessage(error?.message)
|
||||
setLoading(false)
|
||||
} else {
|
||||
setParentView?.(null)
|
||||
await login({
|
||||
email: user.email,
|
||||
password: values.password,
|
||||
})
|
||||
navigate('/community')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (resetPassword?.current) {
|
||||
resetPassword.current.scrollIntoView()
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window?.location?.search)
|
||||
const code = params.get('code')
|
||||
if (!code) {
|
||||
setMessage('Invalid password reset token')
|
||||
} else {
|
||||
setCode(code)
|
||||
}
|
||||
}
|
||||
}, [resetPassword])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={resetPassword}>
|
||||
<div>
|
||||
<Formik
|
||||
validateOnMount
|
||||
initialValues={{
|
||||
@@ -54,8 +73,15 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ setMessage, setParentView
|
||||
return (
|
||||
<Form>
|
||||
<label htmlFor="password">New password</label>
|
||||
<Field required id="password" name="password" type="password" placeholder="New password" />
|
||||
<button style={loading || !isValid ? { opacity: '.5' } : {}} type="submit">
|
||||
<Field
|
||||
disabled={!code}
|
||||
required
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
<button disabled={!code} style={loading || !isValid ? { opacity: '.5' } : {}} type="submit">
|
||||
Reset password
|
||||
</button>
|
||||
</Form>
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import React from 'react'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import { User, useUser } from 'hooks/useUser'
|
||||
|
||||
type SignInProps = {
|
||||
setMessage: any
|
||||
handleMessageSubmit: (message: any) => Promise<void> | void
|
||||
formValues: any
|
||||
apiHost: string
|
||||
buttonText: string
|
||||
organizationId: string
|
||||
buttonText?: string
|
||||
onSubmit?: (user: User | null) => void
|
||||
setMessage?: React.Dispatch<React.SetStateAction<string | null>>
|
||||
}
|
||||
|
||||
const SignIn: React.FC<SignInProps> = ({ setMessage, handleMessageSubmit, formValues, buttonText }) => {
|
||||
const errorMessages = {
|
||||
'Invalid identifier or password': 'Invalid email or password',
|
||||
}
|
||||
|
||||
export const SignIn: React.FC<SignInProps> = ({ buttonText = 'Login', onSubmit, setMessage }) => {
|
||||
const { isLoading, login } = useUser()
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
let user = await login({
|
||||
const user = await login({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
setMessage('Incorrect email/password. Please try again.')
|
||||
if (user?.error) {
|
||||
setMessage?.(errorMessages[user?.error] || user?.error)
|
||||
} else {
|
||||
await handleMessageSubmit(formValues || { email: values.email })
|
||||
onSubmit?.(user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +51,23 @@ const SignIn: React.FC<SignInProps> = ({ setMessage, handleMessageSubmit, formVa
|
||||
return (
|
||||
<Form>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<Field required id="email" name="email" type="email" placeholder="Email address..." />
|
||||
<Field
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
required
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email address..."
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<Field required id="password" name="password" type="password" placeholder="Password..." />
|
||||
<Field
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
required
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password..."
|
||||
/>
|
||||
<button style={isLoading || !isValid ? { opacity: '.5' } : {}} type="submit">
|
||||
{buttonText}
|
||||
</button>
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import { User, useUser } from 'hooks/useUser'
|
||||
import React from 'react'
|
||||
|
||||
type SignUpProps = {
|
||||
setMessage: (message: any) => void
|
||||
handleMessageSubmit: (message: any) => Promise<void> | void
|
||||
formValues: any
|
||||
organizationId: string
|
||||
apiHost: string
|
||||
buttonText: string
|
||||
onSignUp?: (values: any) => void
|
||||
buttonText?: string
|
||||
onSubmit?: (user: User | null) => void
|
||||
setMessage?: React.Dispatch<React.SetStateAction<string | null>>
|
||||
}
|
||||
|
||||
const SignUp: React.FC<SignUpProps> = ({ handleMessageSubmit, formValues, buttonText, onSignUp }) => {
|
||||
export const SignUp: React.FC<SignUpProps> = ({ buttonText = 'Sign up', onSubmit, setMessage }) => {
|
||||
const { signUp } = useUser()
|
||||
const handleSubmit = async (values: any) => {
|
||||
await signUp(values)
|
||||
await handleMessageSubmit(formValues || { email: values.email })
|
||||
|
||||
onSignUp &&
|
||||
onSignUp({
|
||||
email: values.email,
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
})
|
||||
const handleSubmit = async (values: any) => {
|
||||
const user = await signUp(values)
|
||||
|
||||
if (user?.error) {
|
||||
setMessage?.(user?.error)
|
||||
} else {
|
||||
onSubmit?.(user)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -57,6 +52,7 @@ const SignUp: React.FC<SignUpProps> = ({ handleMessageSubmit, formValues, button
|
||||
<span>
|
||||
<label htmlFor="firstName">First name</label>
|
||||
<Field
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
required
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
@@ -66,13 +62,33 @@ const SignUp: React.FC<SignUpProps> = ({ handleMessageSubmit, formValues, button
|
||||
</span>
|
||||
<span>
|
||||
<label htmlFor="lastName">Last name</label>
|
||||
<Field id="lastName" name="lastName" type="text" placeholder="Last name..." />
|
||||
<Field
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
placeholder="Last name..."
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<Field required id="email" name="email" type="email" placeholder="Email address..." />
|
||||
<Field
|
||||
required
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email address..."
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<Field required id="password" name="password" type="password" placeholder="Password..." />
|
||||
<Field
|
||||
required
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password..."
|
||||
/>
|
||||
<button style={isSubmitting || !isValid ? { opacity: '.5' } : {}} type="submit">
|
||||
{buttonText}
|
||||
</button>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { useRef } from 'react'
|
||||
import AuthenticationComponent from '../Authentication'
|
||||
import root from 'react-shadow/styled-components'
|
||||
import { Provider as OrgProvider } from '../../hooks/useOrg'
|
||||
import { Theme } from '../Theme'
|
||||
import ErrorBoundary from '../ErrorBoundary'
|
||||
|
||||
type AuthenticationProps = {
|
||||
organizationId: string
|
||||
apiHost: string
|
||||
onAuth: () => void
|
||||
}
|
||||
|
||||
export const Authentication: React.FC<AuthenticationProps> = ({ onAuth, organizationId, apiHost }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{/* @ts-ignore */}
|
||||
<root.div ref={containerRef}>
|
||||
<OrgProvider value={{ organizationId, apiHost }}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
<AuthenticationComponent
|
||||
banner={{
|
||||
title: 'Please signup to post.',
|
||||
body: 'Create an account to ask questions & help others.',
|
||||
}}
|
||||
buttonText={{
|
||||
login: 'Login & post question',
|
||||
signUp: 'Sign up & post question',
|
||||
}}
|
||||
setParentView={() => {}}
|
||||
handleMessageSubmit={onAuth}
|
||||
onSignUp={onAuth}
|
||||
/>
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</root.div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
export default Authentication
|
||||
@@ -1,32 +0,0 @@
|
||||
import React, { useRef } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
import { Provider as OrgProvider } from '../../hooks/useOrg'
|
||||
import QuestionForm from '../QuestionForm'
|
||||
import { Theme } from '../Theme'
|
||||
import ErrorBoundary from '../ErrorBoundary'
|
||||
|
||||
type FormProps = {
|
||||
apiHost: string
|
||||
organizationId: string
|
||||
initialView?: string
|
||||
onSubmit: React.FormEventHandler
|
||||
}
|
||||
|
||||
export const Form: React.FC<FormProps> = ({ apiHost, organizationId, initialView, onSubmit }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{/* @ts-ignore */}
|
||||
<root.div ref={containerRef}>
|
||||
<OrgProvider value={{ organizationId, apiHost }}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
{/* @ts-ignore */}
|
||||
<QuestionForm onSubmit={onSubmit} initialView={initialView} />
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</root.div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import React, { useRef } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
import Authentication from '../Authentication'
|
||||
import { Theme } from '../Theme'
|
||||
import { createGlobalStyle } from 'styled-components'
|
||||
|
||||
const Style = createGlobalStyle`
|
||||
.squeak {
|
||||
.squeak-avatar-container {
|
||||
display: none !important;
|
||||
}
|
||||
.squeak-authentication-form-container {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type LoginProps = {
|
||||
buttonText?: any
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export const Login: React.FC<LoginProps> = ({ onSubmit, buttonText }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* @ts-ignore */}
|
||||
<root.div ref={containerRef}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<Style />
|
||||
<div className="squeak">
|
||||
<Authentication buttonText={buttonText} handleMessageSubmit={onSubmit} />
|
||||
</div>
|
||||
</root.div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React, { useRef } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
import { Provider as OrgProvider } from '../../hooks/useOrg'
|
||||
import SingleQuestion, { QuestionProps } from '../Question'
|
||||
import { Theme } from '../Theme'
|
||||
import ErrorBoundary from '../ErrorBoundary'
|
||||
|
||||
export const Question: React.FC<QuestionProps & { organizationId: string }> = ({
|
||||
apiHost,
|
||||
organizationId,
|
||||
onResolve,
|
||||
onSubmit,
|
||||
question,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{/* @ts-ignore */}
|
||||
<root.div ref={containerRef}>
|
||||
<OrgProvider value={{ organizationId, apiHost }}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
<SingleQuestion
|
||||
apiHost={apiHost}
|
||||
question={question}
|
||||
onSubmit={onSubmit}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</root.div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import React, { useRef } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
|
||||
import { Provider as OrgProvider } from '../../hooks/useOrg'
|
||||
import QuestionsList from '../Questions'
|
||||
import { Theme } from '../Theme'
|
||||
import ErrorBoundary from '../ErrorBoundary'
|
||||
|
||||
type QuestionsProps = {
|
||||
apiHost: string
|
||||
organizationId: string
|
||||
limit?: number
|
||||
onSubmit: () => void
|
||||
onLoad: () => void
|
||||
topics?: boolean
|
||||
onSignUp: () => void
|
||||
topic?: any
|
||||
profileLink?: (profile: any) => string
|
||||
}
|
||||
|
||||
export const Questions = ({
|
||||
apiHost,
|
||||
organizationId,
|
||||
limit,
|
||||
onSubmit,
|
||||
onLoad,
|
||||
topics = true,
|
||||
onSignUp,
|
||||
topic,
|
||||
profileLink,
|
||||
}: QuestionsProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{/* @ts-ignore */}
|
||||
<root.div ref={containerRef}>
|
||||
<OrgProvider value={{ organizationId, apiHost, profileLink }}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
<QuestionsList
|
||||
onSignUp={onSignUp}
|
||||
onLoad={onLoad}
|
||||
topics={topics}
|
||||
onSubmit={onSubmit}
|
||||
limit={limit}
|
||||
topic={topic}
|
||||
/>
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</root.div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React, { useRef } from 'react'
|
||||
import root from 'react-shadow/styled-components'
|
||||
|
||||
import { Provider as OrgProvider } from '../../hooks/useOrg'
|
||||
import Questions from '../Questions'
|
||||
import { Theme } from '../Theme'
|
||||
import ErrorBoundary from '../ErrorBoundary'
|
||||
|
||||
type SqueakProps = {
|
||||
apiHost: string
|
||||
organizationId: string
|
||||
slug?: string
|
||||
limit?: number
|
||||
onSubmit: () => void
|
||||
onLoad: () => void
|
||||
topics?: boolean
|
||||
onSignUp: () => void
|
||||
topic?: any
|
||||
profileLink?: (profile: any) => string
|
||||
}
|
||||
|
||||
export const Squeak = ({
|
||||
apiHost,
|
||||
organizationId,
|
||||
slug,
|
||||
limit,
|
||||
onSubmit,
|
||||
onLoad,
|
||||
topics = true,
|
||||
onSignUp,
|
||||
topic,
|
||||
profileLink,
|
||||
}: SqueakProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const currentSlug = topic ? undefined : slug || typeof window !== 'undefined' ? window.location.pathname : undefined
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{/* @ts-ignore */}
|
||||
<root.div ref={containerRef}>
|
||||
<OrgProvider value={{ organizationId, apiHost, profileLink }}>
|
||||
<Theme containerRef={containerRef} />
|
||||
<div className="squeak">
|
||||
<Questions
|
||||
onSignUp={onSignUp}
|
||||
onLoad={onLoad}
|
||||
topics={topics}
|
||||
onSubmit={onSubmit}
|
||||
limit={limit}
|
||||
slug={currentSlug}
|
||||
topic={topic}
|
||||
/>
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</root.div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { get } from '../lib/api'
|
||||
|
||||
type OrgContextValue = {
|
||||
apiHost: string
|
||||
organizationId: string
|
||||
config?: any
|
||||
profileLink?: (profile: any) => string
|
||||
}
|
||||
|
||||
export const Context = createContext<OrgContextValue | undefined>(undefined)
|
||||
|
||||
type ProviderProps = {
|
||||
value: OrgContextValue
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const Provider: React.FC<ProviderProps> = ({ value: { apiHost, organizationId, profileLink }, children }) => {
|
||||
const [config, setConfig] = useState({})
|
||||
|
||||
const getConfig = async () => {
|
||||
const { data } = (await get(apiHost, '/api/config', { organizationId })) || {}
|
||||
return data
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getConfig().then((config) => {
|
||||
setConfig(config)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <Context.Provider value={{ apiHost, organizationId, config, profileLink }}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export const useOrg = () => {
|
||||
const org = useContext(Context)
|
||||
if (org === undefined) {
|
||||
throw Error('No org has been specified using Provider')
|
||||
}
|
||||
return org
|
||||
}
|
||||
@@ -1,98 +1,183 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useOrg } from '../hooks/useOrg'
|
||||
import qs from 'qs'
|
||||
import { QuestionData, StrapiRecord } from 'lib/strapi'
|
||||
import useSWR from 'swr'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import { doDelete, patch, post } from '../lib/api'
|
||||
|
||||
type QuestionContextValue = {
|
||||
[key: string]: any
|
||||
type UseQuestionOptions = {
|
||||
data?: StrapiRecord<QuestionData>
|
||||
}
|
||||
|
||||
export const Context = createContext<QuestionContextValue>({})
|
||||
export const useQuestion = (id: number | string, options?: UseQuestionOptions) => {
|
||||
const isPermalink = typeof id === 'string'
|
||||
|
||||
type QuestionProviderProps = {
|
||||
children: React.ReactNode
|
||||
question: Record<string, any> // TODO: Real question type
|
||||
onResolve: (resolved: boolean, replyId: string | null) => void
|
||||
onSubmit: React.FormEventHandler
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const Provider: React.FC<QuestionProviderProps> = ({ children, question, onResolve, onSubmit, ...other }) => {
|
||||
const { organizationId, apiHost } = useOrg()
|
||||
const { user } = useUser()
|
||||
const [replies, setReplies] = useState<any[]>([])
|
||||
const [resolvedBy, setResolvedBy] = useState(question?.resolved_reply_id)
|
||||
const [resolved, setResolved] = useState<boolean>(question?.resolved)
|
||||
const [firstReply] = replies
|
||||
const questionAuthorId = firstReply?.profile?.id || null
|
||||
|
||||
const handleResolve = async (resolved: boolean, replyId: string | null = null) => {
|
||||
await post(apiHost, '/api/question/resolve', {
|
||||
messageId: question?.id,
|
||||
replyId,
|
||||
organizationId,
|
||||
resolved,
|
||||
})
|
||||
setResolved(resolved)
|
||||
setResolvedBy(replyId)
|
||||
if (onResolve) {
|
||||
onResolve(resolved, replyId)
|
||||
const query = qs.stringify(
|
||||
{
|
||||
filters: {
|
||||
...(isPermalink
|
||||
? {
|
||||
permalink: {
|
||||
$eq: id,
|
||||
},
|
||||
}
|
||||
: {
|
||||
id: {
|
||||
$eq: id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
populate: {
|
||||
resolvedBy: {
|
||||
select: ['id'],
|
||||
},
|
||||
profile: {
|
||||
select: ['id', 'firstName', 'lastName'],
|
||||
populate: {
|
||||
avatar: {
|
||||
select: ['id', 'url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: {
|
||||
publicationState: 'preview',
|
||||
sort: ['createdAt:asc'],
|
||||
populate: {
|
||||
profile: {
|
||||
fields: ['id', 'firstName', 'lastName', 'gravatarURL'],
|
||||
populate: {
|
||||
avatar: {
|
||||
fields: ['id', 'url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
encodeValuesOnly: true, // prettify URL
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleReply = async (reply: Record<string, any>) => {
|
||||
setReplies((replies) => [...replies, reply])
|
||||
}
|
||||
const {
|
||||
data: question,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<StrapiRecord<QuestionData>>(
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/questions?${query}`,
|
||||
async (url) => {
|
||||
const res = await fetch(url)
|
||||
const { data } = await res.json()
|
||||
return data?.[0]
|
||||
}
|
||||
)
|
||||
|
||||
const handleReplyDelete = async (id: string) => {
|
||||
await doDelete(apiHost, `/api/replies/${id}`, { organizationId })
|
||||
setReplies(replies.filter((reply) => id !== reply.id))
|
||||
}
|
||||
const { getJwt } = useUser()
|
||||
|
||||
const handlePublish = async (id: string, published: boolean) => {
|
||||
await patch(apiHost, `/api/replies/${id}`, {
|
||||
organizationId: organizationId,
|
||||
published,
|
||||
const reply = async (body: string) => {
|
||||
const token = await getJwt()
|
||||
|
||||
await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/replies`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
body,
|
||||
question: question?.id,
|
||||
},
|
||||
populate: {
|
||||
profile: {
|
||||
fields: ['id', 'firstName', 'lastName'],
|
||||
populate: {
|
||||
avatar: {
|
||||
fields: ['id', 'url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
const newReplies = [...replies]
|
||||
newReplies.some((reply) => {
|
||||
if (reply.id === id) {
|
||||
reply.published = published
|
||||
return true
|
||||
}
|
||||
})
|
||||
setReplies(newReplies)
|
||||
|
||||
mutate()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setReplies(other.replies.filter((reply: any) => reply.published || (!reply.published && user?.isModerator)))
|
||||
}, [other.replies, user?.id])
|
||||
const questionData: StrapiRecord<QuestionData> | undefined = question || options?.data
|
||||
|
||||
useEffect(() => {
|
||||
setResolved(question.resolved)
|
||||
}, [question.resolved])
|
||||
const handlePublishReply = async (published: boolean, id: number) => {
|
||||
const replyRes = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/replies/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
publishedAt: published ? null : new Date(),
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
Authorization: `Bearer ${await getJwt()}`,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedBy(question.resolved_reply_id)
|
||||
}, [question.resolved_reply_id])
|
||||
if (!replyRes.ok) {
|
||||
throw new Error('Failed to update reply data')
|
||||
}
|
||||
|
||||
const value = {
|
||||
replies,
|
||||
resolvedBy,
|
||||
resolved,
|
||||
questionAuthorId,
|
||||
question,
|
||||
onSubmit,
|
||||
handleReply,
|
||||
await replyRes.json()
|
||||
|
||||
mutate()
|
||||
}
|
||||
|
||||
const handleResolve = async (resolved: boolean, resolvedBy: number | null) => {
|
||||
const replyRes = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/questions/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
resolved,
|
||||
resolvedBy,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
Authorization: `Bearer ${await getJwt()}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!replyRes.ok) {
|
||||
throw new Error('Failed to update reply data')
|
||||
}
|
||||
|
||||
await replyRes.json()
|
||||
|
||||
mutate()
|
||||
}
|
||||
|
||||
const handleReplyDelete = async (id: number) => {
|
||||
const replyRes = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/replies/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getJwt()}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!replyRes.ok) {
|
||||
throw new Error('Failed to delete reply')
|
||||
}
|
||||
|
||||
await replyRes.json()
|
||||
|
||||
mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
question: questionData,
|
||||
reply,
|
||||
error,
|
||||
isLoading: isLoading && !questionData,
|
||||
isError: error,
|
||||
handlePublishReply,
|
||||
handleResolve,
|
||||
handleReplyDelete,
|
||||
handlePublish,
|
||||
}
|
||||
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export const useQuestion = () => {
|
||||
const question = useContext(Context)
|
||||
return question
|
||||
}
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import { Form } from './components/main/Form'
|
||||
import { Question } from './components/main/Question'
|
||||
import { FullQuestion } from './components/FullQuestion'
|
||||
import { Authentication } from './components/main/Authentication'
|
||||
import { Squeak } from './components/main/Squeak'
|
||||
import { Days } from './components/Days'
|
||||
import { QuestionForm } from './components/QuestionForm'
|
||||
import { Question } from './components/Question'
|
||||
import { Authentication } from './components/Authentication'
|
||||
import { SignIn } from './components/auth/SignIn'
|
||||
import { SignUp } from './components/auth/SignUp'
|
||||
import { Squeak } from './components/Squeak'
|
||||
import { Avatar } from './components/Avatar'
|
||||
import { Login } from './components/main/Login'
|
||||
import { Questions } from './components/main/Questions'
|
||||
import { useOrg, Provider as OrgProvider } from './hooks/useOrg'
|
||||
import { Questions } from './components/Questions'
|
||||
import { EditProfile } from './components/EditProfile'
|
||||
import { useQuestion } from './hooks/useQuestion'
|
||||
|
||||
export {
|
||||
Authentication,
|
||||
Squeak,
|
||||
FullQuestion,
|
||||
Questions,
|
||||
Question,
|
||||
Form,
|
||||
Login,
|
||||
Avatar,
|
||||
Days,
|
||||
OrgProvider,
|
||||
useOrg,
|
||||
useQuestion,
|
||||
}
|
||||
export { Authentication, Squeak, Questions, Question, QuestionForm, Avatar, useQuestion, SignIn, SignUp, EditProfile }
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
const FETCH_DEFAULTS: RequestInit = {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// mode: 'cors'
|
||||
}
|
||||
|
||||
function buildUrl(host: string, path: string) {
|
||||
return host + path
|
||||
}
|
||||
|
||||
export async function patch(host: string, url: string, body: Record<string, any>) {
|
||||
try {
|
||||
const res = await fetch(buildUrl(host, url), {
|
||||
...FETCH_DEFAULTS,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return processResponse(res)
|
||||
} catch (e) {
|
||||
console.error('PATCH request error', e)
|
||||
return { error: e }
|
||||
}
|
||||
}
|
||||
|
||||
export async function doDelete(host: string, url: string, body: Record<string, any>) {
|
||||
try {
|
||||
const res = await fetch(buildUrl(host, url), {
|
||||
...FETCH_DEFAULTS,
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return processResponse(res)
|
||||
} catch (e) {
|
||||
console.error('DELETE request error', e)
|
||||
return { error: e }
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(host: string, url: string, params: Record<string, string> | string | string[][]) {
|
||||
if (params) {
|
||||
url += '?' + new URLSearchParams(params).toString()
|
||||
}
|
||||
try {
|
||||
const res = await fetch(buildUrl(host, url), {
|
||||
...FETCH_DEFAULTS,
|
||||
method: 'GET',
|
||||
})
|
||||
return processResponse(res)
|
||||
} catch (e) {
|
||||
console.error('GET request error', e)
|
||||
return { error: e, data: undefined }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Submit a POST request
|
||||
* @param {string} host
|
||||
* @param {string} url
|
||||
* @param {any} body body is serialized as json
|
||||
*/
|
||||
export async function post(host: string, url: string, body?: Record<string, any>) {
|
||||
try {
|
||||
const res = await fetch(buildUrl(host, url), {
|
||||
...FETCH_DEFAULTS,
|
||||
method: 'POST',
|
||||
...(body && { body: JSON.stringify(body) }),
|
||||
})
|
||||
return processResponse(res)
|
||||
} catch (e) {
|
||||
return { error: e, data: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the response according to it's status code. Success responses are deserialized as json.
|
||||
* @param {} res Response
|
||||
*/
|
||||
async function processResponse(res: Response) {
|
||||
if (res.status >= 200 && res.status <= 201) {
|
||||
const data = await res.json()
|
||||
return { data, response: res }
|
||||
} else if (res.status === 400) {
|
||||
const data = await res.json()
|
||||
return { error: new Error(data?.error), response: res, data: undefined }
|
||||
} else if (res.status === 401) {
|
||||
return { error: new Error('Not authenticated'), response: res }
|
||||
} else if (res.status >= 500) {
|
||||
return { error: new Error('Unexpected error occurred'), response: res }
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './client'
|
||||
11
src/components/Squeak/util/getAvatar.ts
Normal file
11
src/components/Squeak/util/getAvatar.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ProfileData, StrapiRecord } from 'lib/strapi'
|
||||
|
||||
export default function getAvatarURL(profile: StrapiRecord<Pick<ProfileData, 'avatar' | 'gravatarURL'>> | undefined) {
|
||||
return (
|
||||
profile?.avatar?.url ||
|
||||
profile?.avatar?.data?.attributes?.url ||
|
||||
profile?.attributes?.avatar?.data?.attributes?.url ||
|
||||
profile?.gravatarURL ||
|
||||
profile?.attributes?.gravatarURL
|
||||
)
|
||||
}
|
||||
@@ -1,77 +1,32 @@
|
||||
import React from 'react'
|
||||
import { graphql, useStaticQuery } from 'gatsby'
|
||||
import { CardContainer, IRoadmap } from 'components/Roadmap'
|
||||
import { InProgress } from 'components/Roadmap/InProgress'
|
||||
import { OrgProvider } from 'components/Squeak'
|
||||
import Link from 'components/Link'
|
||||
import { useRoadmap } from 'hooks/useRoadmap'
|
||||
|
||||
export default function TeamRoadmap({ team }: { team?: string }) {
|
||||
const {
|
||||
allSqueakRoadmap: { nodes },
|
||||
} = useStaticQuery(query)
|
||||
const teams = useRoadmap()
|
||||
|
||||
const roadmap = team ? nodes.filter((node: IRoadmap) => node?.team?.name === team) : nodes
|
||||
return (
|
||||
<OrgProvider
|
||||
value={{
|
||||
organizationId: process.env.GATSBY_SQUEAK_ORG_ID as string,
|
||||
apiHost: process.env.GATSBY_SQUEAK_API_HOST as string,
|
||||
}}
|
||||
>
|
||||
{roadmap?.length <= 0 ? (
|
||||
<p className="!m-0 py-4 px-6 border border-dashed border-gray-accent-light dark:border-gray-accent-dark rounded-md">
|
||||
Check out the <Link to="/roadmap">company roadmap</Link> to see what we're working on next!
|
||||
</p>
|
||||
) : (
|
||||
<CardContainer>
|
||||
{roadmap?.map((node: IRoadmap) => {
|
||||
return (
|
||||
<InProgress
|
||||
more
|
||||
className="bg-opacity-0 shadow-none border border-dashed border-gray-accent-light dark:border-gray-accent-dark rounded-md !border-t !mb-4"
|
||||
key={node.title}
|
||||
{...node}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</CardContainer>
|
||||
)}
|
||||
</OrgProvider>
|
||||
const roadmaps = teams.find((t) => t.name === team)?.roadmaps || []
|
||||
|
||||
const futureRoadmaps = roadmaps.filter((r) => r.projectedCompletion && !r.complete)
|
||||
|
||||
return futureRoadmaps.length <= 0 ? (
|
||||
<p className="!m-0 py-4 px-6 border border-dashed border-gray-accent-light dark:border-gray-accent-dark rounded-md">
|
||||
Check out the <Link to="/roadmap">company roadmap</Link> to see what we're working on next!
|
||||
</p>
|
||||
) : (
|
||||
<CardContainer>
|
||||
{futureRoadmaps?.map((node: IRoadmap) => {
|
||||
return (
|
||||
<InProgress
|
||||
more
|
||||
className="bg-opacity-0 shadow-none border border-dashed border-gray-accent-light dark:border-gray-accent-dark rounded-md !border-t !mb-4"
|
||||
key={node.title}
|
||||
{...node}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</CardContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const query = graphql`
|
||||
{
|
||||
allSqueakRoadmap(filter: { complete: { ne: true }, projected_completion_date: { ne: null } }) {
|
||||
nodes {
|
||||
beta_available
|
||||
complete
|
||||
date_completed
|
||||
title
|
||||
description
|
||||
team {
|
||||
name
|
||||
}
|
||||
thumbnail {
|
||||
childImageSharp {
|
||||
gatsbyImageData(width: 200, placeholder: NONE, quality: 100)
|
||||
}
|
||||
}
|
||||
otherLinks
|
||||
githubPages {
|
||||
title
|
||||
html_url
|
||||
number
|
||||
closed_at
|
||||
reactions {
|
||||
hooray
|
||||
heart
|
||||
eyes
|
||||
_1
|
||||
}
|
||||
}
|
||||
projected_completion_date
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
119
src/hooks/useQuestions.tsx
Normal file
119
src/hooks/useQuestions.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import qs from 'qs'
|
||||
import { QuestionData, StrapiResult, StrapiRecord } from 'lib/strapi'
|
||||
|
||||
type UseQuestionsOptions = {
|
||||
slug?: string
|
||||
profileId?: number
|
||||
topicId?: number
|
||||
limit?: number
|
||||
sortBy?: 'newest' | 'popular' | 'activity'
|
||||
}
|
||||
|
||||
export const useQuestions = (options?: UseQuestionsOptions) => {
|
||||
const { slug, topicId, profileId, limit = 20, sortBy = 'newest' } = options || {}
|
||||
|
||||
const query = (offset: number) => {
|
||||
const params = {
|
||||
pagination: {
|
||||
start: offset * limit,
|
||||
limit,
|
||||
},
|
||||
sort: 'createdAt:desc',
|
||||
filters: {},
|
||||
populate: {
|
||||
profile: {
|
||||
fields: ['firstName', 'lastName', 'gravatarURL'],
|
||||
populate: {
|
||||
avatar: {
|
||||
fields: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: {
|
||||
fields: ['id', 'createdAt', 'updatedAt'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
params.sort = 'createdAt:desc'
|
||||
break
|
||||
case 'popular':
|
||||
params.sort = 'numReplies:desc'
|
||||
break
|
||||
case 'activity':
|
||||
params.sort = 'updatedAt:desc'
|
||||
break
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
params.filters = {
|
||||
...params.filters,
|
||||
slugs: {
|
||||
slug,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (topicId) {
|
||||
params.filters = {
|
||||
...params.filters,
|
||||
topics: {
|
||||
id: {
|
||||
$eq: topicId,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (profileId) {
|
||||
params.filters = {
|
||||
...params.filters,
|
||||
$or: [
|
||||
{
|
||||
profile: {
|
||||
id: {
|
||||
$eq: profileId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
replies: {
|
||||
profile: {
|
||||
id: {
|
||||
$eq: profileId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return qs.stringify(params, {
|
||||
encodeValuesOnly: true, // prettify URL
|
||||
})
|
||||
}
|
||||
|
||||
const { data, size, setSize, isLoading, mutate } = useSWRInfinite<StrapiResult<QuestionData[]>>(
|
||||
(offset) => `${process.env.GATSBY_SQUEAK_API_HOST}/api/questions?${query(offset)}`,
|
||||
(url: string) => fetch(url).then((r) => r.json())
|
||||
)
|
||||
|
||||
const questions: Omit<StrapiResult<QuestionData[]>, 'meta'> = React.useMemo(() => {
|
||||
return {
|
||||
data: data?.reduce((acc, cur) => [...acc, ...cur.data], [] as StrapiRecord<QuestionData>[]) ?? [],
|
||||
}
|
||||
}, [size, data])
|
||||
|
||||
return {
|
||||
questions,
|
||||
fetchMore: () => setSize(size + 1),
|
||||
isLoading,
|
||||
refresh: () => mutate(),
|
||||
}
|
||||
}
|
||||
83
src/hooks/useRoadmap.tsx
Normal file
83
src/hooks/useRoadmap.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { graphql, useStaticQuery } from 'gatsby'
|
||||
|
||||
interface GitHubPage {
|
||||
title: string
|
||||
html_url: string
|
||||
number: string
|
||||
closed_at: string
|
||||
reactions: {
|
||||
hooray: number
|
||||
heart: number
|
||||
eyes: number
|
||||
plus1: number
|
||||
}
|
||||
}
|
||||
|
||||
interface Team {
|
||||
name: string
|
||||
roadmaps: Roadmap[]
|
||||
}
|
||||
|
||||
export interface Roadmap {
|
||||
squeakId: number
|
||||
title: string
|
||||
description: string
|
||||
betaAvailable: boolean
|
||||
complete: boolean
|
||||
dateCompleted: string
|
||||
image?: {
|
||||
url: string
|
||||
}
|
||||
projectedCompletion: string
|
||||
githubPages: GitHubPage[]
|
||||
}
|
||||
|
||||
type RoadmapData = {
|
||||
allSqueakTeam: {
|
||||
nodes: Team[]
|
||||
}
|
||||
}
|
||||
|
||||
export const useRoadmap = (): Team[] => {
|
||||
const data = useStaticQuery<RoadmapData>(query)
|
||||
|
||||
return data.allSqueakTeam.nodes
|
||||
}
|
||||
|
||||
const query = graphql`
|
||||
query RoadmapQuery {
|
||||
allSqueakTeam {
|
||||
nodes {
|
||||
name
|
||||
roadmaps {
|
||||
...roadmap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment roadmap on SqueakRoadmap {
|
||||
squeakId
|
||||
betaAvailable
|
||||
complete
|
||||
dateCompleted
|
||||
title
|
||||
description
|
||||
image {
|
||||
url
|
||||
}
|
||||
githubPages {
|
||||
title
|
||||
html_url
|
||||
number
|
||||
closed_at
|
||||
reactions {
|
||||
hooray
|
||||
heart
|
||||
eyes
|
||||
plus1
|
||||
}
|
||||
}
|
||||
projectedCompletion
|
||||
}
|
||||
`
|
||||
@@ -1,44 +1,46 @@
|
||||
import { useContext } from 'react'
|
||||
import React, { createContext, useEffect, useState } from 'react'
|
||||
import getGravatar from 'gravatar'
|
||||
import { post } from 'components/Squeak/lib/api'
|
||||
import qs from 'qs'
|
||||
import { ProfileData, StrapiRecord, StrapiResult } from 'lib/strapi'
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
export type User = {
|
||||
id: number
|
||||
email: string
|
||||
isMember: boolean
|
||||
isModerator: boolean
|
||||
profile: {
|
||||
avatar: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
blocked: boolean
|
||||
confirmed: boolean
|
||||
createdAt: string
|
||||
provider: 'local' | 'github' | 'google'
|
||||
username: string
|
||||
profile: StrapiRecord<ProfileData>
|
||||
}
|
||||
|
||||
type UserContextValue = {
|
||||
organizationId: string
|
||||
apiHost: string
|
||||
isLoading: boolean
|
||||
|
||||
user: User | null
|
||||
setUser: React.Dispatch<React.SetStateAction<User | null>>
|
||||
|
||||
getSession: () => Promise<User | null>
|
||||
login: (args: { email: string; password: string }) => Promise<User | null>
|
||||
fetchUser: (token?: string | null) => Promise<User | null>
|
||||
getJwt: () => Promise<string | null>
|
||||
login: (args: { email: string; password: string }) => Promise<User | null | { error: string }>
|
||||
logout: () => Promise<void>
|
||||
signUp: (args: { email: string; password: string; firstName: string; lastName: string }) => Promise<User | null>
|
||||
signUp: (args: {
|
||||
email: string
|
||||
password: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
}) => Promise<User | null | { error: string }>
|
||||
}
|
||||
|
||||
export const UserContext = createContext<UserContextValue>({
|
||||
organizationId: '',
|
||||
apiHost: '',
|
||||
isLoading: true,
|
||||
user: null,
|
||||
setUser: () => {
|
||||
// noop
|
||||
},
|
||||
|
||||
getSession: async () => null,
|
||||
fetchUser: async () => null,
|
||||
getJwt: async () => null,
|
||||
login: async () => null,
|
||||
logout: async () => {
|
||||
// noop
|
||||
@@ -47,47 +49,64 @@ export const UserContext = createContext<UserContextValue>({
|
||||
})
|
||||
|
||||
type UserProviderProps = {
|
||||
organizationId: string
|
||||
apiHost: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const UserProvider: React.FC<UserProviderProps> = ({ apiHost, organizationId, children }) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [jwt, setJwt] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getSession()
|
||||
const jwt = localStorage.getItem('jwt')
|
||||
const user = localStorage.getItem('user')
|
||||
|
||||
if (jwt && user) {
|
||||
setJwt(jwt)
|
||||
setUser(JSON.parse(user))
|
||||
} else {
|
||||
// We shouldn't have a jwt without a user or vice versa. If we do, clear both and reset.
|
||||
logout()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getSession = async (): Promise<User | null> => {
|
||||
const getJwt = async () => {
|
||||
return jwt || localStorage.getItem('jwt')
|
||||
}
|
||||
|
||||
const login = async ({
|
||||
email,
|
||||
password,
|
||||
}: {
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<User | null | { error: string }> => {
|
||||
setIsLoading(true)
|
||||
|
||||
if (user) {
|
||||
return user
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiHost}/api/user?organizationId=${organizationId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
const userRes = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/auth/local`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
identifier: email,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
return null
|
||||
const userData = await userRes.json()
|
||||
|
||||
if (!userRes.ok) {
|
||||
return { error: userData?.error?.message }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const user = await fetchUser(userData.jwt)
|
||||
|
||||
if (data.error) {
|
||||
return null
|
||||
} else {
|
||||
setUser(data)
|
||||
return data as User
|
||||
}
|
||||
localStorage.setItem('jwt', userData.jwt)
|
||||
setJwt(userData.jwt)
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
@@ -96,31 +115,12 @@ export const UserProvider: React.FC<UserProviderProps> = ({ apiHost, organizatio
|
||||
}
|
||||
}
|
||||
|
||||
const login = async ({ email, password }: { email: string; password: string }): Promise<User | null> => {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data, error } =
|
||||
(await post(apiHost, '/api/login', {
|
||||
email,
|
||||
password,
|
||||
organizationId,
|
||||
})) || {}
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false)
|
||||
|
||||
// TODO: Should probably throw here
|
||||
return null
|
||||
} else {
|
||||
setUser(data)
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
await post(apiHost, '/api/logout')
|
||||
localStorage.removeItem('jwt')
|
||||
localStorage.removeItem('user')
|
||||
|
||||
setUser(null)
|
||||
setJwt(null)
|
||||
}
|
||||
|
||||
const signUp = async ({
|
||||
@@ -133,37 +133,84 @@ export const UserProvider: React.FC<UserProviderProps> = ({ apiHost, organizatio
|
||||
password: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
}): Promise<User | null> => {
|
||||
const gravatar = getGravatar.url(email)
|
||||
const avatar = await fetch(`https:${gravatar}?d=404`).then((res) => (res.ok && `https:${gravatar}`) || '')
|
||||
}): Promise<User | null | { error: string }> => {
|
||||
try {
|
||||
const res = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/auth/local/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: email,
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
}),
|
||||
})
|
||||
|
||||
// FIXME: This doesn't seem to return the right format
|
||||
const { error, data } =
|
||||
(await post(apiHost, '/api/register', {
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
avatar,
|
||||
organizationId,
|
||||
})) || {}
|
||||
const userData = await res.json()
|
||||
|
||||
if (error) {
|
||||
// setMessage(error.message)
|
||||
// TODO: Should probably throw here
|
||||
return null
|
||||
} else {
|
||||
// setUser(data)
|
||||
let user = await getSession()
|
||||
if (!res.ok) {
|
||||
return { error: userData?.error?.message }
|
||||
}
|
||||
|
||||
const user = await fetchUser(userData.jwt)
|
||||
|
||||
localStorage.setItem('jwt', userData.jwt)
|
||||
setJwt(userData.jwt)
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUser = async (token?: string | null): Promise<User | null> => {
|
||||
const meQuery = qs.stringify(
|
||||
{
|
||||
populate: {
|
||||
profile: {
|
||||
populate: ['avatar'],
|
||||
},
|
||||
role: {
|
||||
select: ['type'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
encodeValuesOnly: true,
|
||||
}
|
||||
)
|
||||
|
||||
if (!token) {
|
||||
token = await getJwt()
|
||||
}
|
||||
|
||||
const meRes = await fetch(`${process.env.GATSBY_SQUEAK_API_HOST}/api/users/me?${meQuery}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!meRes.ok) {
|
||||
throw new Error('Failed to fetch profile data')
|
||||
}
|
||||
|
||||
const meData: User = await meRes.json()
|
||||
|
||||
setUser(meData)
|
||||
|
||||
return meData
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<UserContext.Provider
|
||||
value={{ organizationId, apiHost, user, setUser, isLoading, getSession, login, logout, signUp }}
|
||||
>
|
||||
<UserContext.Provider value={{ user, setUser, isLoading, getJwt, login, logout, signUp, fetchUser }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
)
|
||||
|
||||
76
src/lib/strapi.ts
Normal file
76
src/lib/strapi.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Strapi helper types
|
||||
export type StrapiResult<T> = StrapiData<T> & StrapiMeta
|
||||
|
||||
export type StrapiMeta = {
|
||||
meta: {
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
pageCount: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maybe rename this StrapiRelationData?
|
||||
export type StrapiData<T> = {
|
||||
data: T extends Array<any> ? StrapiRecord<T[number]>[] : StrapiRecord<T>
|
||||
}
|
||||
|
||||
export type StrapiRecord<T> = {
|
||||
id: number
|
||||
attributes: T
|
||||
}
|
||||
|
||||
export type QuestionData = {
|
||||
subject: string
|
||||
permalink: string
|
||||
resolved: boolean
|
||||
body: string
|
||||
page: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
publishedAt: string
|
||||
profile?: StrapiData<ProfileData>
|
||||
replies?: StrapiData<ReplyData[]>
|
||||
topics?: StrapiData<TopicData[]>
|
||||
}
|
||||
|
||||
export type AvatarData = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ProfileData = {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
biography: string | null
|
||||
company: string | null
|
||||
companyRole: string | null
|
||||
github: string | null
|
||||
linkedin: string | null
|
||||
location: string | null
|
||||
twitter: string | null
|
||||
website: string | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
publishedAt: string | null
|
||||
avatar?: StrapiData<AvatarData>
|
||||
gravatarURL: string | null
|
||||
}
|
||||
|
||||
export type ProfileQuestionsData = {
|
||||
questions: StrapiData<QuestionData[]>
|
||||
}
|
||||
|
||||
export type ReplyData = {
|
||||
body: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
publishedAt: string
|
||||
profile?: StrapiData<Pick<ProfileData, 'firstName' | 'lastName' | 'avatar' | 'gravatarURL'>>
|
||||
}
|
||||
|
||||
export type TopicData = {
|
||||
label: string
|
||||
slug: string
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO GENERATED FILE
|
||||
// AUTO GENERATED FILE
|
||||
|
||||
import { ArrayCTA } from './components/ArrayCTA'
|
||||
import { BasicHedgehogImage } from './components/BasicHedgehogImage'
|
||||
@@ -20,22 +20,22 @@ import { StarRepoButton } from './components/StarRepoButton'
|
||||
import { TracksCTA } from './components/TracksCTA'
|
||||
|
||||
export const shortcodes = {
|
||||
ArrayCTA,
|
||||
BasicHedgehogImage,
|
||||
BorderWrapper,
|
||||
CallToAction,
|
||||
Caption,
|
||||
CompensationCalculator,
|
||||
FeatureAvailability,
|
||||
GDPRForm,
|
||||
HiddenSection,
|
||||
LPCTA,
|
||||
NewsletterTutorial,
|
||||
OverflowXSection,
|
||||
Quote,
|
||||
ProductLayout,
|
||||
Quote2,
|
||||
Squeak,
|
||||
StarRepoButton,
|
||||
TracksCTA,
|
||||
}
|
||||
ArrayCTA,
|
||||
BasicHedgehogImage,
|
||||
BorderWrapper,
|
||||
CallToAction,
|
||||
Caption,
|
||||
CompensationCalculator,
|
||||
FeatureAvailability,
|
||||
GDPRForm,
|
||||
HiddenSection,
|
||||
LPCTA,
|
||||
NewsletterTutorial,
|
||||
OverflowXSection,
|
||||
Quote,
|
||||
ProductLayout,
|
||||
Quote2,
|
||||
Squeak,
|
||||
StarRepoButton,
|
||||
TracksCTA
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { graphql, PageProps } from 'gatsby'
|
||||
import community from 'sidebars/community.json'
|
||||
import SEO from 'components/seo'
|
||||
import Layout from 'components/Layout'
|
||||
import PostLayout from 'components/PostLayout'
|
||||
import Link from 'components/Link'
|
||||
import { OrgProvider, Login as SqueakLogin } from 'components/Squeak'
|
||||
import { Authentication, EditProfile } from 'components/Squeak'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import Modal from 'components/Modal'
|
||||
import EditProfile from 'components/Profiles/EditProfile'
|
||||
import { SqueakProfile } from './profiles/[id]'
|
||||
import { CallToAction } from 'components/CallToAction'
|
||||
import Spinner from 'components/Spinner'
|
||||
import { useStaticQuery } from 'gatsby'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import GitHubTooltip, { Author } from 'components/GitHubTooltip'
|
||||
import QuestionsTable from 'components/Questions/QuestionsTable'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import SidebarSection from 'components/PostLayout/SidebarSection'
|
||||
import { ProfileData, StrapiRecord } from 'lib/strapi'
|
||||
import { useQuestions } from 'hooks/useQuestions'
|
||||
import getAvatarURL from 'components/Squeak/util/getAvatar'
|
||||
import { User } from '../../hooks/useUser'
|
||||
|
||||
export const Avatar = (props: { className?: string; src?: string }) => {
|
||||
return (
|
||||
@@ -41,58 +42,64 @@ export const Avatar = (props: { className?: string; src?: string }) => {
|
||||
}
|
||||
|
||||
export const Login = ({ onSubmit = () => undefined }: { onSubmit?: () => void }) => {
|
||||
const [login, setLogin] = useState<null | { type: 'login' | 'signup' }>(null)
|
||||
return login ? (
|
||||
const [state, setState] = useState<null | 'login' | 'signup'>(null)
|
||||
|
||||
return state === 'login' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm font-bold dark:text-white">
|
||||
Note: PostHog.com authentication is separate from your PostHog app.
|
||||
</p>
|
||||
<p className="text-sm mt-2 dark:text-white">
|
||||
<p className="text-sm my-2 dark:text-white">
|
||||
We suggest signing up with your personal email. Soon you'll be able to link your PostHog app account.
|
||||
</p>
|
||||
<SqueakLogin onSubmit={onSubmit} />
|
||||
<Authentication showBanner={false} showProfile={false} />
|
||||
</>
|
||||
) : state === 'signup' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm font-bold dark:text-white">
|
||||
Note: PostHog.com authentication is separate from your PostHog app.
|
||||
</p>
|
||||
<p className="text-sm my-2 dark:text-white">
|
||||
We suggest signing up with your personal email. Soon you'll be able to link your PostHog app account.
|
||||
</p>
|
||||
<Authentication initialView="sign-up" showBanner={false} showProfile={false} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="m-0 text-sm dark:text-white">
|
||||
Your PostHog.com community profile lets you ask questions and get early access to beta features.
|
||||
</p>
|
||||
<p className="text-[13px] mt-2 dark:text-white p-2 bg-gray-accent-light dark:bg-gray-accent-dark rounded">
|
||||
<p className="text-[13px] my-2 dark:text-white p-2 bg-gray-accent-light dark:bg-gray-accent-dark rounded">
|
||||
<strong>Tip:</strong> If you've ever asked a question on PostHog.com, you already have an account!
|
||||
</p>
|
||||
<CallToAction onClick={() => setLogin({ type: 'login' })} width="full" size="sm">
|
||||
<CallToAction onClick={() => setState('login')} width="full" size="sm">
|
||||
Login to posthog.com
|
||||
</CallToAction>
|
||||
<CallToAction
|
||||
onClick={() => setLogin({ type: 'signup' })}
|
||||
width="full"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
>
|
||||
<CallToAction onClick={() => setState('signup')} width="full" type="secondary" size="sm" className="mt-2">
|
||||
Create an account
|
||||
</CallToAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Profile = ({
|
||||
profile,
|
||||
setEditModalOpen,
|
||||
}: {
|
||||
profile: SqueakProfile
|
||||
setEditModalOpen: (open: boolean) => void
|
||||
}) => {
|
||||
const { avatar, id } = profile
|
||||
const name = [profile?.first_name, profile?.last_name].filter(Boolean).join(' ')
|
||||
export const Profile = ({ user, setEditModalOpen }: { user: User; setEditModalOpen: (open: boolean) => void }) => {
|
||||
const { profile, email } = user
|
||||
const { id } = profile
|
||||
const name = [profile.firstName, profile.lastName].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/community/profiles/${id}`}
|
||||
className="flex items-center space-x-2 mt-2 mb-1 -mx-2 relative active:top-[1px] active:scale-[.99] hover:bg-gray-accent-light dark:hover:bg-gray-accent-dark rounded p-2"
|
||||
>
|
||||
<Avatar src={avatar} className="w-[40px] h-[40px]" />
|
||||
<div>{name && <p className="m-0 font-bold">{name}</p>}</div>
|
||||
<Avatar src={getAvatarURL(user?.profile)} className="w-[40px] h-[40px]" />
|
||||
<div>
|
||||
{name && <p className="m-0 font-bold">{name}</p>}
|
||||
{email && (
|
||||
<p className="m-0 font-normal text-sm text-primary/60 dark:text-primary-dark/60">{email}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<CallToAction
|
||||
@@ -122,6 +129,7 @@ const ListItem = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
const Activity = ({ questions, questionsLoading }) => {
|
||||
const { user } = useUser()
|
||||
|
||||
return (
|
||||
<div id="my-activity" className="mb-12">
|
||||
<SectionTitle>My activity</SectionTitle>
|
||||
@@ -225,26 +233,14 @@ const ActiveIssues = ({ issues }) => {
|
||||
}
|
||||
|
||||
const RecentQuestions = () => {
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'activity' | 'popular'>('newest')
|
||||
// const [sortBy, setSortBy] = useState<'newest' | 'activity' | 'popular'>('newest')
|
||||
|
||||
const { data, size, setSize, isLoading, mutate } = useSWRInfinite<any[]>(
|
||||
(offset) =>
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/v1/questions?organizationId=${
|
||||
process.env.GATSBY_SQUEAK_ORG_ID
|
||||
}&start=${offset * 5}&perPage=5&published=true&sortBy=${sortBy}`,
|
||||
(url: string) =>
|
||||
fetch(url)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.questions)
|
||||
)
|
||||
const { questions, fetchMore, isLoading } = useQuestions({ limit: 3 })
|
||||
|
||||
const questions = React.useMemo(() => {
|
||||
return data?.flat() || []
|
||||
}, [size, data])
|
||||
return (
|
||||
<div id="recent-questions" className="mb-12">
|
||||
<SectionTitle>Recent questions</SectionTitle>
|
||||
<QuestionsTable hideLoadMore questions={questions} size={size} setSize={setSize} isLoading={isLoading} />
|
||||
<QuestionsTable hideLoadMore questions={questions} fetchMore={fetchMore} isLoading={isLoading} />
|
||||
<CallToAction className="mt-4" type="secondary" width="full" to="/questions">
|
||||
Browse all questions
|
||||
</CallToAction>
|
||||
@@ -274,98 +270,34 @@ const ActivePulls = ({ pulls }) => {
|
||||
}
|
||||
|
||||
export default function CommunityPage({ params }: PageProps) {
|
||||
const [profile, setProfile] = useState<SqueakProfile | undefined>(undefined)
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [questions, setQuestions] = useState([])
|
||||
const [questionsLoading, setQuestionsLoading] = useState(true)
|
||||
const { user } = useUser()
|
||||
const { questions, isLoading } = useQuestions({ profileId: user?.profile?.id })
|
||||
|
||||
const { issues, pulls, postHogStats, postHogComStats } = useStaticQuery(query)
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setQuestionsLoading(true)
|
||||
fetch(`https://squeak.cloud/api/questions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
organizationId: 'a898bcf2-c5b9-4039-82a0-a00220a8c626',
|
||||
profileId: profile?.id,
|
||||
published: true,
|
||||
perPage: 5,
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 404) {
|
||||
throw new Error('not found')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
})
|
||||
.then((questions) => {
|
||||
setQuestions(questions?.questions)
|
||||
setQuestionsLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
setQuestionsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
const handleEditProfile = (updatedProfile: SqueakProfile) => {
|
||||
setProfile({ ...profile, ...updatedProfile })
|
||||
setEditModalOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEO title={`Community - PostHog`} />
|
||||
<OrgProvider
|
||||
value={{ organizationId: 'a898bcf2-c5b9-4039-82a0-a00220a8c626', apiHost: 'https://squeak.cloud' }}
|
||||
>
|
||||
<Layout>
|
||||
<Modal setOpen={setEditModalOpen} open={editModalOpen}>
|
||||
<div
|
||||
onClick={() => setEditModalOpen(false)}
|
||||
className="flex flex-start justify-center absolute w-full p-4"
|
||||
>
|
||||
<div
|
||||
className="max-w-xl bg-white dark:bg-black rounded-md relative w-full p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EditProfile onSubmit={handleEditProfile} profile={profile} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<PostLayout
|
||||
menuWidth={{ right: 320 }}
|
||||
title="Profile"
|
||||
menu={community}
|
||||
sidebar={
|
||||
<ProfileSidebar
|
||||
setProfile={setProfile}
|
||||
setEditModalOpen={setEditModalOpen}
|
||||
profile={profile}
|
||||
postHogStats={postHogStats}
|
||||
postHogComStats={postHogComStats}
|
||||
/>
|
||||
}
|
||||
tableOfContents={[
|
||||
...(profile ? [{ url: 'my-activity', value: 'My activity', depth: 0 }] : []),
|
||||
{ url: 'recent-questions', value: 'Recent questions', depth: 0 },
|
||||
{ url: 'active-issues', value: 'Most active issues', depth: 0 },
|
||||
{ url: 'active-pulls', value: 'Most active PRs', depth: 0 },
|
||||
]}
|
||||
hideSurvey
|
||||
>
|
||||
{profile && <Activity questionsLoading={questionsLoading} questions={questions} />}
|
||||
<RecentQuestions />
|
||||
<ActiveIssues issues={issues.nodes} />
|
||||
<ActivePulls pulls={pulls.nodes} />
|
||||
</PostLayout>
|
||||
</Layout>
|
||||
</OrgProvider>
|
||||
<Layout>
|
||||
<PostLayout
|
||||
menuWidth={{ right: 320 }}
|
||||
title="Profile"
|
||||
menu={community}
|
||||
sidebar={<ProfileSidebar postHogStats={postHogStats} postHogComStats={postHogComStats} />}
|
||||
tableOfContents={[
|
||||
...(user ? [{ url: 'my-activity', value: 'My activity', depth: 0 }] : []),
|
||||
{ url: 'recent-questions', value: 'Recent questions', depth: 0 },
|
||||
{ url: 'active-issues', value: 'Most active issues', depth: 0 },
|
||||
{ url: 'active-pulls', value: 'Most active PRs', depth: 0 },
|
||||
]}
|
||||
hideSurvey
|
||||
>
|
||||
{user && <Activity questionsLoading={isLoading} questions={questions} />}
|
||||
<RecentQuestions />
|
||||
<ActiveIssues issues={issues.nodes} />
|
||||
<ActivePulls pulls={pulls.nodes} />
|
||||
</PostLayout>
|
||||
</Layout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -414,47 +346,41 @@ const Stats = ({
|
||||
}
|
||||
|
||||
const ProfileSidebar = ({
|
||||
profile,
|
||||
setProfile,
|
||||
setEditModalOpen,
|
||||
postHogStats,
|
||||
postHogComStats,
|
||||
}: {
|
||||
profile?: SqueakProfile
|
||||
setProfile: (profile: SqueakProfile) => void
|
||||
setEditModalOpen: (open: boolean) => void
|
||||
postHogStats: IGitHubStats
|
||||
postHogComStats: IGitHubStats
|
||||
}) => {
|
||||
const { user, setUser } = useUser()
|
||||
useEffect(() => {
|
||||
setProfile(user?.profile)
|
||||
}, [user])
|
||||
const { user, logout } = useUser()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await fetch('https://squeak.cloud/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
setUser(null)
|
||||
}
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal setOpen={setEditModalOpen} open={editModalOpen}>
|
||||
<div
|
||||
onClick={() => setEditModalOpen(false)}
|
||||
className="flex flex-start justify-center absolute w-full p-4"
|
||||
>
|
||||
<div
|
||||
className="max-w-xl bg-white dark:bg-black rounded-md relative w-full p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EditProfile onSubmit={() => setEditModalOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<SidebarSection>
|
||||
<div className="mb-2 flex items-baseline justify-between">
|
||||
<h4 className="m-0">My profile</h4>
|
||||
{profile && (
|
||||
<button onClick={handleSignOut} className="text-red font-bold text-sm">
|
||||
{user?.profile && (
|
||||
<button onClick={logout} className="text-red font-bold text-sm">
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{profile ? <Profile setEditModalOpen={setEditModalOpen} profile={profile} /> : <Login />}
|
||||
{user?.profile ? <Profile setEditModalOpen={setEditModalOpen} user={user} /> : <Login />}
|
||||
</SidebarSection>
|
||||
<SidebarSection title="Stats for our popular repos">
|
||||
<Stats
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { PageProps } from 'gatsby'
|
||||
|
||||
import community from 'sidebars/community.json'
|
||||
@@ -8,40 +8,17 @@ import PostLayout from 'components/PostLayout'
|
||||
import { GitHub, LinkedIn, Twitter } from 'components/Icons'
|
||||
import Link from 'components/Link'
|
||||
import Markdown from 'markdown-to-jsx'
|
||||
import { OrgProvider, Question } from 'components/Squeak'
|
||||
import { Questions } from 'components/Squeak'
|
||||
import { useUser } from 'hooks/useUser'
|
||||
import Modal from 'components/Modal'
|
||||
import EditProfile from 'components/Profiles/EditProfile'
|
||||
import { EditProfile } from 'components/Squeak'
|
||||
import useSWR from 'swr'
|
||||
import SidebarSection from 'components/PostLayout/SidebarSection'
|
||||
|
||||
export type SqueakProfile = {
|
||||
id: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
avatar?: string
|
||||
|
||||
biography?: string
|
||||
|
||||
website?: string
|
||||
github?: string
|
||||
twitter?: string
|
||||
linkedin?: string
|
||||
|
||||
company?: string
|
||||
company_role?: string
|
||||
|
||||
team?: {
|
||||
name: string
|
||||
profiles: {
|
||||
id: string
|
||||
avatar?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
company_role?: string
|
||||
}[]
|
||||
}
|
||||
}
|
||||
import { ProfileData, ProfileQuestionsData, StrapiData, StrapiRecord } from 'lib/strapi'
|
||||
import getAvatarURL from '../../../components/Squeak/util/getAvatar'
|
||||
import { useBreakpoint } from 'gatsby-plugin-breakpoints'
|
||||
import { RightArrow } from '../../../components/Icons/Icons'
|
||||
import qs from 'qs'
|
||||
|
||||
const Avatar = (props: { className?: string; src?: string }) => {
|
||||
return (
|
||||
@@ -65,73 +42,95 @@ const Avatar = (props: { className?: string; src?: string }) => {
|
||||
}
|
||||
|
||||
export default function ProfilePage({ params }: PageProps) {
|
||||
const id = params.id || params['*']
|
||||
const id = parseInt(params.id || params['*'])
|
||||
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false)
|
||||
|
||||
const { data: profile, mutate: profileMutate } = useSWR<SqueakProfile>(
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/profiles/${id}?organizationId=${process.env.GATSBY_SQUEAK_ORG_ID}`,
|
||||
(url) => fetch(url).then((res) => res.json())
|
||||
const profileQuery = qs.stringify(
|
||||
{
|
||||
populate: {
|
||||
avatar: true,
|
||||
role: {
|
||||
select: ['type'],
|
||||
},
|
||||
teams: {
|
||||
populate: {
|
||||
profiles: {
|
||||
populate: ['avatar'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
encodeValuesOnly: true,
|
||||
}
|
||||
)
|
||||
const { data: questions } = useSWR(
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/v1/questions?organizationId=${process.env.GATSBY_SQUEAK_ORG_ID}&profileId=${id}&published=true`,
|
||||
(url: string) =>
|
||||
fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.questions || [])
|
||||
const { data, mutate } = useSWR<StrapiRecord<ProfileData>>(
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/profiles/${id}?${profileQuery}`,
|
||||
async (url) => {
|
||||
const res = await fetch(url)
|
||||
const { data } = await res.json()
|
||||
return data
|
||||
}
|
||||
)
|
||||
|
||||
const name = [profile?.first_name, profile?.last_name].filter(Boolean).join(' ')
|
||||
const { attributes: profile } = data || {}
|
||||
const { firstName, lastName } = profile || {}
|
||||
|
||||
const handleEditProfile = (updatedProfile: SqueakProfile) => {
|
||||
profileMutate({ ...profile, ...updatedProfile }, false)
|
||||
const name = [firstName, lastName].filter(Boolean).join(' ')
|
||||
|
||||
const handleEditProfile = () => {
|
||||
mutate()
|
||||
setEditModalOpen(false)
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEO title={`Community Profile - PostHog`} />
|
||||
<OrgProvider
|
||||
value={{
|
||||
organizationId: process.env.GATSBY_SQUEAK_ORG_ID as string,
|
||||
apiHost: process.env.GATSBY_SQUEAK_API_HOST as string,
|
||||
}}
|
||||
>
|
||||
<Layout>
|
||||
<Modal setOpen={setEditModalOpen} open={editModalOpen}>
|
||||
<div
|
||||
onClick={() => setEditModalOpen(false)}
|
||||
className="flex flex-start justify-center absolute w-full p-4"
|
||||
>
|
||||
<div
|
||||
className="max-w-xl bg-white dark:bg-black rounded-md relative w-full p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EditProfile onSubmit={handleEditProfile} profile={profile} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<PostLayout
|
||||
title="Profile"
|
||||
breadcrumb={[
|
||||
{ name: 'Community', url: '/questions' },
|
||||
{ name: 'Profile', url: `/community/profiles/${id}` },
|
||||
]}
|
||||
menu={community}
|
||||
sidebar={<ProfileSidebar setEditModalOpen={setEditModalOpen} profile={profile} />}
|
||||
hideSurvey
|
||||
<Layout>
|
||||
<Modal setOpen={setEditModalOpen} open={editModalOpen}>
|
||||
<div
|
||||
onClick={() => setEditModalOpen(false)}
|
||||
className="flex flex-start justify-center absolute w-full p-4"
|
||||
>
|
||||
{profile ? (
|
||||
<div
|
||||
className="max-w-xl bg-white dark:bg-black rounded-md relative w-full p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EditProfile onSubmit={handleEditProfile} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<PostLayout
|
||||
title="Profile"
|
||||
breadcrumb={[{ name: 'Community', url: '/questions' }]}
|
||||
menu={community}
|
||||
sidebar={
|
||||
<ProfileSidebar
|
||||
handleEditProfile={handleEditProfile}
|
||||
setEditModalOpen={setEditModalOpen}
|
||||
profile={{ ...profile, id }}
|
||||
/>
|
||||
}
|
||||
hideSurvey
|
||||
>
|
||||
{profile ? (
|
||||
<>
|
||||
<div className="space-y-8 my-8">
|
||||
<section className="">
|
||||
<Avatar
|
||||
className="w-24 h-24 float-right bg-gray-accent dark:gray-accent-dark"
|
||||
src={profile.avatar}
|
||||
src={getAvatarURL(profile)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="m-0 mb-8">{name || 'Anonymous'}</h1>
|
||||
{profile.company_role && <p className="text-gray">{profile?.company_role}</p>}
|
||||
{profile.companyRole && <p className="text-gray">{profile?.companyRole}</p>}
|
||||
</div>
|
||||
|
||||
{profile?.biography && (
|
||||
@@ -143,40 +142,31 @@ export default function ProfilePage({ params }: PageProps) {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
{questions && questions.length >= 1 && (
|
||||
|
||||
<div className="mt-12">
|
||||
<h3>Discussions</h3>
|
||||
{questions.map((question) => {
|
||||
return (
|
||||
<Question
|
||||
key={question.id}
|
||||
question={question}
|
||||
apiHost={process.env.GATSBY_SQUEAK_API_HOST as string}
|
||||
organizationId={process.env.GATSBY_SQUEAK_ORG_ID as string}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Questions title="Discussions" profileId={id} showForm={false} />
|
||||
</div>
|
||||
)}
|
||||
</PostLayout>
|
||||
</Layout>
|
||||
</OrgProvider>
|
||||
</>
|
||||
) : null}
|
||||
</PostLayout>
|
||||
</Layout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ProfileSidebar = ({
|
||||
profile,
|
||||
setEditModalOpen,
|
||||
}: {
|
||||
profile?: SqueakProfile
|
||||
setEditModalOpen: () => boolean
|
||||
}) => {
|
||||
const name = [profile?.first_name, profile?.last_name].filter(Boolean).join(' ')
|
||||
const { user } = useUser()
|
||||
type ProfileSidebarProps = {
|
||||
profile: ProfileData
|
||||
setEditModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
handleEditProfile: () => void
|
||||
}
|
||||
|
||||
return profile ? (
|
||||
const ProfileSidebar: React.FC<ProfileSidebarProps> = ({ profile, setEditModalOpen, handleEditProfile }) => {
|
||||
const name = [profile.firstName, profile.lastName].filter(Boolean).join(' ')
|
||||
const [editProfile, setEditProfile] = useState(false)
|
||||
const { user } = useUser()
|
||||
const breakpoints = useBreakpoint()
|
||||
|
||||
return profile && !editProfile ? (
|
||||
<>
|
||||
{profile.github || profile.twitter || profile.linkedin || profile.website ? (
|
||||
<SidebarSection title="Links">
|
||||
@@ -229,42 +219,72 @@ const ProfileSidebar = ({
|
||||
</SidebarSection>
|
||||
) : null}
|
||||
|
||||
{profile.team ? (
|
||||
<>
|
||||
<SidebarSection title="Team">
|
||||
<span className="text-xl font-bold">{profile.team.name}</span>
|
||||
</SidebarSection>
|
||||
{profile.teams
|
||||
? profile.teams?.data?.map(({ attributes: { name, profiles } }) => {
|
||||
return (
|
||||
<>
|
||||
<SidebarSection title="Team">
|
||||
<span className="text-xl font-bold">{name}</span>
|
||||
</SidebarSection>
|
||||
|
||||
{profile.team.profiles.length > 0 ? (
|
||||
<SidebarSection title="Co-workers">
|
||||
<ul className="p-0 grid gap-y-2">
|
||||
{profile.team.profiles
|
||||
.filter(({ id }) => id !== profile.id)
|
||||
.map((profile) => {
|
||||
return (
|
||||
<li key={profile.id} className="flex items-center space-x-2">
|
||||
<Avatar className="w-8 h-8" src={profile.avatar} />
|
||||
<a href={`/community/profiles/${profile.id}`}>
|
||||
{profile.first_name} {profile.last_name}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</SidebarSection>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{profiles?.data?.length > 0 ? (
|
||||
<SidebarSection title="Co-workers">
|
||||
<ul className="p-0 grid gap-y-2">
|
||||
{profiles.data
|
||||
.filter(({ id }) => id !== profile.id)
|
||||
.map((profile) => {
|
||||
return (
|
||||
<li key={profile.id} className="flex items-center space-x-2">
|
||||
<Avatar className="w-8 h-8" src={getAvatarURL(profile)} />
|
||||
<a href={`/community/profiles/${profile.id}`}>
|
||||
{profile.attributes?.firstName}{' '}
|
||||
{profile.attributes?.lastName}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</SidebarSection>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
|
||||
{user?.profile?.id === profile.id && (
|
||||
<SidebarSection>
|
||||
<button onClick={() => setEditModalOpen(true)} className="text-base text-red font-semibold">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (breakpoints.md) {
|
||||
setEditProfile(true)
|
||||
} else {
|
||||
setEditModalOpen(true)
|
||||
}
|
||||
}}
|
||||
className="text-base text-red font-semibold"
|
||||
>
|
||||
Edit profile
|
||||
</button>
|
||||
</SidebarSection>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
<div className="pb-6">
|
||||
<div className="mb-4 flex flex-start items-center relative">
|
||||
<button
|
||||
onClick={() => setEditProfile(false)}
|
||||
className="inline-block font-bold bg-gray-accent-light dark:bg-gray-accent-dark mr-2 rounded-sm p-1"
|
||||
>
|
||||
<RightArrow className="w-6 rotate-180" />
|
||||
</button>
|
||||
<h5 className="m-0 text-base font-bold">Back</h5>
|
||||
</div>
|
||||
<EditProfile
|
||||
onSubmit={() => {
|
||||
handleEditProfile()
|
||||
setEditProfile(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import Layout from 'components/Layout'
|
||||
import PostLayout from 'components/PostLayout'
|
||||
import { SEO } from 'components/seo'
|
||||
import { createHubSpotContact } from 'lib/utils'
|
||||
import React from 'react'
|
||||
import { FullQuestion } from 'components/Squeak'
|
||||
import useSWR from 'swr'
|
||||
import { Question, useQuestion } from 'components/Squeak'
|
||||
import community from 'sidebars/community.json'
|
||||
|
||||
import type { Question } from 'components/Questions'
|
||||
import QuestionSidebar from 'components/Questions/QuestionSidebar'
|
||||
import Link from 'components/Link'
|
||||
import SEO from 'components/seo'
|
||||
|
||||
type QuestionPageProps = {
|
||||
params: {
|
||||
@@ -18,39 +15,26 @@ type QuestionPageProps = {
|
||||
}
|
||||
|
||||
export default function QuestionPage(props: QuestionPageProps) {
|
||||
const { data: question } = useSWR<Question>(
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/v1/questions?organizationId=${process.env.GATSBY_SQUEAK_ORG_ID}&permalink=${props.params.permalink}`,
|
||||
(url) =>
|
||||
fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then(({ questions }) => questions[0])
|
||||
)
|
||||
const { question, isLoading } = useQuestion(props.params.permalink)
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title={`${question?.subject} - PostHog`} />
|
||||
<SEO title={isLoading ? 'Squeak question - PostHog' : `${question?.attributes?.subject} - PostHog`} />
|
||||
<PostLayout
|
||||
title={question?.subject || ''}
|
||||
title={question?.attributes?.subject || ''}
|
||||
menu={community}
|
||||
sidebar={<QuestionSidebar question={question} />}
|
||||
hideSurvey
|
||||
>
|
||||
{question && (
|
||||
<section className="max-w-5xl mx-auto pb-12">
|
||||
<div className="mb-4">
|
||||
<Link to="/questions" className="text-gray hover:text-gray-accent-light">
|
||||
← Back to Questions
|
||||
</Link>
|
||||
</div>
|
||||
<section className="max-w-5xl mx-auto pb-12">
|
||||
<div className="mb-4">
|
||||
<Link to="/questions" className="text-gray hover:text-gray-accent-light">
|
||||
← Back to Questions
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FullQuestion
|
||||
apiHost={process.env.GATSBY_SQUEAK_API_HOST as string}
|
||||
organizationId={process.env.GATSBY_SQUEAK_ORG_ID as string}
|
||||
onSignUp={(user) => createHubSpotContact(user)}
|
||||
question={question}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<Question id={props.params.permalink} expanded={true} />
|
||||
</section>
|
||||
</PostLayout>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react'
|
||||
|
||||
import { Listbox } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/outline'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
|
||||
import Layout from 'components/Layout'
|
||||
import { SEO } from 'components/seo'
|
||||
@@ -11,24 +10,12 @@ import PostLayout from 'components/PostLayout'
|
||||
import SidebarSearchBox from 'components/Search/SidebarSearchBox'
|
||||
import QuestionsTable from 'components/Questions/QuestionsTable'
|
||||
import QuestionForm from 'components/Questions/QuestionForm'
|
||||
import { useQuestions } from 'hooks/useQuestions'
|
||||
|
||||
export default function Questions() {
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'activity' | 'popular'>('newest')
|
||||
|
||||
const { data, size, setSize, isLoading, mutate } = useSWRInfinite<any[]>(
|
||||
(offset) =>
|
||||
`${process.env.GATSBY_SQUEAK_API_HOST}/api/v1/questions?organizationId=${
|
||||
process.env.GATSBY_SQUEAK_ORG_ID
|
||||
}&start=${offset * 20}&perPage=20&published=true&sortBy=${sortBy}`,
|
||||
(url: string) =>
|
||||
fetch(url)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.questions)
|
||||
)
|
||||
|
||||
const questions = React.useMemo(() => {
|
||||
return data?.flat() || []
|
||||
}, [size, data])
|
||||
const { questions, isLoading, refresh, fetchMore } = useQuestions({ limit: 20, sortBy })
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -76,16 +63,15 @@ export default function Questions() {
|
||||
</Listbox.Options>
|
||||
</Listbox>
|
||||
|
||||
<QuestionForm onSubmit={() => mutate()} />
|
||||
<QuestionForm onSubmit={refresh} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-col">
|
||||
<QuestionsTable
|
||||
className="sm:grid-cols-4"
|
||||
questions={questions}
|
||||
size={size}
|
||||
setSize={setSize}
|
||||
isLoading={isLoading}
|
||||
fetchMore={fetchMore}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Slack } from 'components/Icons/Icons'
|
||||
import Layout from 'components/Layout'
|
||||
import PostLayout from 'components/PostLayout'
|
||||
import { SEO } from 'components/seo'
|
||||
import { squeakProfileLink } from 'lib/utils'
|
||||
import { Squeak } from 'components/Squeak'
|
||||
import { graphql } from 'gatsby'
|
||||
import Link from 'components/Link'
|
||||
@@ -20,7 +19,7 @@ interface IProps {
|
||||
data: {
|
||||
squeakTopic: {
|
||||
id: string
|
||||
topicId: string
|
||||
squeakId: number
|
||||
label: string
|
||||
}
|
||||
}
|
||||
@@ -62,15 +61,7 @@ export default function SqueakTopics({ data }: IProps) {
|
||||
|
||||
<h2>Questions tagged with "{data.squeakTopic.label}"</h2>
|
||||
|
||||
<Squeak
|
||||
profileLink={squeakProfileLink}
|
||||
limit={5}
|
||||
topics={false}
|
||||
slug={null}
|
||||
apiHost={process.env.GATSBY_SQUEAK_API_HOST as string}
|
||||
organizationId={process.env.GATSBY_SQUEAK_ORG_ID as string}
|
||||
topic={data.squeakTopic.topicId}
|
||||
/>
|
||||
<Squeak limit={5} slug={undefined} topicId={data.squeakTopic.squeakId} />
|
||||
</section>
|
||||
</PostLayout>
|
||||
</Layout>
|
||||
@@ -82,7 +73,7 @@ export const query = graphql`
|
||||
query ($id: String!) {
|
||||
squeakTopic(id: { eq: $id }) {
|
||||
id
|
||||
topicId
|
||||
squeakId
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
171
src/pages/questions/{SqueakQuestion.permalink}.tsx
Normal file
171
src/pages/questions/{SqueakQuestion.permalink}.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import Layout from 'components/Layout'
|
||||
import PostLayout from 'components/PostLayout'
|
||||
import { SEO } from 'components/seo'
|
||||
import { graphql } from 'gatsby'
|
||||
import React from 'react'
|
||||
import { Question } from 'components/Squeak'
|
||||
import community from 'sidebars/community.json'
|
||||
|
||||
import QuestionSidebar from 'components/Questions/QuestionSidebar'
|
||||
import Link from 'components/Link'
|
||||
import { QuestionData, StrapiRecord } from 'lib/strapi'
|
||||
|
||||
type QuestionPageProps = {
|
||||
pageContext: {
|
||||
id: string
|
||||
}
|
||||
data: {
|
||||
squeakQuestion: {
|
||||
id: string
|
||||
squeakId: number
|
||||
subject: string
|
||||
permalink: string
|
||||
resolved: boolean
|
||||
profile: {
|
||||
squeakId: number
|
||||
avatar: {
|
||||
url: string
|
||||
}
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
topics: {
|
||||
label: string
|
||||
}[]
|
||||
replies: {
|
||||
id: number
|
||||
publishedAt: string
|
||||
profile: {
|
||||
squeakId: number
|
||||
avatar: {
|
||||
url: string
|
||||
}
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
body: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}[]
|
||||
}
|
||||
}
|
||||
params: {
|
||||
permalink: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function QuestionPage(props: QuestionPageProps) {
|
||||
const { squeakQuestion } = props.data
|
||||
|
||||
// Remap the data to match the Strapi format
|
||||
const question: StrapiRecord<QuestionData> = {
|
||||
id: squeakQuestion.squeakId,
|
||||
attributes: {
|
||||
subject: squeakQuestion.subject,
|
||||
permalink: squeakQuestion.permalink,
|
||||
resolved: squeakQuestion.resolved,
|
||||
profile: {
|
||||
data: {
|
||||
id: squeakQuestion.profile.squeakId,
|
||||
attributes: {
|
||||
avatar: {
|
||||
data: {
|
||||
id: 0,
|
||||
attributes: {
|
||||
url: squeakQuestion.profile.avatar?.url,
|
||||
},
|
||||
},
|
||||
},
|
||||
firstName: squeakQuestion.profile.firstName,
|
||||
lastName: squeakQuestion.profile.lastName,
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: {
|
||||
data: squeakQuestion.replies.map((reply) => ({
|
||||
id: reply.id,
|
||||
attributes: {
|
||||
body: reply.body,
|
||||
createdAt: reply.createdAt,
|
||||
updatedAt: reply.updatedAt,
|
||||
publishedAt: reply.publishedAt,
|
||||
profile: {
|
||||
data: {
|
||||
id: reply.profile.squeakId,
|
||||
attributes: {
|
||||
avatar: {
|
||||
data: {
|
||||
id: 0,
|
||||
attributes: {
|
||||
url: reply.profile.avatar?.url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title={`${props.data.squeakQuestion.subject} - PostHog`} />
|
||||
<PostLayout
|
||||
title={props.data.squeakQuestion.subject}
|
||||
menu={community}
|
||||
sidebar={<QuestionSidebar question={question} />}
|
||||
hideSurvey
|
||||
>
|
||||
<section className="max-w-5xl mx-auto pb-12">
|
||||
<div className="mb-4">
|
||||
<Link to="/questions" className="text-gray hover:text-gray-accent-light">
|
||||
← Back to Questions
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Question id={props.data.squeakQuestion.squeakId} question={question} expanded={true} />
|
||||
</section>
|
||||
</PostLayout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export const query = graphql`
|
||||
query ($id: String!) {
|
||||
squeakQuestion(id: { eq: $id }) {
|
||||
id
|
||||
squeakId
|
||||
subject
|
||||
permalink
|
||||
resolved
|
||||
profile {
|
||||
squeakId
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
topics {
|
||||
label
|
||||
}
|
||||
replies {
|
||||
squeakId
|
||||
profile {
|
||||
squeakId
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
body
|
||||
publishedAt
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
15
src/pages/reset-password.tsx
Normal file
15
src/pages/reset-password.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Layout from 'components/Layout'
|
||||
import SEO from 'components/seo'
|
||||
import { Authentication } from 'components/Squeak'
|
||||
import React from 'react'
|
||||
|
||||
export default function ForgotPassword() {
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Reset password - PostHog.com" noindex />
|
||||
<div className="max-w-xl mx-auto mt-12 mb-16">
|
||||
<Authentication initialView="reset-password" showBanner={false} showProfile={false} />
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Changelog from 'components/Roadmap/Changelog'
|
||||
|
||||
export default Changelog
|
||||
@@ -1,192 +0,0 @@
|
||||
import Roadmap from 'components/Roadmap'
|
||||
import Layout from 'components/Layout'
|
||||
import { graphql, useStaticQuery } from 'gatsby'
|
||||
import React from 'react'
|
||||
import groupBy from 'lodash.groupby'
|
||||
import Link from 'components/Link'
|
||||
import { SEO } from 'components/seo'
|
||||
import PostLayout from 'components/PostLayout'
|
||||
import { OrgProvider } from 'components/Squeak'
|
||||
import Section from 'components/ProductPage/Section'
|
||||
import { FeatureSnapshot } from 'components/FeatureSnapshot'
|
||||
import { SidecarLogo } from 'components/SidecarLogo/sidecarLogo'
|
||||
|
||||
import banner from '../../images/sidecar-screenshot.png'
|
||||
|
||||
export default function RoadmapPage() {
|
||||
const HomePage = ({ data }) => {
|
||||
return <div>{data.site.siteMetadata.description}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="PostHog Roadmap" />
|
||||
<OrgProvider
|
||||
value={{
|
||||
organizationId: process.env.GATSBY_SQUEAK_ORG_ID,
|
||||
apiHost: process.env.GATSBY_SQUEAK_API_HOST,
|
||||
}}
|
||||
>
|
||||
<div className="border-t border-dashed border-gray-accent-light">
|
||||
<PostLayout
|
||||
darkMode={false}
|
||||
contentWidth={'100%'}
|
||||
article={false}
|
||||
title={'Roadmap'}
|
||||
hideSearch
|
||||
hideSurvey
|
||||
menu={[
|
||||
{ name: 'Questions', url: '/questions' },
|
||||
{ name: 'Roadmap', url: '/roadmap' },
|
||||
{ name: 'Contributors', url: '/contributors' },
|
||||
{ name: 'Core team', url: '/handbook/company/team' },
|
||||
]}
|
||||
>
|
||||
<SidecarLogo className="h-20 mb-8 mx-auto" />
|
||||
|
||||
<h1 className="font-bold text-center text-5xl mb-4 xl:mt-0 hidden">PostHog Sidecar</h1>
|
||||
<p className="font-medium text-center text-xl -mb-6 xl:mt-0">
|
||||
A Chrome extension that shows a user's data from PostHog in any SaaS tool
|
||||
</p>
|
||||
|
||||
<Section title="Test updates safely with feature flags" titleSize="md">
|
||||
<FeatureSnapshot
|
||||
image={banner}
|
||||
features={[
|
||||
<>
|
||||
<strong>More context for debugging</strong> get to session recordings, feature
|
||||
flags and events from any other SaaS product.
|
||||
</>,
|
||||
<>
|
||||
<strong>Context for support teams</strong> know if users are paying customers,
|
||||
which features they use and product actions they've taken.
|
||||
</>,
|
||||
<>
|
||||
<strong>User data in any tool</strong> with zero integration needed.
|
||||
</>,
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className="article-content px-5 lg:px-12 w-full transition-all lg:max-w-3xl mx-auto pb-6">
|
||||
<h3 className="font-bold text-5xl mb-8 xl:mt-0">How to install</h3>
|
||||
<ol>
|
||||
<li>
|
||||
Join our <Link to="/slack">community chat</Link>, and join the
|
||||
#team-website-and-docs channel - the zip file is pinned in the channel.
|
||||
</li>
|
||||
<li>Download the zip file and unzip it.</li>
|
||||
<li>
|
||||
Open the extensions menu in Chrome, switch to developer mode, then choose load
|
||||
unpacked extension and upload it.
|
||||
</li>
|
||||
<li>
|
||||
Chrome's extension store can be buggy - just restart after you enable developer mode
|
||||
if you have any issues.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3 className="font-bold text-5xl mb-8 xl:mt-0">How to use</h3>
|
||||
<ol>
|
||||
<li>
|
||||
Once Sidecar is installed, go to your CRM / customer support tool or anywhere you
|
||||
might find a customers's name.
|
||||
</li>
|
||||
<li>
|
||||
The Sidecar slide out will appear. Select where your PostHog instance exists (if you
|
||||
use cloud, you're probably on 'PostHog US')
|
||||
</li>
|
||||
<li>
|
||||
Insert a personal API key by going to https://app.posthog.com/me/settings (or
|
||||
https://example.com/me/settings if you self host)
|
||||
</li>
|
||||
<li>
|
||||
If you have an ad-blocker enabled, you may need to disable this before hitting
|
||||
'Next'.
|
||||
</li>
|
||||
<li>
|
||||
Now you can select which fields you display on the page. These can be either person
|
||||
or group properties.
|
||||
</li>
|
||||
<li>
|
||||
If you now search, Sidecar will search your PostHog data by email or by distinct ID.
|
||||
It will default search the string that is selected before you right click, but you
|
||||
can edit the search manually afterwards.
|
||||
</li>
|
||||
<li>Happy dealing with customers, with much more context.</li>
|
||||
</ol>
|
||||
|
||||
<h3 className="font-bold text-5xl mb-8 xl:mt-0">
|
||||
I found a bug, have feedback, or want to contribute
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
For feedback, please join our <Link to="/slack">community chat</Link>, and post it in
|
||||
the #team-website-and-docs channel.
|
||||
</p>
|
||||
<p>
|
||||
For bugs,{' '}
|
||||
<Link to="https://github.com/PostHog/sidecar/issues/new">create an issue</Link> in the
|
||||
Sidecar repo
|
||||
</p>
|
||||
<p>
|
||||
To contribute code, we're grateful for{' '}
|
||||
<Link to="https://github.com/PostHog/sidecar">pull requests in the sidecar repo</Link>.
|
||||
To make sure we can merge your work, we recommend talking about it with us in the Slack
|
||||
channel. We give merch for cool features.
|
||||
</p>
|
||||
|
||||
<h3 className="font-bold text-5xl mb-8 xl:mt-0">Feature ideas</h3>
|
||||
|
||||
<ul>
|
||||
<li>Inserting data into a page automatically, so there's no need to right click</li>
|
||||
<li>Inserting react components from PostHog into a page automatically</li>
|
||||
<li>Editable product-specific templates</li>
|
||||
<li>Community product-specific templates</li>
|
||||
<li>Inserting react components into the toolbar automatically</li>
|
||||
<li>
|
||||
Ability to update a user's data in the toolbar, and pushing it back into PostHog
|
||||
</li>
|
||||
<li>
|
||||
Ability to add customer user fields in the toolbar, and pushing it back into PostHog
|
||||
</li>
|
||||
<li>Ability to scrape a page and automatically update a user's profile</li>
|
||||
</ul>
|
||||
</div>
|
||||
</PostLayout>
|
||||
</div>
|
||||
</OrgProvider>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const query = graphql`
|
||||
{
|
||||
allSqueakRoadmap {
|
||||
nodes {
|
||||
beta_available
|
||||
complete
|
||||
date_completed
|
||||
title
|
||||
description
|
||||
team {
|
||||
name
|
||||
}
|
||||
otherLinks
|
||||
githubPages {
|
||||
title
|
||||
html_url
|
||||
number
|
||||
closed_at
|
||||
reactions {
|
||||
hooray
|
||||
heart
|
||||
eyes
|
||||
_1
|
||||
}
|
||||
}
|
||||
projected_completion_date
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
179
src/pages/roadmap/sidecar.tsx
Normal file
179
src/pages/roadmap/sidecar.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import Roadmap from 'components/Roadmap'
|
||||
import Layout from 'components/Layout'
|
||||
import { graphql, useStaticQuery } from 'gatsby'
|
||||
import React from 'react'
|
||||
import groupBy from 'lodash.groupby'
|
||||
import Link from 'components/Link'
|
||||
import { SEO } from 'components/seo'
|
||||
import PostLayout from 'components/PostLayout'
|
||||
import Section from 'components/ProductPage/Section'
|
||||
import { FeatureSnapshot } from 'components/FeatureSnapshot'
|
||||
import { SidecarLogo } from 'components/SidecarLogo/sidecarLogo'
|
||||
|
||||
import banner from '../../images/sidecar-screenshot.png'
|
||||
|
||||
export default function RoadmapPage() {
|
||||
const HomePage = ({ data }) => {
|
||||
return <div>{data.site.siteMetadata.description}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="PostHog Roadmap" />
|
||||
<div className="border-t border-dashed border-gray-accent-light">
|
||||
<PostLayout
|
||||
darkMode={false}
|
||||
contentWidth={'100%'}
|
||||
article={false}
|
||||
title={'Roadmap'}
|
||||
hideSearch
|
||||
hideSurvey
|
||||
menu={[
|
||||
{ name: 'Questions', url: '/questions' },
|
||||
{ name: 'Roadmap', url: '/roadmap' },
|
||||
{ name: 'Contributors', url: '/contributors' },
|
||||
{ name: 'Core team', url: '/handbook/company/team' },
|
||||
]}
|
||||
>
|
||||
<SidecarLogo className="h-20 mb-8 mx-auto" />
|
||||
|
||||
<h1 className="font-bold text-center text-5xl mb-4 xl:mt-0 hidden">PostHog Sidecar</h1>
|
||||
<p className="font-medium text-center text-xl -mb-6 xl:mt-0">
|
||||
A Chrome extension that shows a user's data from PostHog in any SaaS tool
|
||||
</p>
|
||||
|
||||
<Section title="Test updates safely with feature flags" titleSize="md">
|
||||
<FeatureSnapshot
|
||||
image={banner}
|
||||
features={[
|
||||
<>
|
||||
<strong>More context for debugging</strong> get to session recordings, feature flags
|
||||
and events from any other SaaS product.
|
||||
</>,
|
||||
<>
|
||||
<strong>Context for support teams</strong> know if users are paying customers, which
|
||||
features they use and product actions they've taken.
|
||||
</>,
|
||||
<>
|
||||
<strong>User data in any tool</strong> with zero integration needed.
|
||||
</>,
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className="article-content px-5 lg:px-12 w-full transition-all lg:max-w-3xl mx-auto pb-6">
|
||||
<h3 className="font-bold text-5xl mb-8 xl:mt-0">How to install</h3>
|
||||
<ol>
|
||||
<li>
|
||||
Join our <Link to="/slack">community chat</Link>, and join the #team-website-and-docs
|
||||
channel - the zip file is pinned in the channel.
|
||||
</li>
|
||||
<li>Download the zip file and unzip it.</li>
|
||||
<li>
|
||||
Open the extensions menu in Chrome, switch to developer mode, then choose load unpacked
|
||||
extension and upload it.
|
||||
</li>
|
||||
<li>
|
||||
Chrome's extension store can be buggy - just restart after you enable developer mode if
|
||||
you have any issues.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3 className="font-bold text-5xl mb-8 xl:mt-0">How to use</h3>
|
||||
<ol>
|
||||
<li>
|
||||
Once Sidecar is installed, go to your CRM / customer support tool or anywhere you might
|
||||
find a customers's name.
|
||||
</li>
|
||||
<li>
|
||||
The Sidecar slide out will appear. Select where your PostHog instance exists (if you use
|
||||
cloud, you're probably on 'PostHog US')
|
||||
</li>
|
||||
<li>
|
||||
Insert a personal API key by going to https://app.posthog.com/me/settings (or
|
||||
https://example.com/me/settings if you self host)
|
||||
</li>
|
||||
<li>
|
||||
If you have an ad-blocker enabled, you may need to disable this before hitting 'Next'.
|
||||
</li>
|
||||
<li>
|
||||
Now you can select which fields you display on the page. These can be either person or
|
||||
group properties.
|
||||
</li>
|
||||
<li>
|
||||
If you now search, Sidecar will search your PostHog data by email or by distinct ID. It
|
||||
will default search the string that is selected before you right click, but you can edit
|
||||
the search manually afterwards.
|
||||
</li>
|
||||
<li>Happy dealing with customers, with much more context.</li>
|
||||
</ol>
|
||||
|
||||
<h3 className="font-bold text-5xl mb-8 xl:mt-0">
|
||||
I found a bug, have feedback, or want to contribute
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
For feedback, please join our <Link to="/slack">community chat</Link>, and post it in the
|
||||
#team-website-and-docs channel.
|
||||
</p>
|
||||
<p>
|
||||
For bugs, <Link to="https://github.com/PostHog/sidecar/issues/new">create an issue</Link> in
|
||||
the Sidecar repo
|
||||
</p>
|
||||
<p>
|
||||
To contribute code, we're grateful for{' '}
|
||||
<Link to="https://github.com/PostHog/sidecar">pull requests in the sidecar repo</Link>. To
|
||||
make sure we can merge your work, we recommend talking about it with us in the Slack
|
||||
channel. We give merch for cool features.
|
||||
</p>
|
||||
|
||||
<h3 className="font-bold text-5xl mb-8 xl:mt-0">Feature ideas</h3>
|
||||
|
||||
<ul>
|
||||
<li>Inserting data into a page automatically, so there's no need to right click</li>
|
||||
<li>Inserting react components from PostHog into a page automatically</li>
|
||||
<li>Editable product-specific templates</li>
|
||||
<li>Community product-specific templates</li>
|
||||
<li>Inserting react components into the toolbar automatically</li>
|
||||
<li>Ability to update a user's data in the toolbar, and pushing it back into PostHog</li>
|
||||
<li>
|
||||
Ability to add customer user fields in the toolbar, and pushing it back into PostHog
|
||||
</li>
|
||||
<li>Ability to scrape a page and automatically update a user's profile</li>
|
||||
</ul>
|
||||
</div>
|
||||
</PostLayout>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const query = graphql`
|
||||
{
|
||||
allSqueakRoadmap {
|
||||
nodes {
|
||||
beta_available
|
||||
complete
|
||||
dateCompleted
|
||||
title
|
||||
description
|
||||
team {
|
||||
name
|
||||
}
|
||||
otherLinks
|
||||
githubPages {
|
||||
title
|
||||
html_url
|
||||
number
|
||||
closed_at
|
||||
reactions {
|
||||
hooray
|
||||
heart
|
||||
eyes
|
||||
_1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -18504,6 +18504,13 @@ qs@^6.10.0, qs@^6.9.4:
|
||||
dependencies:
|
||||
side-channel "^1.0.4"
|
||||
|
||||
qs@^6.11.1:
|
||||
version "6.11.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.1.tgz#6c29dff97f0c0060765911ba65cbc9764186109f"
|
||||
integrity sha512-0wsrzgTz/kAVIeuxSjnpGC56rzYtr6JT/2BwEvMaPhFIoYa1aGO8LbzuU1R0uUYQkLpWBTOj0l/CLAJB64J6nQ==
|
||||
dependencies:
|
||||
side-channel "^1.0.4"
|
||||
|
||||
"qs@^6.5.1 < 6.10":
|
||||
version "6.9.7"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe"
|
||||
|
||||
Reference in New Issue
Block a user