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:
Paul Hultgren
2023-04-13 10:50:52 -07:00
committed by GitHub
parent 948ab8e988
commit dc4b272a15
79 changed files with 3387 additions and 3240 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},
},
{

View File

@@ -98,7 +98,7 @@ module.exports = {
{
query: `
{
questions: allQuestion(filter: {permalink: {ne: null}}) {
questions: allSqueakQuestion(filter: {permalink: {ne: null}}) {
nodes {
id
title: subject

View File

@@ -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)
}

View File

@@ -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({

View File

@@ -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",

View File

@@ -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")
}
`)
}

View 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
View 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
);

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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 (

View File

@@ -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"

View File

@@ -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>

View File

@@ -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}`}>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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`}>

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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
}[]
}

View File

@@ -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
}
}
}
`

View File

@@ -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}. Well 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>

View File

@@ -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={
<>
Heres 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={
<>
Heres 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
}
}
}
`

View File

@@ -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>
)
}

View File

@@ -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()

View 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"
>
&#215;
</button>
)}
<div className="relative w-full h-full flex items-center justify-center group">
<span className="text-3xl">&#8593;</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>
)
}

View File

@@ -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
}
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
})}
</>
)
}

View File

@@ -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
}

View File

@@ -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 />

View 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>
)
}

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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 }
}
}

View File

@@ -1 +0,0 @@
export * from './client'

View 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
)
}

View File

@@ -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
View 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
View 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
}
`

View File

@@ -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
View 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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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
}
}

View 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
}
}
}
`

View 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>
)
}

View File

@@ -1,3 +0,0 @@
import Changelog from 'components/Roadmap/Changelog'
export default Changelog

View File

@@ -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
}
}
}
`

View 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
}
}
}
}
}
`

View File

@@ -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"